fix(cron): suppress fallback summary after attempted announce delivery

This commit is contained in:
Peter Steinberger
2026-02-26 03:07:27 +00:00
parent e16e8f5af2
commit b37dc42240
7 changed files with 108 additions and 11 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting.

View File

@@ -56,6 +56,7 @@ async function expectBestEffortTelegramNotDelivered(
expect(res.status).toBe("ok");
expect(res.delivered).toBe(false);
expect(res.deliveryAttempted).toBe(true);
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
});
@@ -287,6 +288,33 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("marks attempted when announce delivery reports false and best-effort is enabled", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const deps = createCliDeps();
mockAgentPayloads([{ text: "hello from cron" }]);
vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
delivery: {
mode: "announce",
channel: "telegram",
to: "123",
bestEffort: true,
},
});
expect(res.status).toBe("ok");
expect(res.delivered).toBe(false);
expect(res.deliveryAttempted).toBe(true);
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
});
});
it("ignores structured direct delivery failures when best-effort is enabled", async () => {
await expectBestEffortTelegramNotDelivered({
text: "hello from cron",

View File

@@ -117,6 +117,7 @@ type DispatchCronDeliveryParams = {
export type DispatchCronDeliveryState = {
result?: RunCronAgentTurnResult;
delivered: boolean;
deliveryAttempted: boolean;
summary?: string;
outputText?: string;
synthesizedText?: string;
@@ -134,6 +135,7 @@ export async function dispatchCronDelivery(
// `true` means we confirmed at least one outbound send reached the target.
// Keep this strict so timer fallback can safely decide whether to wake main.
let delivered = params.skipMessagingToolDelivery;
let deliveryAttempted = params.skipMessagingToolDelivery;
const failDeliveryTarget = (error: string) =>
params.withRunSession({
status: "error",
@@ -141,6 +143,7 @@ export async function dispatchCronDelivery(
errorKind: "delivery-target",
summary,
outputText,
deliveryAttempted,
...params.telemetry,
});
@@ -162,9 +165,11 @@ export async function dispatchCronDelivery(
return params.withRunSession({
status: "error",
error: params.abortReason(),
deliveryAttempted,
...params.telemetry,
});
}
deliveryAttempted = true;
const deliveryResults = await deliverOutboundPayloads({
cfg: params.cfgWithAgentDefaults,
channel: delivery.channel,
@@ -187,6 +192,7 @@ export async function dispatchCronDelivery(
summary,
outputText,
error: String(err),
deliveryAttempted,
...params.telemetry,
});
}
@@ -277,9 +283,11 @@ export async function dispatchCronDelivery(
return params.withRunSession({
status: "error",
error: params.abortReason(),
deliveryAttempted,
...params.telemetry,
});
}
deliveryAttempted = true;
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: params.agentSessionKey,
childRunId: `${params.job.id}:${params.runSessionId}:${params.runStartedAt}`,
@@ -315,6 +323,7 @@ export async function dispatchCronDelivery(
summary,
outputText,
error: message,
deliveryAttempted,
...params.telemetry,
});
}
@@ -327,6 +336,7 @@ export async function dispatchCronDelivery(
summary,
outputText,
error: String(err),
deliveryAttempted,
...params.telemetry,
});
}
@@ -345,6 +355,7 @@ export async function dispatchCronDelivery(
return {
result: failDeliveryTarget(params.resolvedDelivery.error.message),
delivered,
deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -357,9 +368,11 @@ export async function dispatchCronDelivery(
status: "ok",
summary,
outputText,
deliveryAttempted,
...params.telemetry,
}),
delivered,
deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -383,6 +396,7 @@ export async function dispatchCronDelivery(
return {
result: directResult,
delivered,
deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -395,6 +409,7 @@ export async function dispatchCronDelivery(
return {
result: announceResult,
delivered,
deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -406,6 +421,7 @@ export async function dispatchCronDelivery(
return {
delivered,
deliveryAttempted,
summary,
outputText,
synthesizedText,

View File

@@ -77,6 +77,12 @@ export type RunCronAgentTurnResult = {
* messages. See: https://github.com/openclaw/openclaw/issues/15692
*/
delivered?: boolean;
/**
* `true` when cron attempted announce/direct delivery for this run.
* This is tracked separately from `delivered` because some announce paths
* cannot guarantee a final delivery ack synchronously.
*/
deliveryAttempted?: boolean;
} & CronRunOutcome &
CronRunTelemetry;
@@ -565,7 +571,7 @@ export async function runCronIsolatedAgentTurn(params: {
const embeddedRunError = hasErrorPayload
? (lastErrorPayloadText ?? "cron isolated run returned an error payload")
: undefined;
const resolveRunOutcome = (params?: { delivered?: boolean }) =>
const resolveRunOutcome = (params?: { delivered?: boolean; deliveryAttempted?: boolean }) =>
withRunSession({
status: hasErrorPayload ? "error" : "ok",
...(hasErrorPayload
@@ -574,6 +580,7 @@ export async function runCronIsolatedAgentTurn(params: {
summary,
outputText,
delivered: params?.delivered,
deliveryAttempted: params?.deliveryAttempted,
...telemetry,
});
@@ -619,14 +626,23 @@ export async function runCronIsolatedAgentTurn(params: {
withRunSession,
});
if (deliveryResult.result) {
const resultWithDeliveryMeta: RunCronAgentTurnResult = {
...deliveryResult.result,
deliveryAttempted:
deliveryResult.result.deliveryAttempted ?? deliveryResult.deliveryAttempted,
};
if (!hasErrorPayload || deliveryResult.result.status !== "ok") {
return deliveryResult.result;
return resultWithDeliveryMeta;
}
return resolveRunOutcome({ delivered: deliveryResult.result.delivered });
return resolveRunOutcome({
delivered: deliveryResult.result.delivered,
deliveryAttempted: resultWithDeliveryMeta.deliveryAttempted,
});
}
const delivered = deliveryResult.delivered;
const deliveryAttempted = deliveryResult.deliveryAttempted;
summary = deliveryResult.summary;
outputText = deliveryResult.outputText;
return resolveRunOutcome({ delivered });
return resolveRunOutcome({ delivered, deliveryAttempted });
}

View File

@@ -625,6 +625,28 @@ describe("CronService", () => {
await store.cleanup();
});
it("does not post isolated summary to main when announce delivery was attempted", async () => {
const runIsolatedAgentJob = vi.fn(async () => ({
status: "ok" as const,
summary: "done",
delivered: false,
deliveryAttempted: true,
}));
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
await runIsolatedAnnounceJobAndWait({
cron,
events,
name: "weekly attempted",
status: "ok",
});
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(enqueueSystemEvent).not.toHaveBeenCalled();
expect(requestHeartbeatNow).not.toHaveBeenCalled();
cron.stop();
await store.cleanup();
});
it("migrates legacy payload.provider to payload.channel on load", async () => {
const rawJob = createLegacyDeliveryMigrationJob({
id: "legacy-1",

View File

@@ -80,6 +80,11 @@ export type CronServiceDeps = {
* https://github.com/openclaw/openclaw/issues/15692
*/
delivered?: boolean;
/**
* `true` when announce/direct delivery was attempted for this run, even
* if the final per-message ack status is uncertain.
*/
deliveryAttempted?: boolean;
} & CronRunOutcome &
CronRunTelemetry
>;

View File

@@ -41,6 +41,7 @@ type TimedCronRunOutcome = CronRunOutcome &
CronRunTelemetry & {
jobId: string;
delivered?: boolean;
deliveryAttempted?: boolean;
startedAt: number;
endedAt: number;
};
@@ -606,7 +607,9 @@ export async function executeJobCore(
state: CronServiceState,
job: CronJob,
abortSignal?: AbortSignal,
): Promise<CronRunOutcome & CronRunTelemetry & { delivered?: boolean }> {
): Promise<
CronRunOutcome & CronRunTelemetry & { delivered?: boolean; deliveryAttempted?: boolean }
> {
const resolveAbortError = () => ({
status: "error" as const,
error: timeoutErrorMessage(),
@@ -729,17 +732,22 @@ export async function executeJobCore(
return { status: "error", error: timeoutErrorMessage() };
}
// Post a short summary back to the main session — but only when the
// isolated run did NOT already deliver its output to the target channel.
// When `res.delivered` is true the announce flow (or direct outbound
// delivery) already sent the result, so posting the summary to main
// would wake the main agent and cause a duplicate message.
// Post a short summary back to the main session only when announce
// delivery was requested and we are confident no outbound delivery path
// ran. If delivery was attempted but final ack is uncertain, suppress the
// main summary to avoid duplicate user-facing sends.
// See: https://github.com/openclaw/openclaw/issues/15692
const summaryText = res.summary?.trim();
const deliveryPlan = resolveCronDeliveryPlan(job);
const suppressMainSummary =
res.status === "error" && res.errorKind === "delivery-target" && deliveryPlan.requested;
if (summaryText && deliveryPlan.requested && !res.delivered && !suppressMainSummary) {
if (
summaryText &&
deliveryPlan.requested &&
!res.delivered &&
res.deliveryAttempted !== true &&
!suppressMainSummary
) {
const prefix = "Cron";
const label =
res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`;
@@ -762,6 +770,7 @@ export async function executeJobCore(
error: res.error,
summary: res.summary,
delivered: res.delivered,
deliveryAttempted: res.deliveryAttempted,
sessionId: res.sessionId,
sessionKey: res.sessionKey,
model: res.model,