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
This commit is contained in:
Peter Steinberger
2026-08-12 19:40:51 -07:00
committed by GitHub
parent c98b841b41
commit 73443ebed4
34 changed files with 357 additions and 195 deletions
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d622706dc586fefb443bed5527aa162c922bce5659df715e2be996b843cfeb3f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"4f73f681d60fd6eed1abe5e4d542c456798e5221fa2c97dcd480d1f33a4b1ecc","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"793f49ac45d6a2d24b95b5631af9a4afed2d21d1d2cceb39904ef47ba3376aad","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"a5062fea6e891209423fcfeae5a88c92695f672d80181b646951879e0613e911","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -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"}
@@ -1 +1 @@
{"contentHash":"34d78d7f9059d6092f5734054493ffc09702a27035d49900c66cb53d4ce89305","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"74285633ffd2690a31f53c3b612b74aef87e887935e11e84e9898e48f4db5d75","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"ed0d2d109ab6800f5031f1535c28967de54cd63e93b8d0a0e4c089f8433e39b9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"331508088936dad14f0caef18bf8d26d51e6db76febaa4045567b97212cff647","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"ca67465d7347811160c037115f309700e31afcbeea2fd323d9965f5270dab44f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"90014e82f746dd9cdfad10b81132431e34da5eb7b06f489e70affc4e3dac5921","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"6c2277b60d16f2cb4fe263d462eea07cb929509e4ffe883b4120f64feccd75b4","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"e40b030c61bbdcf0c7c15620a47ff88a2d0e9aac8d363c019459cece7dfff47c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -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"}
@@ -1 +1 @@
{"contentHash":"2fb26601bfff725440c436a5cc9ee9dfcdb4e959a242aec70387ba207c69f94a","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"f643813855b0359515d0faf0c58b9ef323399e5ba7ffced29ef81305051d66aa","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"72704f536605a38a38826a3e430037fd65bfaa80d7d7f60403cc95430b29c16b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"0f9097423b1b563db164cab7cc7e1f9d1eaed13eb12ce16523a72d8b1614375b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"8481323e3608484d879ca27d447a462c08b7109e414d33da270860b0ecffd83b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"cdb8cb034a2e2f4cb2e511b706c964ddc2704761d17feceb2999fa41e87feea7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"0f556444bd92ec15451542963dccced9708aa38864d50ab72f778960c9046d09","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"3f9b816df61c93c1e3e78245451f69878bc53d194c6737f6d4770728387a9061","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"cde91c704c73a080198c25b797a00150aa8e90f42f87503110763d6c1bf8edac","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"c7d16f543fc05054a0dc3b9b3206b9ba383f18244631ce83d4bf7c46ee26dab3","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
@@ -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<string, Record<string, SessionEntry>>(),
seenChildSessionKeys: new Set<string>(),
controllerSessionKey: params.controller.controllerSessionKey,
suppressTaskDelivery: params.suppressTaskDelivery,
});
if (result.errors.length > 0) {
return {
+1 -2
View File
@@ -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, () => {
-16
View File
@@ -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<typeof abortChatRunById>[1],
) {
return abortChatRunById(ops, params);
}
function resolveChatAbortDeliverySessionKeys(
ops: ChatAbortOps,
sessionKey: string,
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
@@ -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(() => {});
@@ -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<string>;
cascadeDescendants?: true;
};
type ChatAbortTarget = Pick<
@@ -47,6 +49,18 @@ type ChatAbortTarget = Pick<
"sessionKey" | "agentId" | "ownerConnId" | "ownerDeviceId"
>;
function descendantAbortError(
result: Awaited<ReturnType<ReturnType<typeof prepareControlledSubagentAbort>>>,
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);
}
+95 -105
View File
@@ -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<GatewayRequestContext, "logGateway">;
sessionKey: string;
snapshots: AbortedPartialSnapshot[];
}): Promise<void> {
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,
@@ -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);
});
@@ -41,7 +41,7 @@ export function collectTrackedActiveSessionRuns(
}
function isTrackedActiveSessionRunForKey(
active: TrackedActiveSessionRun,
active: Pick<TrackedActiveSessionRun, "sessionKey" | "agentId">,
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<Pick<GatewayRequestContext, "chatAbortControllers">>;
requestedKey: string;
canonicalKey: string;
sessionId?: string;
agentId?: string;
defaultAgentId?: string;
}): boolean {
return resolveVisibleActiveSessionRunState(params).active;
}
+5 -3
View File
@@ -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;
@@ -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 =
@@ -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) {
@@ -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<typeof import("./chat-abort-handler.js")>("./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"] });
+5 -1
View File
@@ -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,
@@ -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", () => {
+24
View File
@@ -19,6 +19,7 @@ type ReconcileHost = Parameters<typeof reconcileChatRunFromCurrentSessionRow>[0]
type TestRow = {
key: string;
hasActiveRun?: boolean;
hasActiveSubagentRun?: boolean;
activeRunIds?: string[];
status?: string;
startedAt?: number;
@@ -64,6 +65,29 @@ function makeAbortHost(over: Partial<AbortHost> = {}): 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;
+2 -1
View File
@@ -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),
),
);
}