refactor(agents): split subagent-control.ts into owner modules (#122875)

This commit is contained in:
Peter Steinberger
2026-08-12 17:23:39 -07:00
committed by GitHub
parent abe0cd691d
commit f3f203f31d
6 changed files with 1583 additions and 1462 deletions
-1
View File
@@ -408,7 +408,6 @@ src/agents/sessions/settings-manager.ts
src/agents/subagents/announce/subagent-announce-delivery.test.ts
src/agents/subagents/announce/subagent-announce.format.e2e.test.ts
src/agents/subagents/registry/subagent-control.test.ts
src/agents/subagents/registry/subagent-control.ts
src/agents/subagents/registry/subagent-registry-lifecycle.test.ts
src/agents/subagents/registry/subagent-registry.steer-restart.test.ts
src/agents/subagents/registry/subagent-registry.test.ts
@@ -0,0 +1,469 @@
/** Session-lifecycle mutation and persistence for subagent kills. */
import type { ClearSessionQueueResult } from "../../../auto-reply/reply/queue.js";
import {
loadSessionEntry,
patchSessionEntryCore,
} from "../../../config/sessions/session-accessor.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { logVerbose } from "../../../globals.js";
import { isAgentEventLifecycleGenerationCurrent } from "../../../infra/agent-events.js";
import { formatErrorMessage } from "../../../infra/errors.js";
import {
interruptSessionWorkAdmissions,
runExclusiveSessionLifecycleMutation,
SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS,
} from "../../../sessions/session-lifecycle-admission.js";
import { createLazyImportLoader } from "../../../shared/lazy-promise.js";
import {
SUBAGENT_KILL_TASK_ERROR,
type DetachedTaskTerminalState,
} from "../../../tasks/detached-task-runtime-contract.js";
import { isCurrentSubagentRun } from "./subagent-control-scope.js";
import { SUBAGENT_ENDED_REASON_KILLED } from "./subagent-lifecycle-events.js";
import { resolveSessionEntryForKey } from "./subagent-list.js";
import {
resolveFinalizedSubagentTaskState,
resolveKilledSubagentTaskEndedAt,
} from "./subagent-registry-completion.js";
import { getLatestLiveSubagentRunByChildSessionKey } from "./subagent-registry-read.js";
import {
claimSubagentRunKill,
markSubagentRunTerminated,
releaseSubagentRunKillClaim,
} from "./subagent-registry.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
type PatchSessionEntry = typeof patchSessionEntryCore;
type AbortEmbeddedAgentRun = (sessionId: string) => boolean;
type IsEmbeddedAgentRunActive = (sessionId: string) => boolean;
type ClearSessionQueues = (keys: Array<string | undefined>) => ClearSessionQueueResult;
type SubagentKillDeps = {
patchSessionEntryCore: PatchSessionEntry;
abortEmbeddedAgentRun?: AbortEmbeddedAgentRun;
isEmbeddedAgentRunActive?: IsEmbeddedAgentRunActive;
clearSessionQueues?: ClearSessionQueues;
};
const defaultSubagentKillDeps: SubagentKillDeps = {
patchSessionEntryCore,
};
let subagentKillDeps: SubagentKillDeps = defaultSubagentKillDeps;
const subagentKillRuntimeLoader = createLazyImportLoader(
() => import("./subagent-control.runtime.js"),
);
async function resolveSubagentKillRuntime(): Promise<{
abortEmbeddedAgentRun: AbortEmbeddedAgentRun;
isEmbeddedAgentRunActive: IsEmbeddedAgentRunActive;
clearSessionQueues: ClearSessionQueues;
}> {
if (
subagentKillDeps.abortEmbeddedAgentRun &&
subagentKillDeps.isEmbeddedAgentRunActive &&
subagentKillDeps.clearSessionQueues
) {
return {
abortEmbeddedAgentRun: subagentKillDeps.abortEmbeddedAgentRun,
isEmbeddedAgentRunActive: subagentKillDeps.isEmbeddedAgentRunActive,
clearSessionQueues: subagentKillDeps.clearSessionQueues,
};
}
const runtime = await subagentKillRuntimeLoader.load();
return {
abortEmbeddedAgentRun: subagentKillDeps.abortEmbeddedAgentRun ?? runtime.abortEmbeddedAgentRun,
isEmbeddedAgentRunActive:
subagentKillDeps.isEmbeddedAgentRunActive ?? runtime.isEmbeddedAgentRunActive,
clearSessionQueues: subagentKillDeps.clearSessionQueues ?? runtime.clearSessionQueues,
};
}
export function setSubagentKillTestDeps(overrides?: Partial<SubagentKillDeps>) {
subagentKillDeps = overrides
? {
...defaultSubagentKillDeps,
...overrides,
}
: defaultSubagentKillDeps;
}
type SubagentKillTargetState =
| { state: "finalizing" }
| { state: "terminal"; task: DetachedTaskTerminalState };
export function resolveSubagentKillTargetState(
entry: SubagentRunRecord,
): SubagentKillTargetState | undefined {
if (
entry.endedReason === SUBAGENT_ENDED_REASON_KILLED &&
entry.suppressAnnounceReason !== "steer-restart"
) {
const taskEndedAt = resolveKilledSubagentTaskEndedAt(entry);
return typeof taskEndedAt === "number"
? {
state: "terminal",
task: {
status: "cancelled",
endedAt: taskEndedAt,
lastEventAt: taskEndedAt,
error: SUBAGENT_KILL_TASK_ERROR,
progressSummary: entry.completion?.resultText ?? undefined,
terminalSummary: null,
},
}
: undefined;
}
const terminal = resolveFinalizedSubagentTaskState(entry);
if (terminal) {
return { state: "terminal", task: terminal };
}
return typeof entry.execution.endedAt === "number" &&
entry.pauseReason !== "sessions_yield" &&
(entry.endedReason !== SUBAGENT_ENDED_REASON_KILLED ||
entry.suppressAnnounceReason === "steer-restart")
? { state: "finalizing" }
: undefined;
}
export async function persistSubagentAbortedLastRun(params: {
childSessionKey: string;
storePath: string;
hasSessionEntry: boolean;
expectedSessionId?: string;
expectedLifecycleRevision?: string;
abortedLastRun: boolean;
isCurrent?: (current: SessionEntry) => boolean;
assertCommitAllowed?: () => void;
strict?: boolean;
}): Promise<boolean> {
if (!params.hasSessionEntry) {
return true;
}
try {
await subagentKillDeps.patchSessionEntryCore(
{ storePath: params.storePath, sessionKey: params.childSessionKey },
(current) =>
current.sessionId !== params.expectedSessionId ||
current.lifecycleRevision !== params.expectedLifecycleRevision ||
params.isCurrent?.(current) === false
? null
: {
...current,
abortedLastRun: params.abortedLastRun,
updatedAt: Date.now(),
},
{
assertCommitAllowed: params.assertCommitAllowed,
replaceEntry: true,
},
);
return true;
} catch (error) {
if (params.strict) {
throw error;
}
logVerbose(
`subagents control kill: failed to persist abortedLastRun=${params.abortedLastRun} for ${params.childSessionKey}: ${formatErrorMessage(error)}`,
);
return false;
}
}
function markSubagentRunTerminatedBestEffort(
params: Parameters<typeof markSubagentRunTerminated>[0],
): number {
try {
return markSubagentRunTerminated(params);
} catch (error) {
// The registry transition rolled back atomically. Keep multi-run control
// moving so one persistence failure cannot leave siblings running.
logVerbose(
`subagents control kill: failed to persist ${params.runId ?? params.childSessionKey ?? "unknown"}: ${formatErrorMessage(error)}`,
);
return 0;
}
}
async function killSubagentRun(params: {
cfg: OpenClawConfig;
entry: SubagentRunRecord;
cache: Map<string, Record<string, SessionEntry>>;
suppressTaskDelivery?: boolean;
}): Promise<{
killed: boolean;
sessionId?: string;
superseded?: boolean;
targetState?: SubagentKillTargetState;
error?: string;
}> {
const markKilledBestEffort = () =>
markSubagentRunTerminatedBestEffort({
runId: params.entry.runId,
reason: "killed",
suppressTaskDelivery: params.suppressTaskDelivery,
});
const initialTargetState = resolveSubagentKillTargetState(params.entry);
if (initialTargetState) {
if (
params.entry.endedReason === SUBAGENT_ENDED_REASON_KILLED &&
params.entry.suppressAnnounceReason !== "steer-restart"
) {
markKilledBestEffort();
}
return { killed: false, targetState: initialTargetState };
}
if (params.entry.execution.endedAt && params.entry.pauseReason !== "sessions_yield") {
return { killed: false };
}
const childSessionKey = params.entry.childSessionKey;
const resolved = resolveSessionEntryForKey({
cfg: params.cfg,
key: childSessionKey,
cache: params.cache,
});
const sessionId = resolved.entry?.sessionId;
const sessionLifecycleRevision = resolved.entry?.lifecycleRevision;
const runtime = await resolveSubagentKillRuntime();
let admittedWorkReleased = true;
return await runExclusiveSessionLifecycleMutation({
scope: resolved.storePath,
identities: [childSessionKey, sessionId],
prepare: async () => {
if (!isCurrentSubagentRun(params.entry, params.cfg)) {
return;
}
admittedWorkReleased = await interruptSessionWorkAdmissions({
scope: resolved.storePath,
identities: [childSessionKey, sessionId],
timeoutMs: SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS,
});
},
run: async () => {
if (!admittedWorkReleased) {
return {
killed: false,
sessionId,
error: "Subagent is still active; try the kill again in a moment.",
};
}
// Runtime loading and admission draining yield. Fence the exact row before
// touching session-owned queues so a successor cannot inherit an older kill.
if (!isCurrentSubagentRun(params.entry, params.cfg)) {
return { killed: false, sessionId, superseded: true };
}
const targetStateAfterRuntimeLoad = resolveSubagentKillTargetState(params.entry);
if (targetStateAfterRuntimeLoad) {
if (
params.entry.endedReason === SUBAGENT_ENDED_REASON_KILLED &&
params.entry.suppressAnnounceReason !== "steer-restart"
) {
markKilledBestEffort();
}
return { killed: false, sessionId, targetState: targetStateAfterRuntimeLoad };
}
let killClaim: ReturnType<typeof claimSubagentRunKill>;
const killOwnerCurrent = () =>
isCurrentSubagentRun(params.entry, params.cfg) &&
(!killClaim ||
((params.entry.killIntent === killClaim ||
(params.entry.endedReason === SUBAGENT_ENDED_REASON_KILLED &&
params.entry.killReconciliation !== undefined &&
params.entry.execution.lifecycleGeneration === killClaim.lifecycleGeneration)) &&
(killClaim.lifecycleGeneration === undefined ||
isAgentEventLifecycleGenerationCurrent(killClaim.lifecycleGeneration))));
const persistAbortedLastRun = (abortedLastRun: boolean, strict = false) =>
persistSubagentAbortedLastRun({
childSessionKey,
storePath: resolved.storePath,
hasSessionEntry: resolved.entry !== undefined,
expectedSessionId: sessionId,
expectedLifecycleRevision: sessionLifecycleRevision,
abortedLastRun,
isCurrent: () => killOwnerCurrent(),
assertCommitAllowed: () => {
if (!killOwnerCurrent()) {
throw new Error("subagent kill lifecycle retired before abort-marker commit");
}
},
strict,
});
try {
// Persist operator intent before aborting runtime work. If terminal
// persistence fails, recovery still cannot replay this exact row.
killClaim = claimSubagentRunKill({
runId: params.entry.runId,
expected: params.entry,
sessionId,
sessionLifecycleRevision,
suppressTaskDelivery: params.suppressTaskDelivery,
});
} catch (error) {
return {
killed: false,
sessionId,
error: `Failed to persist subagent kill intent: ${formatErrorMessage(error)}`,
};
}
if (!killClaim || !killOwnerCurrent()) {
return {
killed: false,
sessionId,
superseded: true,
};
}
const claimedKill = killClaim;
const ownsSessionIncarnation = () => {
const currentSessionEntry = loadSessionEntry({
storePath: resolved.storePath,
sessionKey: childSessionKey,
clone: false,
readConsistency: "latest",
});
return (
(currentSessionEntry !== undefined) === (resolved.entry !== undefined) &&
currentSessionEntry?.sessionId === sessionId &&
currentSessionEntry?.lifecycleRevision === sessionLifecycleRevision
);
};
const releaseChangedSessionKill = () => {
try {
releaseSubagentRunKillClaim({
runId: params.entry.runId,
expected: params.entry,
claim: claimedKill,
});
} catch (error) {
return {
killed: false,
sessionId,
error: `Subagent session changed and its kill intent could not be released: ${formatErrorMessage(error)}`,
};
}
return {
killed: false,
sessionId,
error: "Subagent session changed while the kill was pending; retry.",
};
};
if (!ownsSessionIncarnation()) {
return releaseChangedSessionKill();
}
const active = sessionId ? runtime.isEmbeddedAgentRunActive(sessionId) : false;
if (!ownsSessionIncarnation()) {
return releaseChangedSessionKill();
}
const aborted = sessionId ? runtime.abortEmbeddedAgentRun(sessionId) : false;
if (!ownsSessionIncarnation()) {
return releaseChangedSessionKill();
}
const cleared = runtime.clearSessionQueues([childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents control kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (active && !aborted) {
try {
releaseSubagentRunKillClaim({
runId: params.entry.runId,
expected: params.entry,
claim: killClaim,
});
} catch (error) {
return {
killed: false,
sessionId,
error: `Subagent remained active and its kill intent could not be released: ${formatErrorMessage(error)}`,
};
}
return {
killed: false,
sessionId,
error: "Subagent is still active; try the kill again in a moment.",
};
}
const targetState = resolveSubagentKillTargetState(params.entry);
if (targetState) {
const killedTarget =
targetState.state === "terminal" &&
targetState.task.status === "cancelled" &&
targetState.task.error === SUBAGENT_KILL_TASK_ERROR;
if (killedTarget) {
markKilledBestEffort();
} else {
try {
releaseSubagentRunKillClaim({
runId: params.entry.runId,
expected: params.entry,
claim: killClaim,
});
} catch (error) {
return {
killed: false,
sessionId,
targetState,
error: `Completed subagent kill intent could not be released: ${formatErrorMessage(error)}`,
};
}
}
return { killed: killedTarget, sessionId, targetState };
}
let marked: number;
try {
marked = markSubagentRunTerminated({
runId: params.entry.runId,
reason: "killed",
suppressTaskDelivery: params.suppressTaskDelivery,
});
} catch (error) {
return {
killed: false,
sessionId,
error: `Failed to persist subagent kill tombstone: ${formatErrorMessage(error)}`,
};
}
await persistAbortedLastRun(true);
return {
killed: marked > 0,
sessionId,
};
},
});
}
export async function killLatestSubagentRun(params: {
cfg: OpenClawConfig;
entry: SubagentRunRecord;
cache: Map<string, Record<string, SessionEntry>>;
suppressTaskDelivery?: boolean;
}): Promise<{
entry: SubagentRunRecord;
result: Awaited<ReturnType<typeof killSubagentRun>>;
}> {
let entry = params.entry;
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await killSubagentRun({ ...params, entry });
if (!result.superseded) {
return { entry, result };
}
const latest = getLatestLiveSubagentRunByChildSessionKey(entry.childSessionKey);
if (!latest || latest === entry) {
return { entry, result };
}
if (entry.execution.restartRecovery?.idempotencyKey !== latest.runId) {
return { entry, result };
}
entry = latest;
}
return {
entry,
result: {
killed: false,
superseded: true,
error: "Subagent changed generations repeatedly during kill; retry in a moment.",
},
};
}
@@ -0,0 +1,368 @@
/** Authorized single-run, tree, and admin subagent kill orchestration. */
import { resolveSubagentLabel } from "../../../auto-reply/reply/subagents-utils.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { SUBAGENT_KILL_TASK_ERROR } from "../../../tasks/detached-task-runtime-contract.js";
import {
killLatestSubagentRun,
persistSubagentAbortedLastRun,
resolveSubagentKillTargetState,
} from "./subagent-control-kill-runtime.js";
import {
ensureSubagentControllerOwnsRun,
getLatestOwnedSubagentRun,
isCurrentSubagentRun,
isSameSubagentRunGeneration,
type ResolvedSubagentController,
} from "./subagent-control-scope.js";
import { resolveSessionEntryForKey } from "./subagent-list.js";
import {
getLatestLiveSubagentRunByChildSessionKey,
listSubagentRunsForController,
} from "./subagent-registry-read.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
async function killSubagentRunTree(params: {
cfg: OpenClawConfig;
runs: Iterable<SubagentRunRecord>;
cache: Map<string, Record<string, SessionEntry>>;
seenChildSessionKeys: Set<string>;
controllerSessionKey?: string;
suppressTaskDelivery?: boolean;
}): Promise<{ killed: number; labels: string[]; errors: string[] }> {
let killed = 0;
const labels: string[] = [];
const errors: string[] = [];
for (const run of params.runs) {
const childKey = run.childSessionKey?.trim();
if (!childKey || params.seenChildSessionKeys.has(childKey)) {
continue;
}
const latest = getLatestLiveSubagentRunByChildSessionKey(childKey);
if (!latest || !isSameSubagentRunGeneration(latest, run)) {
continue;
}
const latestControllerSessionKey =
latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim();
if (params.controllerSessionKey && latestControllerSessionKey !== params.controllerSessionKey) {
continue;
}
params.seenChildSessionKeys.add(childKey);
const entry = latest;
if (!entry.execution.endedAt || entry.pauseReason === "sessions_yield") {
const stopped = await killLatestSubagentRun({
cfg: params.cfg,
entry,
cache: params.cache,
suppressTaskDelivery: params.suppressTaskDelivery,
});
const stopResult = stopped.result;
if (stopResult.error) {
errors.push(`${resolveSubagentLabel(stopped.entry)}: ${stopResult.error}`);
}
const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry, params.cfg);
if (stopResult.superseded || (!stopResult.killed && !stoppedEntryIsCurrent)) {
continue;
}
if (stopResult.killed) {
killed += 1;
labels.push(resolveSubagentLabel(stopped.entry));
}
// A replacement generation owns its own descendant tree. The old row's
// kill may have committed, but it must not cascade through the shared key.
if (!stoppedEntryIsCurrent) {
continue;
}
}
const cascade = await killSubagentRunTree({
cfg: params.cfg,
runs: listSubagentRunsForController(childKey),
cache: params.cache,
seenChildSessionKeys: params.seenChildSessionKeys,
controllerSessionKey: childKey,
suppressTaskDelivery: params.suppressTaskDelivery,
});
killed += cascade.killed;
labels.push(...cascade.labels);
errors.push(...cascade.errors);
}
return { killed, labels, errors };
}
async function cascadeKillChildren(params: {
cfg: OpenClawConfig;
parentChildSessionKey: string;
cache: Map<string, Record<string, SessionEntry>>;
seenChildSessionKeys?: Set<string>;
suppressTaskDelivery?: boolean;
}): Promise<{ killed: number; labels: string[]; errors: string[] }> {
return killSubagentRunTree({
cfg: params.cfg,
runs: listSubagentRunsForController(params.parentChildSessionKey),
cache: params.cache,
seenChildSessionKeys: params.seenChildSessionKeys ?? new Set<string>(),
controllerSessionKey: params.parentChildSessionKey,
suppressTaskDelivery: params.suppressTaskDelivery,
});
}
/** Kills every currently controlled child run and its descendants. */
export async function killAllControlledSubagentRuns(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
runs: SubagentRunRecord[];
}) {
if (params.controller.controlScope !== "children") {
return {
status: "forbidden" as const,
error: "Leaf subagents cannot control other sessions.",
killed: 0,
labels: [],
};
}
const result = await killSubagentRunTree({
cfg: params.cfg,
runs: params.runs,
cache: new Map<string, Record<string, SessionEntry>>(),
seenChildSessionKeys: new Set<string>(),
controllerSessionKey: params.controller.controllerSessionKey,
});
if (result.errors.length > 0) {
return {
status: "error" as const,
error: result.errors.join("; "),
killed: result.killed,
labels: result.labels,
};
}
return { status: "ok" as const, killed: result.killed, labels: result.labels };
}
/** Kills one controlled subagent run and any active descendants. */
export async function killControlledSubagentRun(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
entry: SubagentRunRecord;
suppressTaskDelivery?: boolean;
}) {
if (params.controller.controlScope !== "children") {
return {
status: "forbidden" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Leaf subagents cannot control other sessions.",
};
}
const currentEntry = getLatestLiveSubagentRunByChildSessionKey(params.entry.childSessionKey);
if (!currentEntry || !isSameSubagentRunGeneration(currentEntry, params.entry)) {
return {
status: "done" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
const ownershipError = ensureSubagentControllerOwnsRun({
cfg: params.cfg,
controller: params.controller,
entry: currentEntry,
});
if (ownershipError) {
return {
status: "forbidden" as const,
runId: currentEntry.runId,
sessionKey: currentEntry.childSessionKey,
error: ownershipError,
};
}
const killCache = new Map<string, Record<string, SessionEntry>>();
const stopped = await killLatestSubagentRun({
cfg: params.cfg,
entry: currentEntry,
cache: killCache,
suppressTaskDelivery: params.suppressTaskDelivery,
});
const stopResult = stopped.result;
if (stopResult.error) {
return {
status: "error" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: stopResult.error,
};
}
const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry, params.cfg);
if (stopResult.superseded || (!stopResult.killed && !stoppedEntryIsCurrent)) {
return {
status: "done" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
if (!stoppedEntryIsCurrent) {
return {
status: "ok" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
killed: true as const,
cascadeKilled: 0,
cascadeLabels: undefined,
text: `killed ${resolveSubagentLabel(params.entry)}.`,
};
}
const seenChildSessionKeys = new Set<string>();
const targetChildKey = params.entry.childSessionKey?.trim();
if (targetChildKey) {
seenChildSessionKeys.add(targetChildKey);
}
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: params.entry.childSessionKey,
cache: killCache,
seenChildSessionKeys,
suppressTaskDelivery: params.suppressTaskDelivery,
});
if (cascade.errors.length > 0) {
return {
status: "error" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: cascade.errors.join("; "),
...(stopResult.killed ? { killed: true as const } : {}),
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
};
}
if (!stopResult.killed && cascade.killed === 0) {
return {
status: "done" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
const cascadeText =
cascade.killed > 0 ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})` : "";
return {
status: "ok" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
...(stopResult.killed ? { killed: true as const } : {}),
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
text: stopResult.killed
? `killed ${resolveSubagentLabel(params.entry)}${cascadeText}.`
: `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(params.entry)}.`,
};
}
/** Admin kill path for a subagent session key, bypassing caller ownership checks. */
export async function killSubagentRunAdmin(params: {
cfg: OpenClawConfig;
sessionKey: string;
agentId?: string;
}) {
const targetSessionKey = params.sessionKey.trim();
if (!targetSessionKey) {
return { found: false as const, killed: false };
}
const entry = getLatestOwnedSubagentRun(targetSessionKey, params.agentId, params.cfg);
if (!entry) {
return { found: false as const, killed: false };
}
const killCache = new Map<string, Record<string, SessionEntry>>();
const stopped = await killLatestSubagentRun({
cfg: params.cfg,
entry,
cache: killCache,
});
const stopResult = stopped.result;
if (stopResult.error) {
return {
found: true as const,
killed: false,
runId: stopped.entry.runId,
sessionKey: stopped.entry.childSessionKey,
cascadeKilled: 0,
error: stopResult.error,
};
}
const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry, params.cfg);
if (stopResult.superseded || (!stopResult.killed && !stoppedEntryIsCurrent)) {
return {
found: true as const,
killed: false,
runId: stopped.entry.runId,
sessionKey: stopped.entry.childSessionKey,
cascadeKilled: 0,
};
}
if (!stoppedEntryIsCurrent) {
return {
found: true as const,
killed: stopResult.killed,
...(stopResult.targetState ? { targetState: stopResult.targetState } : {}),
runId: stopped.entry.runId,
sessionKey: stopped.entry.childSessionKey,
cascadeKilled: 0,
};
}
const seenChildSessionKeys = new Set<string>([targetSessionKey]);
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: targetSessionKey,
cache: killCache,
seenChildSessionKeys,
});
// Descendant cleanup can yield long enough for the target run to finish.
// Return the freshest registry state so task cancellation cannot make a stale kill sticky.
const targetState = resolveSubagentKillTargetState(stopped.entry) ?? stopResult.targetState;
const killedTarget =
targetState?.state === "terminal" &&
targetState.task.status === "cancelled" &&
targetState.task.error === SUBAGENT_KILL_TASK_ERROR;
const stopResultAlreadyClearedAbort =
stopResult.targetState !== undefined &&
!(
stopResult.targetState.state === "terminal" &&
stopResult.targetState.task.status === "cancelled" &&
stopResult.targetState.task.error === SUBAGENT_KILL_TASK_ERROR
);
if (targetState && !killedTarget && !stopResultAlreadyClearedAbort) {
const resolved = resolveSessionEntryForKey({
cfg: params.cfg,
key: targetSessionKey,
cache: killCache,
});
await persistSubagentAbortedLastRun({
childSessionKey: targetSessionKey,
storePath: resolved.storePath,
hasSessionEntry: resolved.entry !== undefined,
expectedSessionId: resolved.entry?.sessionId,
expectedLifecycleRevision: resolved.entry?.lifecycleRevision,
abortedLastRun: false,
isCurrent: () => isCurrentSubagentRun(stopped.entry, params.cfg),
});
}
return {
found: true as const,
killed: stopResult.killed || cascade.killed > 0,
...(targetState ? { targetState } : {}),
runId: stopped.entry.runId,
sessionKey: stopped.entry.childSessionKey,
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
};
}
@@ -0,0 +1,504 @@
/** Authorized steering and follow-up messaging for controlled subagents. */
import crypto from "node:crypto";
import type { ClearSessionQueueResult } from "../../../auto-reply/reply/queue.js";
import { resolveSubagentLabel } from "../../../auto-reply/reply/subagents-utils.js";
import { resolveSessionStorePathCore } from "../../../config/sessions/paths.js";
import { loadSessionEntry } from "../../../config/sessions/session-accessor.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { callGateway } from "../../../gateway/call.js";
import { getGatewayRecoveryRuntime } from "../../../gateway/server-recovery-runtime-context.js";
import { logVerbose } from "../../../globals.js";
import {
getAgentEventLifecycleGeneration,
isAgentEventLifecycleGenerationCurrent,
} from "../../../infra/agent-events.js";
import { formatErrorMessage } from "../../../infra/errors.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import { createLazyImportLoader } from "../../../shared/lazy-promise.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js";
import { AGENT_LANE_SUBAGENT } from "../../lanes.js";
import {
readLatestAssistantReplySnapshot,
waitForAgentRunAndReadUpdatedAssistantReply,
} from "../../run-wait.js";
import { terminateAcceptedCollectorRun } from "../spawn/subagent-spawn-cleanup.js";
import {
ensureSubagentControllerOwnsRun,
isFinishedSubagentRunForSteer,
isSameSubagentRunGeneration,
type ResolvedSubagentController,
} from "./subagent-control-scope.js";
import { resolveSessionEntryForKey } from "./subagent-list.js";
import {
countPendingDescendantRuns,
getLatestLiveSubagentRunByChildSessionKey,
} from "./subagent-registry-read.js";
import {
clearSubagentRunSteerRestart,
markSubagentRunForSteerRestart,
replaceSubagentRunAfterSteerCore,
} from "./subagent-registry.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
const STEER_RATE_LIMIT_MS = 2_000;
const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
const SUBAGENT_REPLY_HISTORY_LIMIT = 50;
const steerRateLimit = new Map<string, number>();
type GatewayCaller = typeof callGateway;
type AbortEmbeddedAgentRun = (sessionId: string) => boolean;
type IsEmbeddedAgentRunActive = (sessionId: string) => boolean;
type ClearSessionQueues = (keys: Array<string | undefined>) => ClearSessionQueueResult;
const callSubagentControlGateway: GatewayCaller = async (request) => {
const gatewayRuntime = getGatewayRecoveryRuntime();
if (gatewayRuntime && request.method === "agent") {
return await gatewayRuntime.dispatchAgent(
request.params as Parameters<typeof gatewayRuntime.dispatchAgent>[0],
request.timeoutMs ?? undefined,
);
}
if (gatewayRuntime && request.method === "agent.wait") {
return await gatewayRuntime.waitForAgent(
request.params as Parameters<typeof gatewayRuntime.waitForAgent>[0],
request.timeoutMs ?? undefined,
);
}
return await callGateway(request);
};
type SubagentMessagingDeps = {
callGateway: GatewayCaller;
abortEmbeddedAgentRun?: AbortEmbeddedAgentRun;
isEmbeddedAgentRunActive?: IsEmbeddedAgentRunActive;
clearSessionQueues?: ClearSessionQueues;
};
const defaultSubagentMessagingDeps: SubagentMessagingDeps = {
callGateway: callSubagentControlGateway,
};
let subagentMessagingDeps: SubagentMessagingDeps = defaultSubagentMessagingDeps;
const subagentMessagingRuntimeLoader = createLazyImportLoader(
() => import("./subagent-control.runtime.js"),
);
async function resolveSubagentMessagingRuntime(): Promise<{
abortEmbeddedAgentRun: AbortEmbeddedAgentRun;
isEmbeddedAgentRunActive: IsEmbeddedAgentRunActive;
clearSessionQueues: ClearSessionQueues;
}> {
if (
subagentMessagingDeps.abortEmbeddedAgentRun &&
subagentMessagingDeps.isEmbeddedAgentRunActive &&
subagentMessagingDeps.clearSessionQueues
) {
return {
abortEmbeddedAgentRun: subagentMessagingDeps.abortEmbeddedAgentRun,
isEmbeddedAgentRunActive: subagentMessagingDeps.isEmbeddedAgentRunActive,
clearSessionQueues: subagentMessagingDeps.clearSessionQueues,
};
}
const runtime = await subagentMessagingRuntimeLoader.load();
return {
abortEmbeddedAgentRun:
subagentMessagingDeps.abortEmbeddedAgentRun ?? runtime.abortEmbeddedAgentRun,
isEmbeddedAgentRunActive:
subagentMessagingDeps.isEmbeddedAgentRunActive ?? runtime.isEmbeddedAgentRunActive,
clearSessionQueues: subagentMessagingDeps.clearSessionQueues ?? runtime.clearSessionQueues,
};
}
export function setSubagentMessagingTestDeps(overrides?: Partial<SubagentMessagingDeps>) {
subagentMessagingDeps = overrides
? {
...defaultSubagentMessagingDeps,
...overrides,
}
: defaultSubagentMessagingDeps;
}
/** Restarts a controlled subagent run with a new steering message. */
export async function steerControlledSubagentRun(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
entry: SubagentRunRecord;
message: string;
}): Promise<
| {
status: "forbidden" | "done" | "rate_limited" | "error";
runId?: string;
sessionKey: string;
sessionId?: string;
error?: string;
text?: string;
}
| {
status: "accepted";
runId: string;
sessionKey: string;
sessionId?: string;
mode: "restart";
label: string;
text: string;
}
> {
if (params.controller.controlScope !== "children") {
return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Leaf subagents cannot control other sessions.",
};
}
if (params.controller.callerSessionKey === params.entry.childSessionKey) {
return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Subagents cannot steer themselves.",
};
}
const currentEntry = getLatestLiveSubagentRunByChildSessionKey(params.entry.childSessionKey);
const currentHasPendingDescendants = currentEntry
? countPendingDescendantRuns(currentEntry.childSessionKey) > 0
: false;
if (
!currentEntry ||
!isSameSubagentRunGeneration(currentEntry, params.entry) ||
isFinishedSubagentRunForSteer(currentEntry, currentHasPendingDescendants)
) {
return {
status: "done",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
const ownershipError = ensureSubagentControllerOwnsRun({
cfg: params.cfg,
controller: params.controller,
entry: currentEntry,
});
if (ownershipError) {
return {
status: "forbidden",
runId: currentEntry.runId,
sessionKey: currentEntry.childSessionKey,
error: ownershipError,
};
}
if (currentEntry.collect) {
return {
status: "forbidden",
runId: currentEntry.runId,
sessionKey: currentEntry.childSessionKey,
error: "Collector subagents cannot be steered; use agents_wait or cancel the task.",
};
}
const rateKey = `${params.controller.callerSessionKey}:${params.entry.childSessionKey}`;
if (process.env.VITEST !== "true") {
const now = Date.now();
const lastSentAt = steerRateLimit.get(rateKey) ?? 0;
if (now - lastSentAt < STEER_RATE_LIMIT_MS) {
return {
status: "rate_limited",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Steer rate limit exceeded. Wait a moment before sending another steer.",
};
}
steerRateLimit.set(rateKey, now);
}
let ownsSteerRestart: boolean;
try {
ownsSteerRestart = markSubagentRunForSteerRestart(params.entry.runId, currentEntry);
} catch (error) {
return {
status: "error",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: `Failed to persist steer restart ownership: ${formatErrorMessage(error)}`,
};
}
if (!ownsSteerRestart) {
return {
status: "error",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Another subagent restart already owns this session; retry after it settles.",
};
}
const targetSession = resolveSessionEntryForKey({
cfg: params.cfg,
key: params.entry.childSessionKey,
cache: new Map<string, Record<string, SessionEntry>>(),
});
const sessionId =
typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim()
? targetSession.entry.sessionId.trim()
: undefined;
const restartSessionId = sessionId ? crypto.randomUUID() : undefined;
const runtime = await resolveSubagentMessagingRuntime();
if (sessionId) {
const active = runtime.isEmbeddedAgentRunActive(sessionId);
const aborted = runtime.abortEmbeddedAgentRun(sessionId);
if (active && !aborted) {
clearSubagentRunSteerRestart(params.entry.runId, currentEntry);
return {
status: "error",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
sessionId,
error: "Subagent reply is already finalizing and can no longer be restarted.",
};
}
}
const cleared = runtime.clearSessionQueues([params.entry.childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents control steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
try {
await subagentMessagingDeps.callGateway({
method: "agent.wait",
params: {
runId: params.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
},
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
});
} catch {
// Continue even if wait fails; steer should still be attempted.
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
const latestAfterWait = getLatestLiveSubagentRunByChildSessionKey(currentEntry.childSessionKey);
const hasPendingDescendantsAfterWait =
countPendingDescendantRuns(currentEntry.childSessionKey) > 0;
if (
latestAfterWait !== currentEntry ||
currentEntry.suppressAnnounceReason !== "steer-restart" ||
currentEntry.execution.restartRecovery ||
currentEntry.killIntent ||
currentEntry.killReconciliation ||
isFinishedSubagentRunForSteer(currentEntry, hasPendingDescendantsAfterWait)
) {
clearSubagentRunSteerRestart(params.entry.runId, currentEntry);
return {
status: "done",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
try {
const steerLifecycleGeneration = getAgentEventLifecycleGeneration();
const response = await subagentMessagingDeps.callGateway<{ runId: string }>({
method: "agent",
params: {
message: params.message,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
let acceptedSessionEntry: SessionEntry | undefined;
try {
acceptedSessionEntry = loadSessionEntry({
storePath: targetSession.storePath,
sessionKey: params.entry.childSessionKey,
clone: false,
readConsistency: "latest",
});
} catch {
// chat.abort remains the primary cleanup; exact session deletion is only
// the fallback when the accepted session row can be resolved.
}
const terminateUnownedSteer = () =>
terminateAcceptedCollectorRun({
childSessionKey: params.entry.childSessionKey,
gatewayRunId: runId,
expectedSessionId: acceptedSessionEntry?.sessionId,
expectedLifecycleRevision: acceptedSessionEntry?.lifecycleRevision,
callGateway: subagentMessagingDeps.callGateway,
timeoutMs: 10_000,
});
if (!isAgentEventLifecycleGenerationCurrent(steerLifecycleGeneration)) {
await terminateUnownedSteer();
clearSubagentRunSteerRestart(params.entry.runId, currentEntry);
return {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
error: "Gateway lifecycle changed before the steered run could be registered.",
};
}
const replaced = replaceSubagentRunAfterSteerCore({
previousRunId: params.entry.runId,
nextRunId: runId,
fallback: currentEntry,
expected: currentEntry,
allowEndedSource: true,
runTimeoutSeconds: currentEntry.runTimeoutSeconds ?? 0,
lifecycleGeneration: steerLifecycleGeneration,
// Persist the steer so restart recovery cannot reissue the stale task.
task: params.message,
});
if (!replaced) {
await terminateUnownedSteer();
clearSubagentRunSteerRestart(params.entry.runId, currentEntry);
return {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
error: "failed to replace steered subagent run",
};
}
} catch (err) {
clearSubagentRunSteerRestart(params.entry.runId, currentEntry);
const error = formatErrorMessage(err);
return {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
error,
};
}
return {
status: "accepted",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
mode: "restart",
label: resolveSubagentLabel(params.entry),
text: `steered ${resolveSubagentLabel(params.entry)}.`,
};
}
/** Sends a follow-up message to a controlled subagent and waits for a reply. */
export async function sendControlledSubagentMessage(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
entry: SubagentRunRecord;
message: string;
}) {
const ownershipError = ensureSubagentControllerOwnsRun({
cfg: params.cfg,
controller: params.controller,
entry: params.entry,
});
if (ownershipError) {
return { status: "forbidden" as const, error: ownershipError };
}
if (params.entry.collect) {
return {
status: "forbidden" as const,
error: "Collector subagents cannot receive follow-up messages; use agents_wait.",
};
}
if (params.controller.controlScope !== "children") {
return {
status: "forbidden" as const,
error: "Leaf subagents cannot control other sessions.",
};
}
const currentEntry = getLatestLiveSubagentRunByChildSessionKey(params.entry.childSessionKey);
if (!currentEntry || currentEntry.runId !== params.entry.runId) {
return {
status: "done" as const,
runId: params.entry.runId,
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
const targetSessionKey = params.entry.childSessionKey;
const parsed = parseAgentSessionKey(targetSessionKey);
const storePath = resolveSessionStorePathCore(params.cfg.session?.store, {
agentId: parsed?.agentId,
});
const targetSessionEntry = loadSessionEntry({
storePath,
sessionKey: targetSessionKey,
clone: false,
});
const targetSessionId =
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
? targetSessionEntry.sessionId.trim()
: undefined;
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const baselineReply = await readLatestAssistantReplySnapshot({
sessionKey: targetSessionKey,
limit: SUBAGENT_REPLY_HISTORY_LIMIT,
callGateway: subagentMessagingDeps.callGateway,
});
const response = await subagentMessagingDeps.callGateway<{ runId: string }>({
method: "agent",
params: {
message: params.message,
sessionKey: targetSessionKey,
sessionId: targetSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
if (responseRunId) {
runId = responseRunId;
}
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId,
sessionKey: targetSessionKey,
timeoutMs: 30_000,
limit: SUBAGENT_REPLY_HISTORY_LIMIT,
baseline: baselineReply,
callGateway: subagentMessagingDeps.callGateway,
});
if (result.status === "timeout") {
return { status: "timeout" as const, runId };
}
if (result.status === "error") {
return {
status: "error" as const,
runId,
error: result.error ?? "unknown error",
};
}
return { status: "ok" as const, runId, replyText: result.replyText };
} catch (err) {
const error = formatErrorMessage(err);
return { status: "error" as const, runId, error };
}
}
@@ -0,0 +1,218 @@
/** Controller identity, authorization, and controlled-run read scope. */
import { sortSubagentRuns } from "../../../auto-reply/reply/subagents-utils.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../../routing/session-key.js";
import { resolveSessionAgentId } from "../../agent-scope.js";
import { resolveSubagentRequesterAgentId } from "../../subagent-requester-owner.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "../../tools/sessions-helpers.js";
import { resolveStoredSubagentCapabilities } from "../spawn/subagent-capabilities.js";
import { subagentRuns } from "./subagent-registry-memory.js";
import { buildSubagentRunReadIndexFromRuns } from "./subagent-registry-queries.js";
import { getLatestLiveSubagentRunByChildSessionKey } from "./subagent-registry-read.js";
import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
/** Recent-run default window used by subagent control UI/tools. */
export const DEFAULT_RECENT_MINUTES = 30;
/** Maximum recent-run window accepted by subagent control UI/tools. */
export const MAX_RECENT_MINUTES = 24 * 60;
/** Controller identity and capability scope resolved from the caller session. */
export type ResolvedSubagentController = {
controllerSessionKey: string;
controllerAgentId?: string;
callerSessionKey: string;
callerIsSubagent: boolean;
controlScope: "children" | "none";
};
/** Resolves which subagent runs the caller is allowed to control. */
export function resolveSubagentController(params: {
cfg: OpenClawConfig;
agentSessionKey?: string;
agentId?: string;
}): ResolvedSubagentController {
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
const callerRaw = params.agentSessionKey?.trim() || alias;
const callerSessionKey = resolveInternalSessionKey({
key: callerRaw,
alias,
mainKey,
});
const controllerAgentId = resolveSessionAgentId({
config: params.cfg,
sessionKey: callerSessionKey,
agentId: params.agentId,
});
if (!isSubagentSessionKey(callerSessionKey)) {
return {
controllerSessionKey: callerSessionKey,
controllerAgentId,
callerSessionKey,
callerIsSubagent: false,
controlScope: "children",
};
}
const capabilities = resolveStoredSubagentCapabilities(callerSessionKey, {
cfg: params.cfg,
agentId: controllerAgentId,
});
return {
controllerSessionKey: callerSessionKey,
controllerAgentId,
callerSessionKey,
callerIsSubagent: true,
controlScope: capabilities.controlScope,
};
}
function resolveRunRequesterAgentId(
entry: SubagentRunRecord,
cfg?: OpenClawConfig,
): string | undefined {
if (entry.requesterAgentId) {
return entry.requesterAgentId;
}
const parsed = parseAgentSessionKey(entry.requesterSessionKey)?.agentId;
if (parsed || !cfg) {
return parsed;
}
return resolveSubagentRequesterAgentId(cfg, entry);
}
function isSubagentRunVisibleToSession(
entry: SubagentRunRecord,
sessionKey: string,
agentId: string,
cfg?: OpenClawConfig,
): boolean {
const controllerKey = entry.controllerSessionKey?.trim();
const requesterKey = entry.requesterSessionKey.trim();
// Completion routing can target a different session than control ownership.
// Both owners may read the run, while ensureControllerOwnsRun still gates mutations.
const requesterAgentId = resolveRunRequesterAgentId(entry, cfg);
const controllerAgentId =
(controllerKey ? parseAgentSessionKey(controllerKey)?.agentId : undefined) ?? requesterAgentId;
const normalizedAgentId = normalizeAgentId(agentId);
return (
(controllerKey === sessionKey && controllerAgentId === normalizedAgentId) ||
(requesterKey === sessionKey && requesterAgentId === normalizedAgentId)
);
}
/** Builds one stable snapshot for controlled-run listing and descendant status reads. */
export function buildControlledSubagentRunsReadContext(
controllerSessionKey: string,
controllerAgentId?: string,
cfg?: OpenClawConfig,
): {
runs: SubagentRunRecord[];
countPendingDescendantRuns(rootSessionKey: string): number;
} {
const key = controllerSessionKey.trim();
const agentId = controllerAgentId ?? parseAgentSessionKey(key)?.agentId;
if (!key || !agentId) {
return {
runs: [],
countPendingDescendantRuns: () => 0,
};
}
const snapshot = getSubagentRunsSnapshotForRead(subagentRuns);
const readIndex = buildSubagentRunReadIndexFromRuns({ runs: snapshot });
const filtered = Array.from(readIndex.latestRunsByChildSessionKey.values()).filter((entry) =>
isSubagentRunVisibleToSession(entry, key, agentId, cfg),
);
return {
runs: sortSubagentRuns(filtered),
countPendingDescendantRuns: (rootSessionKey) =>
readIndex.countPendingDescendantRuns(rootSessionKey),
};
}
/** Lists latest child runs controlled by a session key. */
export function listControlledSubagentRuns(
controllerSessionKey: string,
controllerAgentId?: string,
cfg?: OpenClawConfig,
): SubagentRunRecord[] {
return buildControlledSubagentRunsReadContext(controllerSessionKey, controllerAgentId, cfg).runs;
}
export function ensureSubagentControllerOwnsRun(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
entry: SubagentRunRecord;
}) {
const owner = params.entry.controllerSessionKey?.trim() || params.entry.requesterSessionKey;
const ownerAgentId =
parseAgentSessionKey(owner)?.agentId ?? resolveRunRequesterAgentId(params.entry, params.cfg);
const controllerAgentId =
params.controller.controllerAgentId ??
parseAgentSessionKey(params.controller.controllerSessionKey)?.agentId;
if (owner === params.controller.controllerSessionKey && ownerAgentId === controllerAgentId) {
return undefined;
}
return "Subagents can only control runs spawned from their own session.";
}
export function isFinishedSubagentRunForSteer(
entry: SubagentRunRecord,
hasPendingDescendants: boolean,
) {
return (
Boolean(entry.execution.endedAt) &&
entry.pauseReason !== "sessions_yield" &&
!hasPendingDescendants
);
}
export function getLatestOwnedSubagentRun(
childSessionKey: string,
agentId: string | undefined,
cfg: OpenClawConfig,
): SubagentRunRecord | undefined {
// Agent-scoped child keys already carry their sole owner; any newer generation fences
// the old row. Bare per-agent keys need the explicit owner to avoid cross-agent shadowing.
const ownerFilter = parseAgentSessionKey(childSessionKey) ? undefined : agentId;
return (
getLatestLiveSubagentRunByChildSessionKey(
childSessionKey,
ownerFilter
? (candidate) => resolveRunRequesterAgentId(candidate, cfg) === ownerFilter
: undefined,
) ?? undefined
);
}
export function isCurrentSubagentRun(entry: SubagentRunRecord, cfg?: OpenClawConfig): boolean {
if (!cfg) {
return getLatestLiveSubagentRunByChildSessionKey(entry.childSessionKey) === entry;
}
return (
getLatestOwnedSubagentRun(
entry.childSessionKey,
resolveRunRequesterAgentId(entry, cfg),
cfg,
) === entry
);
}
export function isSameSubagentRunGeneration(
live: SubagentRunRecord,
snapshot: SubagentRunRecord,
): boolean {
return (
live.childSessionKey === snapshot.childSessionKey &&
live.runId === snapshot.runId &&
live.generation === snapshot.generation &&
live.createdAt === snapshot.createdAt
);
}
File diff suppressed because it is too large Load Diff