fix(automation): harden announce delivery + cron coding profile (#25813 #25821 #25822)

Co-authored-by: Shawn <shenghuikevin@shenghuideMac-mini.local>
Co-authored-by: 不做了睡大觉 <user@example.com>
Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
This commit is contained in:
Peter Steinberger
2026-02-24 23:48:49 +00:00
parent 36d1e1dcff
commit 53f9b7d4e7
7 changed files with 255 additions and 30 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin.
- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
@@ -42,7 +42,7 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
expect(pathValue).not.toContain("..");
const tmpRoot = path.resolve(os.tmpdir());
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(pathValue);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);

View File

@@ -401,6 +401,102 @@ describe("subagent announce formatting", () => {
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
});
it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-completion-skip",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
...defaultOutcomeAnnounce,
expectsCompletionMessage: true,
roundOneReply: "ANNOUNCE_SKIP",
});
expect(didAnnounce).toBe(true);
expect(sendSpy).not.toHaveBeenCalled();
expect(agentSpy).not.toHaveBeenCalled();
});
it("suppresses announce flow for whitespace-padded ANNOUNCE_SKIP and still runs cleanup", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-skip-whitespace",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
...defaultOutcomeAnnounce,
cleanup: "delete",
roundOneReply: " ANNOUNCE_SKIP ",
});
expect(didAnnounce).toBe(true);
expect(sendSpy).not.toHaveBeenCalled();
expect(agentSpy).not.toHaveBeenCalled();
expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1);
});
it("retries completion direct send on transient channel-unavailable errors", async () => {
sendSpy
.mockRejectedValueOnce(new Error("Error: No active WhatsApp Web listener (account: default)"))
.mockRejectedValueOnce(new Error("UNAVAILABLE: listener reconnecting"))
.mockResolvedValueOnce({ runId: "send-main", status: "ok" });
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-completion-retry",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: { channel: "whatsapp", to: "+15550000000", accountId: "default" },
...defaultOutcomeAnnounce,
expectsCompletionMessage: true,
roundOneReply: "final answer",
});
expect(didAnnounce).toBe(true);
expect(sendSpy).toHaveBeenCalledTimes(3);
expect(agentSpy).not.toHaveBeenCalled();
});
it("does not retry completion direct send on permanent channel errors", async () => {
sendSpy.mockRejectedValueOnce(new Error("unsupported channel: telegram"));
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-completion-no-retry",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: { channel: "telegram", to: "telegram:1234" },
...defaultOutcomeAnnounce,
expectsCompletionMessage: true,
roundOneReply: "final answer",
});
expect(didAnnounce).toBe(false);
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(agentSpy).not.toHaveBeenCalled();
});
it("retries direct agent announce on transient channel-unavailable errors", async () => {
agentSpy
.mockRejectedValueOnce(new Error("No active WhatsApp Web listener (account: default)"))
.mockRejectedValueOnce(new Error("UNAVAILABLE: delivery temporarily unavailable"))
.mockResolvedValueOnce({ runId: "run-main", status: "ok" });
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-agent-retry",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: { channel: "whatsapp", to: "+15551112222", accountId: "default" },
...defaultOutcomeAnnounce,
roundOneReply: "worker result",
});
expect(didAnnounce).toBe(true);
expect(agentSpy).toHaveBeenCalledTimes(3);
expect(sendSpy).not.toHaveBeenCalled();
});
it("keeps completion-mode delivery coordinated when sibling runs are still active", async () => {
sessionStore = {
"agent:main:subagent:test": {

View File

@@ -37,12 +37,16 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import type { SpawnSubagentMode } from "./subagent-spawn.js";
import { readLatestAssistantReply } from "./tools/agent-step.js";
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1";
const FAST_TEST_RETRY_INTERVAL_MS = 8;
const FAST_TEST_REPLY_CHANGE_WAIT_MS = 20;
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 60_000;
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
const DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS = FAST_TEST_MODE
? ([8, 16, 32] as const)
: ([5_000, 10_000, 20_000] as const);
type ToolResultMessage = {
role?: unknown;
@@ -72,6 +76,9 @@ function buildCompletionDeliveryMessage(params: {
outcome?: SubagentRunOutcome;
}): string {
const findingsText = params.findings.trim();
if (isAnnounceSkip(findingsText)) {
return "";
}
const hasFindings = findingsText.length > 0 && findingsText !== "(no output)";
const header = (() => {
if (params.outcome?.status === "error") {
@@ -111,6 +118,92 @@ function summarizeDeliveryError(error: unknown): string {
}
}
const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
/\berrorcode=unavailable\b/i,
/\bstatus\s*[:=]\s*"?unavailable\b/i,
/\bUNAVAILABLE\b/,
/no active .* listener/i,
/gateway not connected/i,
/gateway closed \(1006/i,
/gateway timeout/i,
/\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i,
];
const PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
/unsupported channel/i,
/unknown channel/i,
/chat not found/i,
/user not found/i,
/bot was blocked by the user/i,
/forbidden: bot was kicked/i,
/recipient is not a valid/i,
/outbound not configured for channel/i,
];
function isTransientAnnounceDeliveryError(error: unknown): boolean {
const message = summarizeDeliveryError(error);
if (!message) {
return false;
}
if (PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message))) {
return false;
}
return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message));
}
async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) {
return;
}
if (!signal) {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
return;
}
if (signal.aborted) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
resolve();
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function runAnnounceDeliveryWithRetry<T>(params: {
operation: string;
signal?: AbortSignal;
run: () => Promise<T>;
}): Promise<T> {
let retryIndex = 0;
for (;;) {
if (params.signal?.aborted) {
throw new Error("announce delivery aborted");
}
try {
return await params.run();
} catch (err) {
const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex];
if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) {
throw err;
}
const nextAttempt = retryIndex + 2;
const maxAttempts = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS.length + 1;
defaultRuntime.log(
`[warn] Subagent announce ${params.operation} transient failure, retrying ${nextAttempt}/${maxAttempts} in ${Math.round(delayMs / 1000)}s: ${summarizeDeliveryError(err)}`,
);
retryIndex += 1;
await waitForAnnounceRetryDelay(delayMs, params.signal);
}
}
}
function extractToolResultText(content: unknown): string {
if (typeof content === "string") {
return sanitizeTextContent(content);
@@ -712,18 +805,23 @@ async function sendSubagentAnnounceDirectly(params: {
path: "none",
};
}
await callGateway({
method: "send",
params: {
channel: completionChannel,
to: completionTo,
accountId: completionDirectOrigin?.accountId,
threadId: completionThreadId,
sessionKey: canonicalRequesterSessionKey,
message: params.completionMessage,
idempotencyKey: params.directIdempotencyKey,
},
timeoutMs: announceTimeoutMs,
await runAnnounceDeliveryWithRetry({
operation: "completion direct send",
signal: params.signal,
run: async () =>
await callGateway({
method: "send",
params: {
channel: completionChannel,
to: completionTo,
accountId: completionDirectOrigin?.accountId,
threadId: completionThreadId,
sessionKey: canonicalRequesterSessionKey,
message: params.completionMessage,
idempotencyKey: params.directIdempotencyKey,
},
timeoutMs: announceTimeoutMs,
}),
});
return {
@@ -754,21 +852,26 @@ async function sendSubagentAnnounceDirectly(params: {
path: "none",
};
}
await callGateway({
method: "agent",
params: {
sessionKey: canonicalRequesterSessionKey,
message: params.triggerMessage,
deliver: shouldDeliverExternally,
bestEffortDeliver: params.bestEffortDeliver,
channel: shouldDeliverExternally ? directChannel : undefined,
accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined,
to: shouldDeliverExternally ? directTo : undefined,
threadId: shouldDeliverExternally ? threadId : undefined,
idempotencyKey: params.directIdempotencyKey,
},
expectFinal: true,
timeoutMs: announceTimeoutMs,
await runAnnounceDeliveryWithRetry({
operation: "direct announce agent call",
signal: params.signal,
run: async () =>
await callGateway({
method: "agent",
params: {
sessionKey: canonicalRequesterSessionKey,
message: params.triggerMessage,
deliver: shouldDeliverExternally,
bestEffortDeliver: params.bestEffortDeliver,
channel: shouldDeliverExternally ? directChannel : undefined,
accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined,
to: shouldDeliverExternally ? directTo : undefined,
threadId: shouldDeliverExternally ? threadId : undefined,
idempotencyKey: params.directIdempotencyKey,
},
expectFinal: true,
timeoutMs: announceTimeoutMs,
}),
});
return {
@@ -1096,6 +1199,10 @@ export async function runSubagentAnnounceFlow(params: {
return false;
}
if (isAnnounceSkip(reply)) {
return true;
}
if (!outcome) {
outcome = { status: "unknown" };
}

View File

@@ -190,7 +190,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
label: "cron",
description: "Schedule tasks",
sectionId: "automation",
profiles: [],
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{

View File

@@ -56,6 +56,8 @@ describe("tool-policy", () => {
it("resolves known profiles and ignores unknown ones", () => {
const coding = resolveToolProfilePolicy("coding");
expect(coding?.allow).toContain("read");
expect(coding?.allow).toContain("cron");
expect(coding?.allow).not.toContain("gateway");
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
});

View File

@@ -120,4 +120,23 @@ describe("tools invoke HTTP denylist", () => {
expect(cronRes.status).toBe(200);
});
it("keeps cron available under coding profile without exposing gateway", async () => {
cfg = {
tools: {
profile: "coding",
},
gateway: {
tools: {
allow: ["cron"],
},
},
};
const cronRes = await invoke("cron");
const gatewayRes = await invoke("gateway");
expect(cronRes.status).toBe(200);
expect(gatewayRes.status).toBe(404);
});
});