From 53f9b7d4e74a109dee007edf93593070c04bd086 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:48:49 +0000 Subject: [PATCH] fix(automation): harden announce delivery + cron coding profile (#25813 #25821 #25822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Shawn Co-authored-by: 不做了睡大觉 Co-authored-by: Marcus Widing --- CHANGELOG.md | 1 + extensions/feishu/src/media.test.ts | 4 +- src/agents/subagent-announce.format.test.ts | 96 +++++++++++ src/agents/subagent-announce.ts | 161 +++++++++++++++--- src/agents/tool-catalog.ts | 2 +- src/agents/tool-policy.test.ts | 2 + .../tools-invoke-http.cron-regression.test.ts | 19 +++ 7 files changed, 255 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b74f1d444..a0deda09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 5851e8490..fc600481e 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -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); diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index b486dff75..91f4b0d67 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -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": { diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index c0c981e8e..7d7fd7ceb 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -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 { + if (ms <= 0) { + return; + } + if (!signal) { + await new Promise((resolve) => setTimeout(resolve, ms)); + return; + } + if (signal.aborted) { + return; + } + await new Promise((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(params: { + operation: string; + signal?: AbortSignal; + run: () => Promise; +}): Promise { + 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" }; } diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 705656889..bbada8e7b 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -190,7 +190,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ label: "cron", description: "Schedule tasks", sectionId: "automation", - profiles: [], + profiles: ["coding"], includeInOpenClawGroup: true, }, { diff --git a/src/agents/tool-policy.test.ts b/src/agents/tool-policy.test.ts index e2fe0a4d1..9a9f51218 100644 --- a/src/agents/tool-policy.test.ts +++ b/src/agents/tool-policy.test.ts @@ -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(); }); diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts index a3df26338..509df1449 100644 --- a/src/gateway/tools-invoke-http.cron-regression.test.ts +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -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); + }); });