From 73443ebed487347e4d0c84de93e6288a62c68704 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 19:40:51 -0700 Subject: [PATCH] fix: stopping a session leaves subagents running with no stop control (#122909) * fix(gateway): cascade session stop to subagents Cascade exact and session-wide aborts through the subagent registry and keep Control UI Stop visible while descendants remain active. * refactor(gateway): absorb stop cascade orchestration * chore(gateway): refresh plugin SDK API contract for abort refactor Regenerates the export-closure contract after the stop-cascade refactor moved gateway abort orchestration exports, and drops a redundant Boolean() wrapper flagged by lint. Public SDK surface gate is unchanged. * chore(gateway): regenerate plugin SDK API baseline --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- .../registry/subagent-control-kill.ts | 2 + src/gateway/chat-abort.test.ts | 3 +- src/gateway/chat-abort.ts | 16 -- src/gateway/server-close.ts | 4 +- src/gateway/server-maintenance.ts | 4 +- .../agent.abort-integration.test-utils.ts | 74 +++++++ .../server-methods/chat-abort-handler.ts | 38 ++++ .../server-methods/chat-abort-runtime.ts | 200 +++++++++--------- .../session-active-runs.test.ts | 5 +- .../server-methods/session-active-runs.ts | 50 ++--- src/gateway/server-methods/sessions-abort.ts | 8 +- .../server-methods/sessions-compact.ts | 6 +- src/gateway/server-methods/sessions-rewind.ts | 6 +- .../sessions.abort-agent-scope.test.ts | 67 +++++- src/gateway/session-utils-row.ts | 6 +- src/gateway/session-utils.subagent.test.ts | 4 + ui/src/pages/chat/run-lifecycle.test.ts | 24 +++ ui/src/pages/chat/run-lifecycle.ts | 3 +- 34 files changed, 357 insertions(+), 195 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index c49521ed510d..5b2b8281b8ef 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"7799f5c1879ff22e475239dc4787ea57ea527fabf637c9cf9fd30046ab7d153e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"a5cf59f64e5a88c3affe8a177f9bb4bd38e80c364506509b7fc6427a211bd371","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 5b2a18c44b15..64bada020b8a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"d622706dc586fefb443bed5527aa162c922bce5659df715e2be996b843cfeb3f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"4f73f681d60fd6eed1abe5e4d542c456798e5221fa2c97dcd480d1f33a4b1ecc","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 4023ecfb3d7a..fcff48a5ddaf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"793f49ac45d6a2d24b95b5631af9a4afed2d21d1d2cceb39904ef47ba3376aad","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"a5062fea6e891209423fcfeae5a88c92695f672d80181b646951879e0613e911","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 006d01fc7a58..f447bdae090f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"26d071867a0d71a4ca01b29f359e2a43744043246ecb3dc4760147eef2bceedd","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"1897ffe4a97dfe51f41f506e443ab06529463c9366ee1bed542ee807684e32aa","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index ea6663b17efa..fef4a91f3436 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"34d78d7f9059d6092f5734054493ffc09702a27035d49900c66cb53d4ce89305","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"74285633ffd2690a31f53c3b612b74aef87e887935e11e84e9898e48f4db5d75","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index ba9a61650b82..13408964bde3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"ed0d2d109ab6800f5031f1535c28967de54cd63e93b8d0a0e4c089f8433e39b9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"331508088936dad14f0caef18bf8d26d51e6db76febaa4045567b97212cff647","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 2423cc0b588d..a98ed1aa5fea 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"119b44fd2f8e8b45d7c1fc3fe794c5a1e78504d7d1c0d02ce68ea7862bc1e799","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"8a776a6fa7cca763c8c80b882a10cea91c7d3f0e7fdb637217c7698f27f7b749","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 5fe2e0e50a28..f5bb04d7a770 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"ca67465d7347811160c037115f309700e31afcbeea2fd323d9965f5270dab44f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"90014e82f746dd9cdfad10b81132431e34da5eb7b06f489e70affc4e3dac5921","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index ec1e36e7b36c..8e40487020f4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"6c2277b60d16f2cb4fe263d462eea07cb929509e4ffe883b4120f64feccd75b4","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"e40b030c61bbdcf0c7c15620a47ff88a2d0e9aac8d363c019459cece7dfff47c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index fe830e549d2d..b6f87cfde78c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"3e3cdf7fa4f1ea45c01b697daaa2794224123383ef9344dd35d987aff2583f44","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"a4f9073153fbd595667d0e9f7375f17046e7f8d0fe88d317359420125e4ce563","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 56b64b5f65ec..509cba0803f8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"2fb26601bfff725440c436a5cc9ee9dfcdb4e959a242aec70387ba207c69f94a","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"f643813855b0359515d0faf0c58b9ef323399e5ba7ffced29ef81305051d66aa","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index b9c95b29a970..17173f171ab8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"72704f536605a38a38826a3e430037fd65bfaa80d7d7f60403cc95430b29c16b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"0f9097423b1b563db164cab7cc7e1f9d1eaed13eb12ce16523a72d8b1614375b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 167c442d29ec..38dc8c334889 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"8481323e3608484d879ca27d447a462c08b7109e414d33da270860b0ecffd83b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"cdb8cb034a2e2f4cb2e511b706c964ddc2704761d17feceb2999fa41e87feea7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index e880e7abc2a2..8042c0bbb7dd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"183975ef6f81b63d1dd9b0962a55593f10dde7d84ac448321c33623ce092b966","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"6d565d7d935dd58b836d23335b7e75a2985e34740b42e06d78f02e743f2ae374","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index e28723f3c77f..36d860502618 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"0f556444bd92ec15451542963dccced9708aa38864d50ab72f778960c9046d09","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"3f9b816df61c93c1e3e78245451f69878bc53d194c6737f6d4770728387a9061","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 82a62ff67a3f..dafa2f5b1046 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"cde91c704c73a080198c25b797a00150aa8e90f42f87503110763d6c1bf8edac","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"c7d16f543fc05054a0dc3b9b3206b9ba383f18244631ce83d4bf7c46ee26dab3","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/src/agents/subagents/registry/subagent-control-kill.ts b/src/agents/subagents/registry/subagent-control-kill.ts index fbdb7d1d0b1a..0b488f5879df 100644 --- a/src/agents/subagents/registry/subagent-control-kill.ts +++ b/src/agents/subagents/registry/subagent-control-kill.ts @@ -115,6 +115,7 @@ export async function killAllControlledSubagentRuns(params: { cfg: OpenClawConfig; controller: ResolvedSubagentController; runs: SubagentRunRecord[]; + suppressTaskDelivery?: boolean; }) { if (params.controller.controlScope !== "children") { return { @@ -130,6 +131,7 @@ export async function killAllControlledSubagentRuns(params: { cache: new Map>(), seenChildSessionKeys: new Set(), controllerSessionKey: params.controller.controllerSessionKey, + suppressTaskDelivery: params.suppressTaskDelivery, }); if (result.errors.length > 0) { return { diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 007f00e2fbb0..0adae6b29336 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -8,7 +8,6 @@ import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; import { abortChatRunById, abortChatRunsForProvider, - abortTrackedChatRunById, boundInFlightRunSnapshotForChatHistory, isChatStopCommandText, registerChatAbortController, @@ -594,7 +593,7 @@ describe("abortChatRunById", () => { name: "preserves default-agent global delivery through tracked maintenance aborts", runId: "run-tracked-global", createEntry: () => ({ ...createActiveEntry("global"), agentId: "main" }), - abort: abortTrackedChatRunById, + abort: abortChatRunById, }, ]) { it(testCase.name, () => { diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 1258344e624a..09a49544c4c8 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -505,22 +505,6 @@ export type ChatAbortOps = { onRunAborted?: (runId: string) => void; }; -type TrackedChatRunAbortOps = { - chatAbortControllers: ChatAbortOps["chatAbortControllers"]; - chatRunState: ChatAbortOps["chatRunState"]; - removeChatRun: ChatAbortOps["removeChatRun"]; - agentRunSeq: ChatAbortOps["agentRunSeq"]; - broadcast: ChatAbortOps["broadcast"]; - nodeSendToSession: ChatAbortOps["nodeSendToSession"]; -}; - -export function abortTrackedChatRunById( - ops: TrackedChatRunAbortOps, - params: Parameters[1], -) { - return abortChatRunById(ops, params); -} - function resolveChatAbortDeliverySessionKeys( ops: ChatAbortOps, sessionKey: string, diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 96796619ffe7..a932602f0b7c 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -19,7 +19,7 @@ import { clearActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import { drainGlobalSingletonLifecycleState } from "../shared/global-singleton.js"; import { - abortTrackedChatRunById, + abortChatRunById, type ChatAbortControllerEntry, isChatAbortControllerEntryAbortable, removeChatAbortControllerEntry, @@ -447,7 +447,7 @@ function abortActiveRunsForRestart(params: RestartRunAbortParams): number { aborted += 1; continue; } - const result = abortTrackedChatRunById(params, { + const result = abortChatRunById(params, { runId, sessionKey: entry.sessionKey, stopReason: "restart", diff --git a/src/gateway/server-maintenance.ts b/src/gateway/server-maintenance.ts index 4489458534ce..72f6446f66ad 100644 --- a/src/gateway/server-maintenance.ts +++ b/src/gateway/server-maintenance.ts @@ -19,7 +19,7 @@ import { startSkillCollectionMaintenance, } from "../skills/workshop/collection-review.js"; import { - abortTrackedChatRunById, + abortChatRunById, type ChatAbortControllerEntry, removeChatAbortControllerEntry, type RestartRecoveryCandidate, @@ -309,7 +309,7 @@ export function startGatewayMaintenanceTimers(params: { removeChatAbortControllerEntry(params.chatAbortControllers, runId, entry); continue; } - abortTrackedChatRunById(params, { + abortChatRunById(params, { runId, sessionKey: entry.sessionKey, stopReason: "timeout", diff --git a/src/gateway/server-methods/agent.abort-integration.test-utils.ts b/src/gateway/server-methods/agent.abort-integration.test-utils.ts index e7af40975347..c750cac2b25a 100644 --- a/src/gateway/server-methods/agent.abort-integration.test-utils.ts +++ b/src/gateway/server-methods/agent.abort-integration.test-utils.ts @@ -2,6 +2,11 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerExecApprovalFollowupRuntimeHandoff } from "../../agents/bash-tools.exec-approval-followup-state.js"; +import { + addSubagentRunForTests, + getSubagentRunByChildSessionKey, + testing as subagentRegistryTesting, +} from "../../agents/subagents/registry/subagent-registry.test-helpers.js"; import type { InternalSessionEntry as SessionEntry } from "../../config/sessions.js"; import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js"; import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js"; @@ -1939,6 +1944,75 @@ describe("gateway agent handler chat.abort integration", () => { expect(context.chatAbortControllers.has(runId)).toBe(false); }); + it("chat.abort by runId kills only subagents owned by that requester turn", async () => { + prime(); + subagentRegistryTesting.setDepsForTest({ + persistSubagentRunsToDisk: () => {}, + persistSubagentRunsToDiskOrThrow: () => {}, + }); + const pending = new Promise(() => {}); + let capturedSignal: AbortSignal | undefined; + mocks.agentCommand.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => { + capturedSignal = opts.abortSignal; + return pending; + }); + + const context = makeContext(); + const runId = "idem-abort-owned-subagents"; + const ownedChildSessionKey = "agent:main:subagent:owned-by-aborted-turn"; + const unrelatedChildSessionKey = "agent:main:subagent:owned-by-other-turn"; + await invokeAgent( + { + message: "hi", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: runId, + }, + { context, reqId: runId }, + ); + for (const [childSessionKey, requesterTurnRunId] of [ + [ownedChildSessionKey, runId], + [unrelatedChildSessionKey, "other-parent-turn"], + ] as const) { + addSubagentRunForTests({ + runId: `child-${requesterTurnRunId}`, + childSessionKey, + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterAgentId: "main", + requesterTurnRunId, + task: requesterTurnRunId, + cleanup: "keep", + createdAt: Date.now() - 2_000, + startedAt: Date.now() - 1_000, + }); + } + + const abortRespond = vi.fn(); + await expectDefined( + chatHandlers["chat.abort"], + 'chatHandlers["chat.abort"] test invariant', + )({ + params: { sessionKey: "agent:main:main", runId }, + respond: abortRespond as never, + context, + req: { type: "req", id: "abort-req", method: "chat.abort" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mockCallArg(abortRespond)).toBe(true); + expect(capturedSignal?.aborted).toBe(true); + expect(getSubagentRunByChildSessionKey(ownedChildSessionKey)).toMatchObject({ + endedReason: "subagent-killed", + killReconciliation: { suppressTaskDelivery: true }, + }); + expect( + getSubagentRunByChildSessionKey(unrelatedChildSessionKey)?.execution.endedAt, + ).toBeUndefined(); + }); + it("chat.abort by runId allows the owner connection to use a stale session key", async () => { prime(); const pending = new Promise(() => {}); diff --git a/src/gateway/server-methods/chat-abort-handler.ts b/src/gateway/server-methods/chat-abort-handler.ts index 2210cacf74f0..3c5ea1ee15a2 100644 --- a/src/gateway/server-methods/chat-abort-handler.ts +++ b/src/gateway/server-methods/chat-abort-handler.ts @@ -29,6 +29,7 @@ import { cancelWorkerInferenceForSession, createChatAbortOps, persistAbortedPartials, + prepareControlledSubagentAbort, } from "./chat-abort-runtime.js"; import { normalizeOptionalChatText as normalizeOptionalText, @@ -40,6 +41,7 @@ import { assertValidParams } from "./validation.js"; type ChatAbortLifecycle = { onAuthorizedAfterQueuedAbort?: () => boolean; excludeRunIds?: ReadonlySet; + cascadeDescendants?: true; }; type ChatAbortTarget = Pick< @@ -47,6 +49,18 @@ type ChatAbortTarget = Pick< "sessionKey" | "agentId" | "ownerConnId" | "ownerDeviceId" >; +function descendantAbortError( + result: Awaited>>, + subject: "Parent run" | "Session", +) { + return result && result.status !== "ok" + ? errorShape( + ErrorCodes.UNAVAILABLE, + `${subject} stopped, but descendant cancellation was incomplete: ${result.error}`, + ) + : undefined; +} + export async function handleChatAbortRequestWithLifecycle( { params, respond, context, client }: GatewayRequestHandlerOptions, lifecycle: ChatAbortLifecycle = {}, @@ -155,6 +169,19 @@ export async function handleChatAbortRequestWithLifecycle( respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized")); return; } + if (lifecycle.cascadeDescendants) { + const descendants = await prepareControlledSubagentAbort({ + cfg: abortCfg, + sessionKey: canonicalAbortSessionKey, + agentId: abortAgentId, + })(); + const error = descendantAbortError(descendants, "Session"); + if (error) { + respond(false, undefined, error); + return; + } + res.aborted ||= Boolean(descendants?.killed); + } respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds }); return; } @@ -292,6 +319,12 @@ export async function handleChatAbortRequestWithLifecycle( if (!authorizeRunTarget(active)) { return; } + const abortControlledSubagents = prepareControlledSubagentAbort({ + cfg: abortCfg, + sessionKey: active.sessionKey, + agentId: active.agentId, + requesterTurnRunId: runId, + }); const partialText = context.chatRunState.resolveBuffer(runId).text; const res = abortChatRunById(ops, { @@ -314,6 +347,11 @@ export async function handleChatAbortRequestWithLifecycle( ], }); } + const descendantError = descendantAbortError(await abortControlledSubagents(), "Parent run"); + if (descendantError) { + respond(false, undefined, descendantError); + return; + } respondWithWorkerRuns(res.aborted ? [runId] : [], active.sessionId); } diff --git a/src/gateway/server-methods/chat-abort-runtime.ts b/src/gateway/server-methods/chat-abort-runtime.ts index 7e35f9088d60..9f67c50ac053 100644 --- a/src/gateway/server-methods/chat-abort-runtime.ts +++ b/src/gateway/server-methods/chat-abort-runtime.ts @@ -1,3 +1,9 @@ +import { + killAllControlledSubagentRuns, + resolveSubagentController, +} from "../../agents/subagents/registry/subagent-control.js"; +import { listSubagentRunsForController } from "../../agents/subagents/registry/subagent-registry-read.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { abortChatRunById, type ChatAbortControllerEntry, @@ -25,6 +31,36 @@ import type { GatewayRequestContext } from "./types.js"; type AbortOrigin = "rpc" | "stop-command"; +export function prepareControlledSubagentAbort(params: { + cfg: OpenClawConfig; + sessionKey: string; + agentId?: string; + requesterTurnRunId?: string; +}) { + const controller = resolveSubagentController({ + cfg: params.cfg, + agentSessionKey: params.sessionKey, + agentId: params.agentId, + }); + const runs = listSubagentRunsForController( + controller.controllerSessionKey, + controller.controllerAgentId, + ).filter( + (entry) => + params.requesterTurnRunId === undefined || + entry.requesterTurnRunId === params.requesterTurnRunId, + ); + return async () => + runs.length === 0 + ? undefined + : await killAllControlledSubagentRuns({ + cfg: params.cfg, + controller, + runs, + suppressTaskDelivery: true, + }); +} + const SESSION_LIFECYCLE_ABORT_REQUESTER: ChatAbortRequester = { isAdmin: true }; type AbortedPartialSnapshot = { @@ -35,40 +71,15 @@ type AbortedPartialSnapshot = { abortOrigin: AbortOrigin; }; -function collectSessionAbortPartials(params: { - chatRunState: GatewayRequestContext["chatRunState"]; - runs: ReadonlyArray<{ runId: string; entry: ChatAbortControllerEntry }>; - abortOrigin: AbortOrigin; -}): AbortedPartialSnapshot[] { - const out: AbortedPartialSnapshot[] = []; - for (const { runId, entry } of params.runs) { - const text = params.chatRunState.resolveBuffer(runId).text; - if (!text || !text.trim()) { - continue; - } - out.push({ - runId, - sessionId: entry.sessionId, - agentId: entry.agentId, - text, - abortOrigin: params.abortOrigin, - }); - } - return out; -} - export async function persistAbortedPartials(params: { context: Pick; sessionKey: string; snapshots: AbortedPartialSnapshot[]; }): Promise { - if (params.snapshots.length === 0) { - return; - } for (const snapshot of params.snapshots) { const sessionLoadOptions = snapshot.agentId ? { agentId: snapshot.agentId } : undefined; const { cfg, storePath, entry } = loadSessionEntry(params.sessionKey, sessionLoadOptions); - const sessionId = entry?.sessionId ?? snapshot.sessionId ?? snapshot.runId; + const sessionId = entry?.sessionId ?? snapshot.sessionId; const appended = await appendAssistantTranscriptMessage({ sessionKey: params.sessionKey, message: snapshot.text, @@ -139,44 +150,34 @@ type SessionAbortOwnerParams = { /** Authoritative active, pending, or queued Gateway owner for an exact session. */ export function hasGatewaySessionAbortOwner(params: SessionAbortOwnerParams): boolean { - const active = resolveAuthorizedRunsForSessionKeys({ - chatAbortControllers: params.context.chatAbortControllers, + const ownerScope = { sessionKeys: params.sessionKeys, - sessionIds: [params.sessionId], agentId: params.agentId, defaultAgentId: params.defaultAgentId, requester: SESSION_LIFECYCLE_ABORT_REQUESTER, - includeProtectedRuns: true, - }); - if (active.authorizedRuns.length > 0) { - return true; - } - const queued = resolveAuthorizedQueuedTurnsForSession({ - context: params.context, - sessionKeys: params.sessionKeys, - sessionId: params.sessionId, - agentId: params.agentId, - defaultAgentId: params.defaultAgentId, - requester: SESSION_LIFECYCLE_ABORT_REQUESTER, - }); - if (queued.authorized.length > 0) { - return true; - } - for (const keyPrefix of ["agent:", PENDING_CHAT_SEND_DEDUPE_PREFIX]) { - const pending = resolveAuthorizedPreRegisteredRunsForSessionKeys({ - context: params.context, - sessionKeys: params.sessionKeys, - agentId: params.agentId, - defaultAgentId: params.defaultAgentId, - requester: SESSION_LIFECYCLE_ABORT_REQUESTER, - keyPrefix, + }; + return ( + resolveAuthorizedRunsForSessionKeys({ + chatAbortControllers: params.context.chatAbortControllers, + sessionIds: [params.sessionId], + ...ownerScope, includeProtectedRuns: true, - }); - if (pending.authorizedRuns.length > 0) { - return true; - } - } - return false; + }).authorizedRuns.length > 0 || + resolveAuthorizedQueuedTurnsForSession({ + context: params.context, + sessionId: params.sessionId, + ...ownerScope, + }).authorized.length > 0 || + ["agent:", PENDING_CHAT_SEND_DEDUPE_PREFIX].some( + (keyPrefix) => + resolveAuthorizedPreRegisteredRunsForSessionKeys({ + context: params.context, + ...ownerScope, + keyPrefix, + includeProtectedRuns: true, + }).authorizedRuns.length > 0, + ) + ); } export function cancelWorkerInferenceForSession(params: { @@ -245,43 +246,25 @@ export async function abortChatRunsForSessionKeyWithPartials(params: { includeProtectedRuns: params.includeProtectedRuns, excludeRunIds: params.excludeRunIds, }); - const { - authorizedRuns: authorizedPendingAgentRuns, - hasUnauthorizedRuns: hasUnauthorizedPendingAgentRuns, - hasUnauthorizedProtectedRuns: hasUnauthorizedProtectedPendingAgentRuns, - hasProtectedRuns: hasProtectedPendingAgentRuns, - } = resolveAuthorizedPreRegisteredRunsForSessionKeys({ - context: params.context, - sessionKeys, - agentId: params.agentId, - defaultAgentId: params.defaultAgentId, - requester: params.requester, - keyPrefix: "agent:", - preserveSideRuns: params.preserveSideRuns, - includeProtectedRuns: params.includeProtectedRuns, - excludeRunIds: params.excludeRunIds, - }); - const { - authorizedRuns: authorizedPendingChatRuns, - hasUnauthorizedRuns: hasUnauthorizedPendingChatRuns, - hasUnauthorizedProtectedRuns: hasUnauthorizedProtectedPendingChatRuns, - hasProtectedRuns: hasProtectedPendingChatRuns, - } = resolveAuthorizedPreRegisteredRunsForSessionKeys({ - context: params.context, - sessionKeys, - agentId: params.agentId, - defaultAgentId: params.defaultAgentId, - requester: params.requester, - keyPrefix: PENDING_CHAT_SEND_DEDUPE_PREFIX, - preserveSideRuns: params.preserveSideRuns, - includeProtectedRuns: params.includeProtectedRuns, - excludeRunIds: params.excludeRunIds, - }); + const resolvePendingRuns = (keyPrefix: string) => + resolveAuthorizedPreRegisteredRunsForSessionKeys({ + context: params.context, + sessionKeys, + agentId: params.agentId, + defaultAgentId: params.defaultAgentId, + requester: params.requester, + keyPrefix, + preserveSideRuns: params.preserveSideRuns, + includeProtectedRuns: params.includeProtectedRuns, + excludeRunIds: params.excludeRunIds, + }); + const pendingAgent = resolvePendingRuns("agent:"); + const pendingChat = resolvePendingRuns(PENDING_CHAT_SEND_DEDUPE_PREFIX); + const pendingPlans = [pendingAgent, pendingChat]; const hasAuthorizedGatewayRuns = authorizedRuns.length > 0 || - authorizedPendingAgentRuns.length > 0 || - authorizedPendingChatRuns.length > 0 || - queuedPlan.authorized.length > 0; + queuedPlan.authorized.length > 0 || + pendingPlans.some((plan) => plan.authorizedRuns.length > 0); const workerService = asWorkerInferenceControl(params.context.workerEnvironmentService); const workerSessionId = params.sessionId; const hasWorkerRun = Boolean( @@ -302,16 +285,14 @@ export async function abortChatRunsForSessionKeyWithPartials(params: { ); const hasUnauthorizedOwner = hasUnauthorizedActiveRuns || - hasUnauthorizedPendingAgentRuns || - hasUnauthorizedPendingChatRuns || queuedPlan.hasUnauthorizedRuns || + pendingPlans.some((plan) => plan.hasUnauthorizedRuns) || (hasWorkerRun && !hasControllerRepresentedWorkerRun && !params.requester.isAdmin); const hasProtectedLifecycleRuns = - hasProtectedActiveRuns || hasProtectedPendingAgentRuns || hasProtectedPendingChatRuns; + hasProtectedActiveRuns || pendingPlans.some((plan) => plan.hasProtectedRuns); const hasUnauthorizedProtectedOwner = hasUnauthorizedProtectedActiveRuns || - hasUnauthorizedProtectedPendingAgentRuns || - hasUnauthorizedProtectedPendingChatRuns; + pendingPlans.some((plan) => plan.hasUnauthorizedProtectedRuns); const hasUnauthorizedLifecycleOwner = Boolean(params.onAuthorizedAfterQueuedAbort) && hasUnauthorizedProtectedOwner; const canRunLifecycleCleanup = !hasUnauthorizedOwner && !hasProtectedLifecycleRuns; @@ -343,10 +324,19 @@ export async function abortChatRunsForSessionKeyWithPartials(params: { unauthorized: false, }; } - const snapshots = collectSessionAbortPartials({ - chatRunState: params.context.chatRunState, - runs: authorizedRuns, - abortOrigin: params.abortOrigin, + const snapshots = authorizedRuns.flatMap(({ runId, entry }) => { + const text = params.context.chatRunState.resolveBuffer(runId).text; + return text?.trim() + ? [ + { + runId, + sessionId: entry.sessionId, + agentId: entry.agentId, + text, + abortOrigin: params.abortOrigin, + }, + ] + : []; }); // Abort queued owners before any active-work signal can promote a successor. // Keep them first in the response to preserve the established runIds ordering. @@ -372,7 +362,7 @@ export async function abortChatRunsForSessionKeyWithPartials(params: { } const endedAt = Date.now(); const stopReason = params.stopReason ?? "rpc"; - for (const { runId, sessionKey, payload } of authorizedPendingAgentRuns) { + for (const { runId, sessionKey, payload } of pendingAgent.authorizedRuns) { writePreRegisteredAgentAbort({ context: params.context, runId, @@ -383,7 +373,7 @@ export async function abortChatRunsForSessionKeyWithPartials(params: { }); runIds.push(runId); } - for (const { runId, payload } of authorizedPendingChatRuns) { + for (const { runId, payload } of pendingChat.authorizedRuns) { writePreRegisteredChatAbort({ context: params.context, runId, diff --git a/src/gateway/server-methods/session-active-runs.test.ts b/src/gateway/server-methods/session-active-runs.test.ts index 054e9be09099..b5fa6aa28dba 100644 --- a/src/gateway/server-methods/session-active-runs.test.ts +++ b/src/gateway/server-methods/session-active-runs.test.ts @@ -17,7 +17,6 @@ import { collectTrackedActiveSessionRuns, hasRegisteredChatRunForSessionKey, hasTrackedActiveSessionRun, - hasVisibleActiveSessionRun, resolveVisibleActiveSessionRunState, } from "./session-active-runs.js"; @@ -88,13 +87,13 @@ it("matches session-id-only gateway runs during archive admission", () => { } as never; expect( - hasVisibleActiveSessionRun({ + resolveVisibleActiveSessionRunState({ context, requestedKey: "agent:main:child", canonicalKey: "agent:main:child", sessionId: "session-1", defaultAgentId: "main", - }), + }).active, ).toBe(true); }); diff --git a/src/gateway/server-methods/session-active-runs.ts b/src/gateway/server-methods/session-active-runs.ts index 917f048002d3..5e8ae707cbf5 100644 --- a/src/gateway/server-methods/session-active-runs.ts +++ b/src/gateway/server-methods/session-active-runs.ts @@ -41,7 +41,7 @@ export function collectTrackedActiveSessionRuns( } function isTrackedActiveSessionRunForKey( - active: TrackedActiveSessionRun, + active: Pick, key: string, agentId?: string, defaultAgentId?: string, @@ -95,31 +95,18 @@ export function hasRegisteredChatRunForSessionKey(params: { agentId: string | undefined; defaultAgentId?: string; }): boolean { - if (!(params.context.chatAbortControllers instanceof Map)) { - return false; - } - const requestedAgentId = resolveChatRunOwnerAgentId({ - agentId: params.agentId, - sessionKey: params.sessionKey, - defaultAgentId: params.defaultAgentId, - }); - if (!requestedAgentId) { - return false; - } - for (const active of params.context.chatAbortControllers.values()) { - if (active.sessionKey?.trim() !== params.sessionKey) { - continue; - } - const activeAgentId = resolveChatRunOwnerAgentId({ - agentId: active.agentId, - sessionKey: active.sessionKey, - defaultAgentId: params.defaultAgentId, - }); - if (!requestedAgentId || requestedAgentId === activeAgentId) { - return true; - } - } - return false; + const controllers = params.context.chatAbortControllers; + return ( + controllers instanceof Map && + [...controllers.values()].some((active) => + isTrackedActiveSessionRunForKey( + active, + params.sessionKey, + params.agentId, + params.defaultAgentId, + ), + ) + ); } /** Returns true when either requested or canonical session key has a visible active run. */ @@ -205,14 +192,3 @@ export function resolveVisibleActiveSessionRunState(params: { runIds, }; } - -export function hasVisibleActiveSessionRun(params: { - context: Partial>; - requestedKey: string; - canonicalKey: string; - sessionId?: string; - agentId?: string; - defaultAgentId?: string; -}): boolean { - return resolveVisibleActiveSessionRunState(params).active; -} diff --git a/src/gateway/server-methods/sessions-abort.ts b/src/gateway/server-methods/sessions-abort.ts index 7d7e9c27922f..9590079dd526 100644 --- a/src/gateway/server-methods/sessions-abort.ts +++ b/src/gateway/server-methods/sessions-abort.ts @@ -90,8 +90,7 @@ function sessionKeyBelongsToAgent( agentId: string, cfg: OpenClawConfig, ): boolean { - const sessionAgentId = resolveSessionKeyAgentId(sessionKey, cfg); - return Boolean(sessionAgentId && sessionAgentId === normalizeAgentId(agentId)); + return resolveSessionKeyAgentId(sessionKey, cfg) === normalizeAgentId(agentId); } function resolveScopedAbortKey(params: { @@ -380,7 +379,10 @@ export const sessionAbortHandlers: GatewayRequestHandlers = { client, isWebchatConnect, }, - onAuthorizedAfterQueuedAbort ? { onAuthorizedAfterQueuedAbort } : {}, + { + ...(onAuthorizedAfterQueuedAbort ? { onAuthorizedAfterQueuedAbort } : {}), + ...(!requestedRunId ? { cascadeDescendants: true as const } : {}), + }, ); if (!chatAbortSucceeded) { return; diff --git a/src/gateway/server-methods/sessions-compact.ts b/src/gateway/server-methods/sessions-compact.ts index 8741936ef4fd..71187534c468 100644 --- a/src/gateway/server-methods/sessions-compact.ts +++ b/src/gateway/server-methods/sessions-compact.ts @@ -35,7 +35,7 @@ import { resolveGatewaySessionStoreTargetWithStore, } from "../session-utils.js"; import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; -import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; +import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { preflightGatewaySessionCompaction, @@ -218,14 +218,14 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { sessionId, ) ?? false) || - hasVisibleActiveSessionRun({ + resolveVisibleActiveSessionRunState({ context, requestedKey: key, canonicalKey: target.canonicalKey, sessionId, agentId: requestedAgentId, defaultAgentId: compatibilityDefaultAgentId, - }); + }).active; // Accepted work can live only in its command lane; waiting behind it // while holding the lifecycle fence would deadlock or drop that turn. blockedByQueuedWork = diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts index 0941871e78ed..9b646b54ea7a 100644 --- a/src/gateway/server-methods/sessions-rewind.ts +++ b/src/gateway/server-methods/sessions-rewind.ts @@ -34,7 +34,7 @@ import { tryResolveSessionCompatibilityOwnerAgentId, } from "../session-request-agent.js"; import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; -import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; +import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { resolveOperatorSessionCreation } from "./session-creation-provenance.js"; import { @@ -282,14 +282,14 @@ async function mutateSessionAtMessage( initialSessionId, ) ?? false) || - hasVisibleActiveSessionRun({ + resolveVisibleActiveSessionRunState({ context, requestedKey: sessionKey, canonicalKey: current.canonicalKey, sessionId: initialSessionId, agentId: requestedAgent.agentId, defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, sessionKey), - }); + }).active; }, run: async () => { if (!targetStillCurrent) { diff --git a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts index dc76065cdff5..c952534119ea 100644 --- a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts +++ b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts @@ -3,7 +3,13 @@ */ import { expectDefined } from "@openclaw/normalization-core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + addSubagentRunForTests, + getSubagentRunByChildSessionKey, + resetSubagentRegistryForTests, + testing as subagentRegistryTesting, +} from "../../agents/subagents/registry/subagent-registry.test-helpers.js"; import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; @@ -227,6 +233,11 @@ async function expectListedGlobalSessionActiveRun(params: { } describe("sessions.abort agent scope", () => { + afterEach(() => { + resetSubagentRegistryForTests({ persist: false }); + subagentRegistryTesting.setDepsForTest(); + }); + beforeEach(() => { chatAbortMock.mockReset(); resolveSessionKeyForRunMock.mockReset(); @@ -245,6 +256,10 @@ describe("sessions.abort agent scope", () => { abortEmbeddedAgentRunMock.mockReset(); clearSessionQueuesMock.mockReset(); clearSessionQueuesMock.mockReturnValue({ followupCleared: 0, laneCleared: 0, keys: [] }); + subagentRegistryTesting.setDepsForTest({ + persistSubagentRunsToDisk: () => {}, + persistSubagentRunsToDiskOrThrow: () => {}, + }); }); it("does not abort an active run whose session key belongs to another requested agent", async () => { @@ -315,6 +330,56 @@ describe("sessions.abort agent scope", () => { }); }); + it("kills controlled subagents after the parent run has already ended", async () => { + const actualChatAbort = + await vi.importActual("./chat-abort-handler.js"); + chatAbortMock.mockImplementationOnce(actualChatAbort.handleChatAbortRequestWithLifecycle); + const childSessionKey = "agent:main:subagent:orphaned-after-parent-stop"; + addSubagentRunForTests({ + runId: "run-orphaned-child", + childSessionKey, + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterAgentId: "main", + requesterTurnRunId: "ended-parent-run", + task: "orphaned child", + cleanup: "keep", + createdAt: Date.now() - 2_000, + startedAt: Date.now() - 1_000, + }); + const context = createContext({ + extra: { + agentRunSeq: new Map(), + broadcast: vi.fn(), + cancelRunBoundApprovals: vi.fn(), + chatQueuedTurns: new Map(), + chatRunState: { resolveBuffer: () => ({ text: "" }) } as never, + dedupe: new Map(), + getSessionEventSubscriberConnIds: () => new Set(), + nodeSendToSession: vi.fn(), + removeChatRun: vi.fn(), + }, + }); + + const respond = await callSessions( + "sessions.abort", + { key: "agent:main:main" }, + { context, reqId: "req-orphaned-child" }, + ); + + expect(respond).toHaveBeenCalledWith( + true, + { ok: true, abortedRunId: null, status: "aborted" }, + undefined, + undefined, + ); + expect(getSubagentRunByChildSessionKey(childSessionKey)).toMatchObject({ + endedReason: "subagent-killed", + killReconciliation: { suppressTaskDelivery: true }, + }); + }); + it("resolves runId-only worker aborts to the owning session", async () => { const resolveInferenceSessionForRunId = vi.fn(() => "session-worker"); mockChatSuccess(chatAbortMock, { ok: true, aborted: true, runIds: ["run-worker"] }); diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 0db7adf453dc..98829a8603ef 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -7,6 +7,7 @@ import { resolveFastModeState } from "../agents/fast-mode.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveSessionModelIdentityRef } from "../agents/session-model-ref.js"; import { + countActiveDescendantRuns, getSessionDisplaySubagentRunByChildSessionKey, getSubagentSessionRuntimeMs, getSubagentSessionStartedAt, @@ -172,6 +173,9 @@ export function buildGatewaySessionRow(params: { normalizeOptionalString(subagentRun?.controllerSessionKey) || normalizeOptionalString(subagentRun?.requesterSessionKey); const liveSubagentRunActive = isSubagentRunLive(subagentRun); + const hasActiveSubagentRun = + liveSubagentRunActive || + (rowContext?.subagentRuns.countActiveDescendantRuns(key) ?? countActiveDescendantRuns(key)) > 0; const persistedSessionStatus = entry?.status; const persistedSessionEndedAt = entry?.endedAt; const persistedSessionStartedAt = entry?.startedAt; @@ -491,7 +495,7 @@ export function buildGatewaySessionRow(params: { lastRunError: entry?.lastRunError, hasAutomation: sessionHasAutomation(key, cfg, sessionAgentId) ? true : undefined, subagentRunState, - hasActiveSubagentRun: subagentRun ? liveSubagentRunActive : undefined, + hasActiveSubagentRun: subagentRun || hasActiveSubagentRun ? hasActiveSubagentRun : undefined, startedAt: subagentRun ? subagentStartedAt : entry?.startedAt, endedAt: subagentRun ? subagentEndedAt : entry?.endedAt, runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs, diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index a3e90c270757..51b7c470b358 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -1292,6 +1292,10 @@ describe("listSessionsFromStore subagent metadata", () => { }); const main = result.sessions.find((session) => session.key === "agent:main:main"); expect(main?.childSessions).toEqual([parentKey]); + expect(main?.hasActiveSubagentRun).toBe(true); + expect(result.sessions.find((session) => session.key === parentKey)?.hasActiveSubagentRun).toBe( + true, + ); }); test("falls back to persisted subagent timing after run archival", () => { diff --git a/ui/src/pages/chat/run-lifecycle.test.ts b/ui/src/pages/chat/run-lifecycle.test.ts index 3c5a8f1fc654..1f86662e0e76 100644 --- a/ui/src/pages/chat/run-lifecycle.test.ts +++ b/ui/src/pages/chat/run-lifecycle.test.ts @@ -19,6 +19,7 @@ type ReconcileHost = Parameters[0] type TestRow = { key: string; hasActiveRun?: boolean; + hasActiveSubagentRun?: boolean; activeRunIds?: string[]; status?: string; startedAt?: number; @@ -64,6 +65,29 @@ function makeAbortHost(over: Partial = {}): AbortHost { } describe("handleAbortChat", () => { + it("dispatches sessions.abort when only descendant work remains", async () => { + const request = vi.fn(async () => ({ status: "aborted" })); + const host = makeAbortHost({ + client: { request } as unknown as GatewayBrowserClient, + sessionsResult: makeSessionsResult([ + { + key: "agent:main", + hasActiveRun: false, + hasActiveSubagentRun: true, + status: "done", + }, + ]), + }); + + expect(hasAbortableSessionRun(host)).toBe(true); + await handleAbortChat(host); + + expect(request).toHaveBeenCalledWith("sessions.abort", { + key: "agent:main", + clearQueued: true, + }); + }); + it("shows reconnect guidance when an offline session run has no browser run identity", async () => { const request = vi.fn(); const client = { request } as unknown as GatewayBrowserClient; diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index f232705cb6cd..e008c327aad2 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -153,7 +153,8 @@ export function hasAbortableSessionRun(host: { return Boolean( host.sessionsResult?.sessions.some( (session) => - areUiSessionKeysEquivalent(session.key, host.sessionKey) && isSessionRunActive(session), + areUiSessionKeysEquivalent(session.key, host.sessionKey) && + (isSessionRunActive(session) || session.hasActiveSubagentRun === true), ), ); }