mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
c98b841b41
commit
73443ebed4
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
{"contentHash":"ca67465d7347811160c037115f309700e31afcbeea2fd323d9965f5270dab44f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"90014e82f746dd9cdfad10b81132431e34da5eb7b06f489e70affc4e3dac5921","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user