fix: agent-only announce path, BB message IDs, sender identity, SSRF allowlist (#23970)

* fix(agents): defer announces until descendant cleanup settles

* fix(bluebubbles): harden message metadata extraction

* feat(contributors): rank by composite score (commits, PRs, LOC, tenure)

* refactor(control-ui): move method guard after path checks to improve request handling

* fix subagent completion announce when only current run is pending

* fix(subagents): keep orchestrator runs active until descendants finish

* fix: prepare PR feedback follow-ups (#23970) (thanks @tyler6204)
This commit is contained in:
Tyler Yust
2026-03-01 22:52:11 -08:00
committed by GitHub
parent cfba64c9db
commit f918b336d1
22 changed files with 814 additions and 155 deletions

View File

@@ -876,6 +876,59 @@ describe("sessions tools", () => {
expect(details.text).toContain("recent (last 30m):");
});
it("subagents list keeps ended orchestrators active while descendants are pending", async () => {
resetSubagentRegistryForTests();
const now = Date.now();
addSubagentRunForTests({
runId: "run-orchestrator-ended",
childSessionKey: "agent:main:subagent:orchestrator-ended",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "orchestrate child workers",
cleanup: "keep",
createdAt: now - 5 * 60_000,
startedAt: now - 5 * 60_000,
endedAt: now - 4 * 60_000,
outcome: { status: "ok" },
});
addSubagentRunForTests({
runId: "run-orchestrator-child-active",
childSessionKey: "agent:main:subagent:orchestrator-ended:subagent:child",
requesterSessionKey: "agent:main:subagent:orchestrator-ended",
requesterDisplayKey: "subagent:orchestrator-ended",
task: "child worker still running",
cleanup: "keep",
createdAt: now - 60_000,
startedAt: now - 60_000,
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-list-orchestrator", { action: "list" });
const details = result.details as {
status?: string;
active?: Array<{ runId?: string; status?: string }>;
recent?: Array<{ runId?: string }>;
};
expect(details.status).toBe("ok");
expect(details.active).toEqual(
expect.arrayContaining([
expect.objectContaining({
runId: "run-orchestrator-ended",
status: "active",
}),
]),
);
expect(details.recent?.find((entry) => entry.runId === "run-orchestrator-ended")).toBeFalsy();
});
it("subagents list usage separates io tokens from prompt/cache", async () => {
resetSubagentRegistryForTests();
const now = Date.now();

View File

@@ -34,6 +34,8 @@ const embeddedRunMock = {
const subagentRegistryMock = {
isSubagentSessionRunActive: vi.fn(() => true),
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
countPendingDescendantRuns: vi.fn((_sessionKey: string) => 0),
countPendingDescendantRunsExcludingRun: vi.fn((_sessionKey: string, _runId: string) => 0),
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
};
const subagentDeliveryTargetHookMock = vi.fn(
@@ -172,6 +174,16 @@ describe("subagent announce formatting", () => {
embeddedRunMock.waitForEmbeddedPiRunEnd.mockClear().mockResolvedValue(true);
subagentRegistryMock.isSubagentSessionRunActive.mockClear().mockReturnValue(true);
subagentRegistryMock.countActiveDescendantRuns.mockClear().mockReturnValue(0);
subagentRegistryMock.countPendingDescendantRuns
.mockClear()
.mockImplementation((sessionKey: string) =>
subagentRegistryMock.countActiveDescendantRuns(sessionKey),
);
subagentRegistryMock.countPendingDescendantRunsExcludingRun
.mockClear()
.mockImplementation((sessionKey: string, _runId: string) =>
subagentRegistryMock.countPendingDescendantRuns(sessionKey),
);
subagentRegistryMock.resolveRequesterForChildSession.mockClear().mockReturnValue(null);
hasSubagentDeliveryTargetHook = false;
hookRunnerMock.hasHooks.mockClear();
@@ -408,6 +420,45 @@ describe("subagent announce formatting", () => {
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
});
it("keeps direct completion send when only the announcing run itself is pending", async () => {
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-self-pending",
},
"agent:main:main": {
sessionId: "requester-session-self-pending",
},
};
chatHistoryMock.mockResolvedValueOnce({
messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: done" }] }],
});
subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) =>
sessionKey === "agent:main:main" ? 1 : 0,
);
subagentRegistryMock.countPendingDescendantRunsExcludingRun.mockImplementation(
(sessionKey: string, runId: string) =>
sessionKey === "agent:main:main" && runId === "run-direct-self-pending" ? 0 : 1,
);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-self-pending",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
...defaultOutcomeAnnounce,
expectsCompletionMessage: true,
});
expect(didAnnounce).toBe(true);
expect(subagentRegistryMock.countPendingDescendantRunsExcludingRun).toHaveBeenCalledWith(
"agent:main:main",
"run-direct-self-pending",
);
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(agentSpy).not.toHaveBeenCalled();
});
it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",

View File

@@ -53,6 +53,7 @@ vi.mock("./pi-embedded.js", () => ({
vi.mock("./subagent-registry.js", () => ({
countActiveDescendantRuns: () => 0,
countPendingDescendantRuns: () => 0,
isSubagentSessionRunActive: () => true,
resolveRequesterForChildSession: () => null,
}));

View File

@@ -728,6 +728,7 @@ async function sendSubagentAnnounceDirectly(params: {
completionRouteMode?: "bound" | "fallback" | "hook";
spawnMode?: SpawnSubagentMode;
directIdempotencyKey: string;
currentRunId?: string;
completionDirectOrigin?: DeliveryContext;
directOrigin?: DeliveryContext;
requesterIsSubagent: boolean;
@@ -770,19 +771,35 @@ async function sendSubagentAnnounceDirectly(params: {
(params.completionRouteMode === "bound" || params.completionRouteMode === "hook");
let shouldSendCompletionDirectly = true;
if (!forceBoundSessionDirectDelivery) {
let activeDescendantRuns = 0;
let pendingDescendantRuns = 0;
try {
const { countActiveDescendantRuns } = await import("./subagent-registry.js");
activeDescendantRuns = Math.max(
0,
countActiveDescendantRuns(canonicalRequesterSessionKey),
);
const {
countPendingDescendantRuns,
countPendingDescendantRunsExcludingRun,
countActiveDescendantRuns,
} = await import("./subagent-registry.js");
if (params.currentRunId && typeof countPendingDescendantRunsExcludingRun === "function") {
pendingDescendantRuns = Math.max(
0,
countPendingDescendantRunsExcludingRun(
canonicalRequesterSessionKey,
params.currentRunId,
),
);
} else {
pendingDescendantRuns = Math.max(
0,
typeof countPendingDescendantRuns === "function"
? countPendingDescendantRuns(canonicalRequesterSessionKey)
: countActiveDescendantRuns(canonicalRequesterSessionKey),
);
}
} catch {
// Best-effort only; when unavailable keep historical direct-send behavior.
}
// Keep non-bound completion announcements coordinated via requester
// session routing while sibling/descendant runs are still active.
if (activeDescendantRuns > 0) {
// session routing while sibling or descendant runs are still pending.
if (pendingDescendantRuns > 0) {
shouldSendCompletionDirectly = false;
}
}
@@ -899,6 +916,7 @@ async function deliverSubagentAnnouncement(params: {
completionRouteMode?: "bound" | "fallback" | "hook";
spawnMode?: SpawnSubagentMode;
directIdempotencyKey: string;
currentRunId?: string;
signal?: AbortSignal;
}): Promise<SubagentAnnounceDeliveryResult> {
return await runSubagentAnnounceDispatch({
@@ -922,6 +940,7 @@ async function deliverSubagentAnnouncement(params: {
completionMessage: params.completionMessage,
internalEvents: params.internalEvents,
directIdempotencyKey: params.directIdempotencyKey,
currentRunId: params.currentRunId,
completionDirectOrigin: params.completionDirectOrigin,
completionRouteMode: params.completionRouteMode,
spawnMode: params.spawnMode,
@@ -1203,16 +1222,23 @@ export async function runSubagentAnnounceFlow(params: {
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
let activeChildDescendantRuns = 0;
let pendingChildDescendantRuns = 0;
try {
const { countActiveDescendantRuns } = await import("./subagent-registry.js");
activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey));
const { countPendingDescendantRuns, countActiveDescendantRuns } =
await import("./subagent-registry.js");
pendingChildDescendantRuns = Math.max(
0,
typeof countPendingDescendantRuns === "function"
? countPendingDescendantRuns(params.childSessionKey)
: countActiveDescendantRuns(params.childSessionKey),
);
} catch {
// Best-effort only; fall back to direct announce behavior when unavailable.
}
if (activeChildDescendantRuns > 0) {
// The finished run still has active descendant subagents. Defer announcing
// this run until descendants settle so we avoid posting in-progress updates.
if (pendingChildDescendantRuns > 0) {
// The finished run still has pending descendant subagents (either active,
// or ended but still finishing their own announce and cleanup flow). Defer
// announcing this run until descendants fully settle.
shouldDeleteChildSession = false;
return false;
}
@@ -1383,6 +1409,7 @@ export async function runSubagentAnnounceFlow(params: {
completionRouteMode: completionResolution.routeMode,
spawnMode: params.spawnMode,
directIdempotencyKey,
currentRunId: params.childRunId,
signal: params.signal,
});
// Cron delivery state should only be marked as delivered when we have a

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { resolveDeferredCleanupDecision } from "./subagent-registry-cleanup.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
function makeEntry(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
return {
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "test",
cleanup: "keep",
createdAt: 0,
endedAt: 1_000,
...overrides,
};
}
describe("resolveDeferredCleanupDecision", () => {
const now = 2_000;
it("defers completion-message cleanup while descendants are still pending", () => {
const decision = resolveDeferredCleanupDecision({
entry: makeEntry({ expectsCompletionMessage: true }),
now,
activeDescendantRuns: 2,
announceExpiryMs: 5 * 60_000,
announceCompletionHardExpiryMs: 30 * 60_000,
maxAnnounceRetryCount: 3,
deferDescendantDelayMs: 1_000,
resolveAnnounceRetryDelayMs: () => 2_000,
});
expect(decision).toEqual({ kind: "defer-descendants", delayMs: 1_000 });
});
it("hard-expires completion-message cleanup when descendants never settle", () => {
const decision = resolveDeferredCleanupDecision({
entry: makeEntry({ expectsCompletionMessage: true, endedAt: now - (30 * 60_000 + 1) }),
now,
activeDescendantRuns: 1,
announceExpiryMs: 5 * 60_000,
announceCompletionHardExpiryMs: 30 * 60_000,
maxAnnounceRetryCount: 3,
deferDescendantDelayMs: 1_000,
resolveAnnounceRetryDelayMs: () => 2_000,
});
expect(decision).toEqual({ kind: "give-up", reason: "expiry" });
});
it("keeps regular expiry behavior for non-completion flows", () => {
const decision = resolveDeferredCleanupDecision({
entry: makeEntry({ expectsCompletionMessage: false, endedAt: now - (5 * 60_000 + 1) }),
now,
activeDescendantRuns: 0,
announceExpiryMs: 5 * 60_000,
announceCompletionHardExpiryMs: 30 * 60_000,
maxAnnounceRetryCount: 3,
deferDescendantDelayMs: 1_000,
resolveAnnounceRetryDelayMs: () => 2_000,
});
expect(decision).toEqual({ kind: "give-up", reason: "expiry", retryCount: 1 });
});
it("uses retry backoff for completion-message flows once descendants are settled", () => {
const decision = resolveDeferredCleanupDecision({
entry: makeEntry({ expectsCompletionMessage: true, announceRetryCount: 1 }),
now,
activeDescendantRuns: 0,
announceExpiryMs: 5 * 60_000,
announceCompletionHardExpiryMs: 30 * 60_000,
maxAnnounceRetryCount: 3,
deferDescendantDelayMs: 1_000,
resolveAnnounceRetryDelayMs: (retryCount) => retryCount * 1_000,
});
expect(decision).toEqual({ kind: "retry", retryCount: 2, resumeDelayMs: 2_000 });
});
});

View File

@@ -35,20 +35,27 @@ export function resolveDeferredCleanupDecision(params: {
now: number;
activeDescendantRuns: number;
announceExpiryMs: number;
announceCompletionHardExpiryMs: number;
maxAnnounceRetryCount: number;
deferDescendantDelayMs: number;
resolveAnnounceRetryDelayMs: (retryCount: number) => number;
}): DeferredCleanupDecision {
const endedAgo = resolveEndedAgoMs(params.entry, params.now);
if (params.entry.expectsCompletionMessage === true && params.activeDescendantRuns > 0) {
if (endedAgo > params.announceExpiryMs) {
const isCompletionMessageFlow = params.entry.expectsCompletionMessage === true;
const completionHardExpiryExceeded =
isCompletionMessageFlow && endedAgo > params.announceCompletionHardExpiryMs;
if (isCompletionMessageFlow && params.activeDescendantRuns > 0) {
if (completionHardExpiryExceeded) {
return { kind: "give-up", reason: "expiry" };
}
return { kind: "defer-descendants", delayMs: params.deferDescendantDelayMs };
}
const retryCount = (params.entry.announceRetryCount ?? 0) + 1;
if (retryCount >= params.maxAnnounceRetryCount || endedAgo > params.announceExpiryMs) {
const expiryExceeded = isCompletionMessageFlow
? completionHardExpiryExceeded
: endedAgo > params.announceExpiryMs;
if (retryCount >= params.maxAnnounceRetryCount || expiryExceeded) {
return {
kind: "give-up",
reason: retryCount >= params.maxAnnounceRetryCount ? "retry-limit" : "expiry",

View File

@@ -113,6 +113,59 @@ export function countActiveDescendantRunsFromRuns(
return count;
}
function countPendingDescendantRunsInternal(
runs: Map<string, SubagentRunRecord>,
rootSessionKey: string,
excludeRunId?: string,
): number {
const root = rootSessionKey.trim();
if (!root) {
return 0;
}
const excludedRunId = excludeRunId?.trim();
const pending = [root];
const visited = new Set<string>([root]);
let count = 0;
for (let index = 0; index < pending.length; index += 1) {
const requester = pending[index];
if (!requester) {
continue;
}
for (const [runId, entry] of runs.entries()) {
if (entry.requesterSessionKey !== requester) {
continue;
}
const runEnded = typeof entry.endedAt === "number";
const cleanupCompleted = typeof entry.cleanupCompletedAt === "number";
if ((!runEnded || !cleanupCompleted) && runId !== excludedRunId) {
count += 1;
}
const childKey = entry.childSessionKey.trim();
if (!childKey || visited.has(childKey)) {
continue;
}
visited.add(childKey);
pending.push(childKey);
}
}
return count;
}
export function countPendingDescendantRunsFromRuns(
runs: Map<string, SubagentRunRecord>,
rootSessionKey: string,
): number {
return countPendingDescendantRunsInternal(runs, rootSessionKey);
}
export function countPendingDescendantRunsExcludingRunFromRuns(
runs: Map<string, SubagentRunRecord>,
rootSessionKey: string,
excludeRunId: string,
): number {
return countPendingDescendantRunsInternal(runs, rootSessionKey, excludeRunId);
}
export function listDescendantRunsForRequesterFromRuns(
runs: Map<string, SubagentRunRecord>,
rootSessionKey: string,

View File

@@ -156,6 +156,41 @@ describe("announce loop guard (#18264)", () => {
expect(stored?.cleanupCompletedAt).toBeDefined();
});
test("expired completion-message entries are still resumed for announce", async () => {
announceFn.mockReset();
announceFn.mockResolvedValueOnce(true);
registry.resetSubagentRegistryForTests();
const now = Date.now();
const runId = "test-expired-completion-message";
loadSubagentRegistryFromDisk.mockReturnValue(
new Map([
[
runId,
{
runId,
childSessionKey: "agent:main:subagent:child-1",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "agent:main:main",
task: "completion announce after long descendants",
cleanup: "keep" as const,
createdAt: now - 20 * 60_000,
startedAt: now - 19 * 60_000,
endedAt: now - 10 * 60_000,
cleanupHandled: false,
expectsCompletionMessage: true,
},
],
]),
);
registry.initSubagentRegistry();
await Promise.resolve();
await Promise.resolve();
expect(announceFn).toHaveBeenCalledTimes(1);
});
test("announce rejection resets cleanupHandled so retries can resume", async () => {
announceFn.mockReset();
announceFn.mockRejectedValueOnce(new Error("announce failed"));

View File

@@ -162,4 +162,88 @@ describe("subagent registry nested agent tracking", () => {
expect(countActiveDescendantRuns("agent:main:main")).toBe(1);
expect(countActiveDescendantRuns("agent:main:subagent:orch-ended")).toBe(1);
});
it("countPendingDescendantRuns includes ended descendants until cleanup completes", async () => {
const { addSubagentRunForTests, countPendingDescendantRuns } = subagentRegistry;
addSubagentRunForTests({
runId: "run-parent-ended-pending",
childSessionKey: "agent:main:subagent:orch-pending",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "orchestrate",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
cleanupHandled: false,
cleanupCompletedAt: undefined,
});
addSubagentRunForTests({
runId: "run-leaf-ended-pending",
childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf",
requesterSessionKey: "agent:main:subagent:orch-pending",
requesterDisplayKey: "orch-pending",
task: "leaf",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
cleanupHandled: true,
cleanupCompletedAt: undefined,
});
expect(countPendingDescendantRuns("agent:main:main")).toBe(2);
expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
addSubagentRunForTests({
runId: "run-leaf-completed",
childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf-completed",
requesterSessionKey: "agent:main:subagent:orch-pending",
requesterDisplayKey: "orch-pending",
task: "leaf complete",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
cleanupHandled: true,
cleanupCompletedAt: 3,
});
expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
});
it("countPendingDescendantRunsExcludingRun ignores only the active announce run", async () => {
const { addSubagentRunForTests, countPendingDescendantRunsExcludingRun } = subagentRegistry;
addSubagentRunForTests({
runId: "run-self",
childSessionKey: "agent:main:subagent:worker",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "self",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
cleanupHandled: false,
cleanupCompletedAt: undefined,
});
addSubagentRunForTests({
runId: "run-sibling",
childSessionKey: "agent:main:subagent:sibling",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "sibling",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
cleanupHandled: false,
cleanupCompletedAt: undefined,
});
expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-self")).toBe(1);
expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-sibling")).toBe(1);
});
});

View File

@@ -537,7 +537,7 @@ describe("subagent registry steer restarts", () => {
});
});
it("emits subagent_ended when completion cleanup expires with active descendants", async () => {
it("keeps completion cleanup pending while descendants are still active", async () => {
announceSpy.mockResolvedValue(false);
mod.registerSubagentRun({
@@ -574,10 +574,11 @@ describe("subagent registry steer restarts", () => {
const event = call[0] as { runId?: string; reason?: string };
return event.runId === "run-parent-expiry" && event.reason === "subagent-complete";
});
expect(parentHookCall).toBeDefined();
expect(parentHookCall).toBeUndefined();
const parent = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-parent-expiry");
expect(parent?.cleanupCompletedAt).toBeTypeOf("number");
expect(parent?.cleanupCompletedAt).toBeUndefined();
expect(parent?.cleanupHandled).toBe(false);
});
});

View File

@@ -32,6 +32,8 @@ import {
import {
countActiveDescendantRunsFromRuns,
countActiveRunsForSessionFromRuns,
countPendingDescendantRunsExcludingRunFromRuns,
countPendingDescendantRunsFromRuns,
findRunIdsByChildSessionKeyFromRuns,
listDescendantRunsForRequesterFromRuns,
listRunsForRequesterFromRuns,
@@ -63,10 +65,15 @@ const MAX_ANNOUNCE_RETRY_DELAY_MS = 8_000;
*/
const MAX_ANNOUNCE_RETRY_COUNT = 3;
/**
* Announce entries older than this are force-expired even if delivery never
* succeeded. Guards against stale registry entries surviving gateway restarts.
* Non-completion announce entries older than this are force-expired even if
* delivery never succeeded.
*/
const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes
/**
* Completion-message flows can wait for descendants to finish, but this hard
* cap prevents indefinite pending state when descendants never fully settle.
*/
const ANNOUNCE_COMPLETION_HARD_EXPIRY_MS = 30 * 60_000; // 30 minutes
type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id";
/**
* Embedded runs can emit transient lifecycle `error` events while provider/model
@@ -445,7 +452,11 @@ function resumeSubagentRun(runId: string) {
persistSubagentRuns();
return;
}
if (typeof entry.endedAt === "number" && Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS) {
if (
entry.expectsCompletionMessage !== true &&
typeof entry.endedAt === "number" &&
Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS
) {
logAnnounceGiveUp(entry, "expiry");
entry.cleanupCompletedAt = Date.now();
persistSubagentRuns();
@@ -462,6 +473,7 @@ function resumeSubagentRun(runId: string) {
) {
const waitMs = Math.max(1, earliestRetryAt - now);
setTimeout(() => {
resumedRuns.delete(runId);
resumeSubagentRun(runId);
}, waitMs).unref?.();
resumedRuns.add(runId);
@@ -709,8 +721,10 @@ async function finalizeSubagentCleanup(
const deferredDecision = resolveDeferredCleanupDecision({
entry,
now,
activeDescendantRuns: Math.max(0, countActiveDescendantRuns(entry.childSessionKey)),
// Defer until descendants are fully settled, including post-end cleanup.
activeDescendantRuns: Math.max(0, countPendingDescendantRuns(entry.childSessionKey)),
announceExpiryMs: ANNOUNCE_EXPIRY_MS,
announceCompletionHardExpiryMs: ANNOUNCE_COMPLETION_HARD_EXPIRY_MS,
maxAnnounceRetryCount: MAX_ANNOUNCE_RETRY_COUNT,
deferDescendantDelayMs: MIN_ANNOUNCE_RETRY_DELAY_MS,
resolveAnnounceRetryDelayMs,
@@ -753,6 +767,7 @@ async function finalizeSubagentCleanup(
// Applies to both keep/delete cleanup modes so delete-runs are only removed
// after a successful announce (or terminal give-up).
entry.cleanupHandled = false;
// Clear the in-flight resume marker so the scheduled retry can run again.
resumedRuns.delete(runId);
persistSubagentRuns();
if (deferredDecision.resumeDelayMs == null) {
@@ -815,9 +830,10 @@ function retryDeferredCompletedAnnounces(excludeRunId?: string) {
if (suppressAnnounceForSteerRestart(entry)) {
continue;
}
// Force-expire announces that have been pending too long (#18264).
// Force-expire stale non-completion announces; completion-message flows can
// stay pending while descendants run for a long time.
const endedAgo = now - (entry.endedAt ?? now);
if (endedAgo > ANNOUNCE_EXPIRY_MS) {
if (entry.expectsCompletionMessage !== true && endedAgo > ANNOUNCE_EXPIRY_MS) {
logAnnounceGiveUp(entry, "expiry");
entry.cleanupCompletedAt = now;
persistSubagentRuns();
@@ -1214,6 +1230,24 @@ export function countActiveDescendantRuns(rootSessionKey: string): number {
);
}
export function countPendingDescendantRuns(rootSessionKey: string): number {
return countPendingDescendantRunsFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),
rootSessionKey,
);
}
export function countPendingDescendantRunsExcludingRun(
rootSessionKey: string,
excludeRunId: string,
): number {
return countPendingDescendantRunsExcludingRunFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),
rootSessionKey,
excludeRunId,
);
}
export function listDescendantRunsForRequester(rootSessionKey: string): SubagentRunRecord[] {
return listDescendantRunsForRequesterFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),

View File

@@ -31,6 +31,7 @@ import { optionalStringEnum } from "../schema/typebox.js";
import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import {
clearSubagentRunSteerRestart,
countPendingDescendantRuns,
listSubagentRunsForRequester,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
@@ -70,7 +71,10 @@ type ResolvedRequesterKey = {
callerIsSubagent: boolean;
};
function resolveRunStatus(entry: SubagentRunRecord) {
function resolveRunStatus(entry: SubagentRunRecord, options?: { hasPendingDescendants?: boolean }) {
if (options?.hasPendingDescendants) {
return "active";
}
if (!entry.endedAt) {
return "running";
}
@@ -365,6 +369,16 @@ 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) => {
if (pendingDescendantCache.has(sessionKey)) {
return pendingDescendantCache.get(sessionKey) === true;
}
const hasPending = countPendingDescendantRuns(sessionKey) > 0;
pendingDescendantCache.set(sessionKey, hasPending);
return hasPending;
};
let index = 1;
const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
const sessionEntry = resolveSessionEntryForKey({
@@ -374,7 +388,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = formatTokenUsageDisplay(sessionEntry);
const status = resolveRunStatus(entry);
const status = resolveRunStatus(entry, {
hasPendingDescendants: hasPendingDescendants(entry.childSessionKey),
});
const runtime = formatDurationCompact(runtimeMs);
const label = truncateLine(resolveSubagentLabel(entry), 48);
const task = truncateLine(entry.task.trim(), 72);
@@ -396,10 +412,15 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView };
};
const active = runs
.filter((entry) => !entry.endedAt)
.filter((entry) => !entry.endedAt || hasPendingDescendants(entry.childSessionKey))
.map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
const recent = runs
.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff)
.filter(
(entry) =>
!!entry.endedAt &&
!hasPendingDescendants(entry.childSessionKey) &&
(entry.endedAt ?? 0) >= recentCutoff,
)
.map((entry) =>
buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
);

View File

@@ -18,6 +18,14 @@ function parseConversationInfoPayload(text: string): Record<string, unknown> {
return JSON.parse(match[1]) as Record<string, unknown>;
}
function parseSenderInfoPayload(text: string): Record<string, unknown> {
const match = text.match(/Sender \(untrusted metadata\):\n```json\n([\s\S]*?)\n```/);
if (!match?.[1]) {
throw new Error("missing sender info json block");
}
return JSON.parse(match[1]) as Record<string, unknown>;
}
describe("buildInboundMetaSystemPrompt", () => {
it("includes session-stable routing fields", () => {
const prompt = buildInboundMetaSystemPrompt({
@@ -147,6 +155,29 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["sender"]).toBe("+15551234567");
});
it("prefers SenderName in conversation info sender identity", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
SenderName: " Tyler ",
SenderId: " +15551234567 ",
} as TemplateContext);
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["sender"]).toBe("Tyler");
});
it("includes sender metadata block for direct chats", () => {
const text = buildInboundUserContextPrefix({
ChatType: "direct",
SenderName: "Tyler",
SenderId: "+15551234567",
} as TemplateContext);
const senderInfo = parseSenderInfoPayload(text);
expect(senderInfo["label"]).toBe("Tyler (+15551234567)");
expect(senderInfo["id"]).toBe("+15551234567");
});
it("includes formatted timestamp in conversation info when provided", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
@@ -187,7 +218,7 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["message_id"]).toBe("msg-123");
});
it("includes message_id_full when it differs from message_id", () => {
it("prefers MessageSid when both MessageSid and MessageSidFull are present", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
MessageSid: "short-id",
@@ -196,18 +227,18 @@ describe("buildInboundUserContextPrefix", () => {
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["message_id"]).toBe("short-id");
expect(conversationInfo["message_id_full"]).toBe("full-provider-message-id");
expect(conversationInfo["message_id_full"]).toBeUndefined();
});
it("omits message_id_full when it matches message_id", () => {
it("falls back to MessageSidFull when MessageSid is missing", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
MessageSid: "same-id",
MessageSidFull: "same-id",
MessageSid: " ",
MessageSidFull: "full-provider-message-id",
} as TemplateContext);
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["message_id"]).toBe("same-id");
expect(conversationInfo["message_id"]).toBe("full-provider-message-id");
expect(conversationInfo["message_id_full"]).toBeUndefined();
});

View File

@@ -88,21 +88,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
const messageId = safeTrim(ctx.MessageSid);
const messageIdFull = safeTrim(ctx.MessageSidFull);
const resolvedMessageId = messageId ?? messageIdFull;
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
const conversationInfo = {
message_id: isDirect ? undefined : messageId,
message_id_full: isDirect
? undefined
: messageIdFull && messageIdFull !== messageId
? messageIdFull
: undefined,
message_id: isDirect ? undefined : resolvedMessageId,
reply_to_id: isDirect ? undefined : safeTrim(ctx.ReplyToId),
sender_id: isDirect ? undefined : safeTrim(ctx.SenderId),
conversation_label: isDirect ? undefined : safeTrim(ctx.ConversationLabel),
sender: isDirect
? undefined
: (safeTrim(ctx.SenderE164) ?? safeTrim(ctx.SenderId) ?? safeTrim(ctx.SenderUsername)),
: (safeTrim(ctx.SenderName) ??
safeTrim(ctx.SenderE164) ??
safeTrim(ctx.SenderId) ??
safeTrim(ctx.SenderUsername)),
timestamp: timestampStr,
group_subject: safeTrim(ctx.GroupSubject),
group_channel: safeTrim(ctx.GroupChannel),
@@ -131,20 +130,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
);
}
const senderInfo = isDirect
? undefined
: {
label: resolveSenderLabel({
name: safeTrim(ctx.SenderName),
username: safeTrim(ctx.SenderUsername),
tag: safeTrim(ctx.SenderTag),
e164: safeTrim(ctx.SenderE164),
}),
name: safeTrim(ctx.SenderName),
username: safeTrim(ctx.SenderUsername),
tag: safeTrim(ctx.SenderTag),
e164: safeTrim(ctx.SenderE164),
};
const senderInfo = {
label: resolveSenderLabel({
name: safeTrim(ctx.SenderName),
username: safeTrim(ctx.SenderUsername),
tag: safeTrim(ctx.SenderTag),
e164: safeTrim(ctx.SenderE164),
id: safeTrim(ctx.SenderId),
}),
id: safeTrim(ctx.SenderId),
name: safeTrim(ctx.SenderName),
username: safeTrim(ctx.SenderUsername),
tag: safeTrim(ctx.SenderTag),
e164: safeTrim(ctx.SenderE164),
};
if (senderInfo?.label) {
blocks.push(
["Sender (untrusted metadata):", "```json", JSON.stringify(senderInfo, null, 2), "```"].join(

View File

@@ -42,7 +42,7 @@ describe("handleControlUiHttpRequest", () => {
function runControlUiRequest(params: {
url: string;
method: "GET" | "HEAD";
method: "GET" | "HEAD" | "POST";
rootPath: string;
basePath?: string;
}) {
@@ -356,6 +356,36 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("falls through POST requests when basePath is empty", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const { handled, end } = runControlUiRequest({
url: "/webhook/bluebubbles",
method: "POST",
rootPath: tmp,
});
expect(handled).toBe(false);
expect(end).not.toHaveBeenCalled();
},
});
});
it("returns 405 for POST requests under configured basePath", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const { handled, res, end } = runControlUiRequest({
url: "/openclaw/",
method: "POST",
rootPath: tmp,
basePath: "/openclaw",
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(405);
expect(end).toHaveBeenCalledWith("Method Not Allowed");
},
});
});
it("rejects absolute-path escape attempts under basePath routes", async () => {
await withBasePathRootFixture({
siblingDir: "ui-secrets",

View File

@@ -275,13 +275,6 @@ export function handleControlUiHttpRequest(
if (!urlRaw) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const url = new URL(urlRaw, "http://localhost");
const basePath = normalizeControlUiBasePath(opts?.basePath);
const pathname = url.pathname;
@@ -315,6 +308,19 @@ export function handleControlUiHttpRequest(
}
}
// Method guard must run AFTER path checks so that POST requests to non-UI
// paths (channel webhooks etc.) fall through to later handlers. When no
// basePath is configured the SPA catch-all would otherwise 405 every POST.
if (req.method !== "GET" && req.method !== "HEAD") {
if (!basePath) {
return false;
}
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
applyControlUiSecurityHeaders(res);
const bootstrapConfigPath = basePath