mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 01:50:22 +00:00
Fix nested subagent announce routing and waiting status
This commit is contained in:
@@ -914,8 +914,9 @@ describe("sessions tools", () => {
|
||||
const result = await tool.execute("call-subagents-list-orchestrator", { action: "list" });
|
||||
const details = result.details as {
|
||||
status?: string;
|
||||
active?: Array<{ runId?: string; status?: string }>;
|
||||
active?: Array<{ runId?: string; status?: string; pendingDescendants?: number }>;
|
||||
recent?: Array<{ runId?: string }>;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
expect(details.status).toBe("ok");
|
||||
@@ -923,11 +924,13 @@ describe("sessions tools", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
runId: "run-orchestrator-ended",
|
||||
status: "active",
|
||||
status: "active (waiting on 1 child)",
|
||||
pendingDescendants: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(details.recent?.find((entry) => entry.runId === "run-orchestrator-ended")).toBeFalsy();
|
||||
expect(details.text).toContain("active (waiting on 1 child)");
|
||||
});
|
||||
|
||||
it("subagents list usage separates io tokens from prompt/cache", async () => {
|
||||
|
||||
@@ -15,6 +15,13 @@ let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfi
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
let requesterDepthResolver: (sessionKey?: string) => number = () => 0;
|
||||
let subagentSessionRunActive = true;
|
||||
let shouldIgnorePostCompletion = false;
|
||||
let fallbackRequesterResolution: {
|
||||
requesterSessionKey: string;
|
||||
requesterOrigin?: { channel?: string; to?: string; accountId?: string };
|
||||
} | null = null;
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(async (request: GatewayCall) => {
|
||||
@@ -42,7 +49,7 @@ vi.mock("../config/sessions.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-depth.js", () => ({
|
||||
getSubagentDepthFromSessionStore: () => 0,
|
||||
getSubagentDepthFromSessionStore: (sessionKey?: string) => requesterDepthResolver(sessionKey),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-embedded.js", () => ({
|
||||
@@ -55,8 +62,9 @@ vi.mock("./subagent-registry.js", () => ({
|
||||
countActiveDescendantRuns: () => 0,
|
||||
countPendingDescendantRuns: () => 0,
|
||||
listSubagentRunsForRequester: () => [],
|
||||
isSubagentSessionRunActive: () => true,
|
||||
resolveRequesterForChildSession: () => null,
|
||||
isSubagentSessionRunActive: () => subagentSessionRunActive,
|
||||
shouldIgnorePostCompletionAnnounceForSession: () => shouldIgnorePostCompletion,
|
||||
resolveRequesterForChildSession: () => fallbackRequesterResolution,
|
||||
}));
|
||||
|
||||
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
|
||||
@@ -115,6 +123,10 @@ describe("subagent announce timeout config", () => {
|
||||
configOverride = {
|
||||
session: defaultSessionConfig,
|
||||
};
|
||||
requesterDepthResolver = () => 0;
|
||||
subagentSessionRunActive = true;
|
||||
shouldIgnorePostCompletion = false;
|
||||
fallbackRequesterResolution = null;
|
||||
});
|
||||
|
||||
it("uses 60s timeout by default for direct announce agent call", async () => {
|
||||
@@ -151,4 +163,57 @@ describe("subagent announce timeout config", () => {
|
||||
);
|
||||
expect(completionDirectAgentCall?.timeoutMs).toBe(90_000);
|
||||
});
|
||||
|
||||
it("routes child announce back to ended parent subagent session when parent session still exists", async () => {
|
||||
const parentSessionKey = "agent:main:subagent:parent";
|
||||
requesterDepthResolver = (sessionKey?: string) =>
|
||||
sessionKey === parentSessionKey ? 1 : sessionKey?.includes(":subagent:") ? 1 : 0;
|
||||
subagentSessionRunActive = false;
|
||||
shouldIgnorePostCompletion = false;
|
||||
fallbackRequesterResolution = {
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: { channel: "discord", to: "chan-main", accountId: "acct-main" },
|
||||
};
|
||||
// No sessionId on purpose: existence in store should still count as alive.
|
||||
sessionStore[parentSessionKey] = { updatedAt: Date.now() };
|
||||
|
||||
await runAnnounceFlowForTest("run-parent-route", {
|
||||
requesterSessionKey: parentSessionKey,
|
||||
requesterDisplayKey: parentSessionKey,
|
||||
childSessionKey: `${parentSessionKey}:subagent:child`,
|
||||
});
|
||||
|
||||
const directAgentCall = findGatewayCall(
|
||||
(call) => call.method === "agent" && call.expectFinal === true,
|
||||
);
|
||||
expect(directAgentCall?.params?.sessionKey).toBe(parentSessionKey);
|
||||
expect(directAgentCall?.params?.deliver).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to grandparent only when ended parent subagent session is missing", async () => {
|
||||
const parentSessionKey = "agent:main:subagent:parent-missing";
|
||||
requesterDepthResolver = (sessionKey?: string) =>
|
||||
sessionKey === parentSessionKey ? 1 : sessionKey?.includes(":subagent:") ? 1 : 0;
|
||||
subagentSessionRunActive = false;
|
||||
shouldIgnorePostCompletion = false;
|
||||
fallbackRequesterResolution = {
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: { channel: "discord", to: "chan-main", accountId: "acct-main" },
|
||||
};
|
||||
|
||||
await runAnnounceFlowForTest("run-parent-fallback", {
|
||||
requesterSessionKey: parentSessionKey,
|
||||
requesterDisplayKey: parentSessionKey,
|
||||
childSessionKey: `${parentSessionKey}:subagent:child`,
|
||||
});
|
||||
|
||||
const directAgentCall = findGatewayCall(
|
||||
(call) => call.method === "agent" && call.expectFinal === true,
|
||||
);
|
||||
expect(directAgentCall?.params?.sessionKey).toBe("agent:main:main");
|
||||
expect(directAgentCall?.params?.deliver).toBe(true);
|
||||
expect(directAgentCall?.params?.channel).toBe("discord");
|
||||
expect(directAgentCall?.params?.to).toBe("chan-main");
|
||||
expect(directAgentCall?.params?.accountId).toBe("acct-main");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1260,10 +1260,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
// Parent run has ended. Check if parent SESSION still exists.
|
||||
// If it does, the parent may be waiting for child results — inject there.
|
||||
const parentSessionEntry = loadSessionEntryByKey(targetRequesterSessionKey);
|
||||
const parentSessionAlive =
|
||||
parentSessionEntry &&
|
||||
typeof parentSessionEntry.sessionId === "string" &&
|
||||
parentSessionEntry.sessionId.trim();
|
||||
const parentSessionAlive = Boolean(parentSessionEntry);
|
||||
|
||||
if (!parentSessionAlive) {
|
||||
// Parent session is truly gone — fallback to grandparent
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
countPendingDescendantRunsExcludingRunFromRuns,
|
||||
countPendingDescendantRunsFromRuns,
|
||||
resolveRequesterForChildSessionFromRuns,
|
||||
shouldIgnorePostCompletionAnnounceForSessionFromRuns,
|
||||
} from "./subagent-registry-queries.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
@@ -139,8 +140,9 @@ describe("subagent registry query regressions", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("regression post-completion gating, run-mode sessions ignore late announces once the latest run is ended", () => {
|
||||
// Regression guard: late descendant announces must not reopen completed run-mode sessions.
|
||||
it("regression post-completion gating, run-mode sessions ignore late announces after cleanup completes", () => {
|
||||
// Regression guard: late descendant announces must not reopen run-mode sessions
|
||||
// once their own completion cleanup has fully finished.
|
||||
const childSessionKey = "agent:main:subagent:orchestrator";
|
||||
const runs = toRunMap([
|
||||
makeRun({
|
||||
@@ -149,6 +151,7 @@ describe("subagent registry query regressions", () => {
|
||||
requesterSessionKey: "agent:main:main",
|
||||
createdAt: 1,
|
||||
endedAt: 10,
|
||||
cleanupCompletedAt: 11,
|
||||
spawnMode: "run",
|
||||
}),
|
||||
makeRun({
|
||||
@@ -157,6 +160,7 @@ describe("subagent registry query regressions", () => {
|
||||
requesterSessionKey: "agent:main:main",
|
||||
createdAt: 2,
|
||||
endedAt: 20,
|
||||
cleanupCompletedAt: 21,
|
||||
spawnMode: "run",
|
||||
}),
|
||||
]);
|
||||
@@ -164,6 +168,118 @@ describe("subagent registry query regressions", () => {
|
||||
expect(shouldIgnorePostCompletionAnnounceForSessionFromRuns(runs, childSessionKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps run-mode orchestrators announce-eligible while waiting on child completions", () => {
|
||||
const parentSessionKey = "agent:main:subagent:orchestrator";
|
||||
const childOneSessionKey = `${parentSessionKey}:subagent:child-one`;
|
||||
const childTwoSessionKey = `${parentSessionKey}:subagent:child-two`;
|
||||
|
||||
const runs = toRunMap([
|
||||
makeRun({
|
||||
runId: "run-parent",
|
||||
childSessionKey: parentSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
createdAt: 1,
|
||||
endedAt: 100,
|
||||
cleanupCompletedAt: undefined,
|
||||
spawnMode: "run",
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-child-one",
|
||||
childSessionKey: childOneSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
createdAt: 2,
|
||||
endedAt: 110,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-child-two",
|
||||
childSessionKey: childTwoSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
createdAt: 3,
|
||||
endedAt: 111,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(resolveRequesterForChildSessionFromRuns(runs, childOneSessionKey)).toMatchObject({
|
||||
requesterSessionKey: parentSessionKey,
|
||||
});
|
||||
expect(resolveRequesterForChildSessionFromRuns(runs, childTwoSessionKey)).toMatchObject({
|
||||
requesterSessionKey: parentSessionKey,
|
||||
});
|
||||
expect(shouldIgnorePostCompletionAnnounceForSessionFromRuns(runs, parentSessionKey)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
runs.set(
|
||||
"run-child-one",
|
||||
makeRun({
|
||||
runId: "run-child-one",
|
||||
childSessionKey: childOneSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
createdAt: 2,
|
||||
endedAt: 110,
|
||||
cleanupCompletedAt: 120,
|
||||
}),
|
||||
);
|
||||
runs.set(
|
||||
"run-child-two",
|
||||
makeRun({
|
||||
runId: "run-child-two",
|
||||
childSessionKey: childTwoSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
createdAt: 3,
|
||||
endedAt: 111,
|
||||
cleanupCompletedAt: 121,
|
||||
}),
|
||||
);
|
||||
|
||||
const childThreeSessionKey = `${parentSessionKey}:subagent:child-three`;
|
||||
runs.set(
|
||||
"run-child-three",
|
||||
makeRun({
|
||||
runId: "run-child-three",
|
||||
childSessionKey: childThreeSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
createdAt: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolveRequesterForChildSessionFromRuns(runs, childThreeSessionKey)).toMatchObject({
|
||||
requesterSessionKey: parentSessionKey,
|
||||
});
|
||||
expect(shouldIgnorePostCompletionAnnounceForSessionFromRuns(runs, parentSessionKey)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
runs.set(
|
||||
"run-child-three",
|
||||
makeRun({
|
||||
runId: "run-child-three",
|
||||
childSessionKey: childThreeSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
createdAt: 4,
|
||||
endedAt: 122,
|
||||
cleanupCompletedAt: 123,
|
||||
}),
|
||||
);
|
||||
|
||||
runs.set(
|
||||
"run-parent",
|
||||
makeRun({
|
||||
runId: "run-parent",
|
||||
childSessionKey: parentSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
createdAt: 1,
|
||||
endedAt: 100,
|
||||
cleanupCompletedAt: 130,
|
||||
spawnMode: "run",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(shouldIgnorePostCompletionAnnounceForSessionFromRuns(runs, parentSessionKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("regression post-completion gating, session-mode sessions keep accepting follow-up announces", () => {
|
||||
// Regression guard: persistent session-mode orchestrators must continue receiving child completions.
|
||||
const childSessionKey = "agent:main:subagent:orchestrator-session";
|
||||
|
||||
@@ -82,9 +82,13 @@ export function shouldIgnorePostCompletionAnnounceForSessionFromRuns(
|
||||
if (latest.spawnMode === "session") {
|
||||
return false;
|
||||
}
|
||||
// Run-mode subagent sessions should not process new descendant completion
|
||||
// traffic after their own run has already ended.
|
||||
return typeof latest.endedAt === "number";
|
||||
// Run-mode sessions should only ignore late descendant completion traffic
|
||||
// once the run has fully completed its own cleanup/announce flow.
|
||||
return (
|
||||
typeof latest.endedAt === "number" &&
|
||||
typeof latest.cleanupCompletedAt === "number" &&
|
||||
latest.cleanupCompletedAt >= latest.endedAt
|
||||
);
|
||||
}
|
||||
|
||||
export function countActiveRunsForSessionFromRuns(
|
||||
|
||||
@@ -71,9 +71,11 @@ type ResolvedRequesterKey = {
|
||||
callerIsSubagent: boolean;
|
||||
};
|
||||
|
||||
function resolveRunStatus(entry: SubagentRunRecord, options?: { hasPendingDescendants?: boolean }) {
|
||||
if (options?.hasPendingDescendants) {
|
||||
return "active";
|
||||
function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) {
|
||||
const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
|
||||
if (pendingDescendants > 0) {
|
||||
const childLabel = pendingDescendants === 1 ? "child" : "children";
|
||||
return `active (waiting on ${pendingDescendants} ${childLabel})`;
|
||||
}
|
||||
if (!entry.endedAt) {
|
||||
return "running";
|
||||
@@ -369,14 +371,14 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
|
||||
const recentCutoff = now - recentMinutes * 60_000;
|
||||
const cache = new Map<string, Record<string, SessionEntry>>();
|
||||
|
||||
const pendingDescendantCache = new Map<string, boolean>();
|
||||
const hasPendingDescendants = (sessionKey: string) => {
|
||||
const pendingDescendantCache = new Map<string, number>();
|
||||
const pendingDescendantCount = (sessionKey: string) => {
|
||||
if (pendingDescendantCache.has(sessionKey)) {
|
||||
return pendingDescendantCache.get(sessionKey) === true;
|
||||
return pendingDescendantCache.get(sessionKey) ?? 0;
|
||||
}
|
||||
const hasPending = countPendingDescendantRuns(sessionKey) > 0;
|
||||
pendingDescendantCache.set(sessionKey, hasPending);
|
||||
return hasPending;
|
||||
const pending = Math.max(0, countPendingDescendantRuns(sessionKey));
|
||||
pendingDescendantCache.set(sessionKey, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
let index = 1;
|
||||
@@ -388,8 +390,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
|
||||
}).entry;
|
||||
const totalTokens = resolveTotalTokens(sessionEntry);
|
||||
const usageText = formatTokenUsageDisplay(sessionEntry);
|
||||
const pendingDescendants = pendingDescendantCount(entry.childSessionKey);
|
||||
const status = resolveRunStatus(entry, {
|
||||
hasPendingDescendants: hasPendingDescendants(entry.childSessionKey),
|
||||
pendingDescendants,
|
||||
});
|
||||
const runtime = formatDurationCompact(runtimeMs);
|
||||
const label = truncateLine(resolveSubagentLabel(entry), 48);
|
||||
@@ -402,6 +405,7 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
|
||||
label,
|
||||
task,
|
||||
status,
|
||||
pendingDescendants,
|
||||
runtime,
|
||||
runtimeMs,
|
||||
model: resolveModelRef(sessionEntry) || entry.model,
|
||||
@@ -412,13 +416,13 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
|
||||
return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView };
|
||||
};
|
||||
const active = runs
|
||||
.filter((entry) => !entry.endedAt || hasPendingDescendants(entry.childSessionKey))
|
||||
.filter((entry) => !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0)
|
||||
.map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
|
||||
const recent = runs
|
||||
.filter(
|
||||
(entry) =>
|
||||
!!entry.endedAt &&
|
||||
!hasPendingDescendants(entry.childSessionKey) &&
|
||||
pendingDescendantCount(entry.childSessionKey) === 0 &&
|
||||
(entry.endedAt ?? 0) >= recentCutoff,
|
||||
)
|
||||
.map((entry) =>
|
||||
|
||||
Reference in New Issue
Block a user