Skip to content

Commit 75001a0

Browse files
committed
fix cron announce routing and timeout handling
1 parent e1015a5 commit 75001a0

12 files changed

Lines changed: 298 additions & 43 deletions

CHANGELOG.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ Docs: https://docs.openclaw.ai
2525

2626
### Fixes
2727

28-
- BlueBubbles: add fallback path to recover outbound `message_id` from `fromMe` webhooks when platform message IDs are missing.
29-
- BlueBubbles: match outbound message-id fallback recovery by chat identifier as well as account context.
30-
- BlueBubbles: include sender identifier in untrusted conversation metadata for conversation info payloads.
28+
- BlueBubbles: add fallback path to recover outbound `message_id` from `fromMe` webhooks when platform message IDs are missing. Thanks @tyler6204.
29+
- BlueBubbles: match outbound message-id fallback recovery by chat identifier as well as account context. Thanks @tyler6204.
30+
- BlueBubbles: include sender identifier in untrusted conversation metadata for conversation info payloads. Thanks @tyler6204.
3131
- macOS/Update: correct the Sparkle appcast version for 2026.2.15 so updates are offered again. (#18201)
3232
- Gateway/Auth: clear stale device-auth tokens after device token mismatch errors so re-paired clients can re-auth. (#18201)
3333
- Voice call/Gateway: prevent overlapping closed-loop turn races with per-call turn locking, route transcript dedupe via source-aware fingerprints with strict cache eviction bounds, and harden `voicecall latency` stats for large logs without spread-operator stack overflow. (#19140) Thanks @mbelinky.
@@ -47,6 +47,9 @@ Docs: https://docs.openclaw.ai
4747
- Feishu: detect bot mentions in post messages with embedded docs when `message.mentions` is empty. (#18074) Thanks @popomore.
4848
- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
4949
- Cron: preserve default model fallbacks for cron agent runs when only `model.primary` is overridden, so failover still follows configured fallbacks unless explicitly cleared with `fallbacks: []`. (#18210) Thanks @mahsumaktas.
50+
- Cron: route text-only announce output through the main session announce flow via runSubagentAnnounceFlow so cron text-only output remains visible to the initiating session. Thanks @tyler6204.
51+
- Cron: treat `timeoutSeconds: 0` as no-timeout (not clamped to 1), ensuring long-running cron runs are not prematurely terminated. Thanks @tyler6204.
52+
- Cron announce injection now targets the session determined by delivery config (`to` + channel) instead of defaulting to the current session. Thanks @tyler6204.
5053
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
5154
- Cron/Webhooks: reuse existing session IDs for webhook/cron runs when the session key is stable and still fresh, preserving conversation history. (#18031) Thanks @Operative-001.
5255
- Cron: prevent spin loops when cron jobs complete within the scheduled second by advancing the next run and enforcing a minimum refire gap. (#18073) Thanks @widingmarcus-cyber.
@@ -92,7 +95,7 @@ Docs: https://docs.openclaw.ai
9295
- CLI/Message: preserve `--components` JSON payloads in `openclaw message send` so Discord component payloads are no longer dropped. (#18222) Thanks @saurabhchopade.
9396
- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437)
9497
- Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior.
95-
- Subagents: route nested announce results back to the parent session after the parent run ends, falling back only when the parent session is deleted. (#18043)
98+
- Subagents: route nested announce results back to the parent session after the parent run ends, falling back only when the parent session is deleted. (#18043) Thanks @tyler6204.
9699
- Subagents: cap announce retry loops with max attempts and expiry to prevent infinite retry spam after deferred announces. (#18444)
97100
- Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836)
98101
- Agents/Tools/exec: treat normal non-zero exit codes as completed and append the exit code to tool output to avoid false tool-failure warnings. (#18425)

src/agents/tools/cron-tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ PAYLOAD TYPES (payload.kind):
237237
- "systemEvent": Injects text as system event into session
238238
{ "kind": "systemEvent", "text": "<message>" }
239239
- "agentTurn": Runs agent with message (isolated sessions only)
240-
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
240+
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional, 0 means no timeout> }
241241
242242
DELIVERY (top-level):
243243
{ "mode": "none|announce|webhook", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }

src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async function expectBestEffortTelegramNotDelivered(
9393
});
9494
}
9595

96-
async function expectExplicitTelegramTargetDelivery(params: {
96+
async function expectExplicitTelegramTargetAnnounce(params: {
9797
payloads: Array<Record<string, unknown>>;
9898
expectedText: string;
9999
}): Promise<void> {
@@ -110,11 +110,17 @@ async function expectExplicitTelegramTargetDelivery(params: {
110110

111111
expect(res.status).toBe("ok");
112112
expect(res.delivered).toBe(true);
113-
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
114-
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
115-
const [to, text] = vi.mocked(deps.sendMessageTelegram).mock.calls[0] ?? [];
116-
expect(to).toBe("123");
117-
expect(text).toBe(params.expectedText);
113+
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
114+
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
115+
| {
116+
requesterOrigin?: { channel?: string; to?: string };
117+
roundOneReply?: string;
118+
}
119+
| undefined;
120+
expect(announceArgs?.requesterOrigin?.channel).toBe("telegram");
121+
expect(announceArgs?.requesterOrigin?.to).toBe("123");
122+
expect(announceArgs?.roundOneReply).toBe(params.expectedText);
123+
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
118124
});
119125
}
120126

@@ -123,20 +129,61 @@ describe("runCronIsolatedAgentTurn", () => {
123129
setupIsolatedAgentTurnMocks();
124130
});
125131

126-
it("delivers directly when delivery has an explicit target", async () => {
127-
await expectExplicitTelegramTargetDelivery({
132+
it("routes text-only explicit target delivery through announce flow", async () => {
133+
await expectExplicitTelegramTargetAnnounce({
128134
payloads: [{ text: "hello from cron" }],
129135
expectedText: "hello from cron",
130136
});
131137
});
132138

133-
it("delivers the final payload text when delivery has an explicit target", async () => {
134-
await expectExplicitTelegramTargetDelivery({
139+
it("announces the final payload text when delivery has an explicit target", async () => {
140+
await expectExplicitTelegramTargetAnnounce({
135141
payloads: [{ text: "Working on it..." }, { text: "Final weather summary" }],
136142
expectedText: "Final weather summary",
137143
});
138144
});
139145

146+
it("routes announce injection to the delivery-target session key", async () => {
147+
await withTempCronHome(async (home) => {
148+
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
149+
const deps = createCliDeps();
150+
mockAgentPayloads([{ text: "hello from cron" }]);
151+
152+
const res = await runCronIsolatedAgentTurn({
153+
cfg: makeCfg(home, storePath, {
154+
session: {
155+
store: storePath,
156+
mainKey: "main",
157+
dmScope: "per-channel-peer",
158+
},
159+
channels: {
160+
telegram: { botToken: "t-1" },
161+
},
162+
}),
163+
deps,
164+
job: {
165+
...makeJob({ kind: "agentTurn", message: "do it" }),
166+
delivery: { mode: "announce", channel: "telegram", to: "123" },
167+
},
168+
message: "do it",
169+
sessionKey: "cron:job-1",
170+
lane: "cron",
171+
});
172+
173+
expect(res.status).toBe("ok");
174+
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
175+
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
176+
| {
177+
requesterSessionKey?: string;
178+
requesterOrigin?: { channel?: string; to?: string };
179+
}
180+
| undefined;
181+
expect(announceArgs?.requesterSessionKey).toBe("agent:main:telegram:direct:123");
182+
expect(announceArgs?.requesterOrigin?.channel).toBe("telegram");
183+
expect(announceArgs?.requesterOrigin?.to).toBe("123");
184+
});
185+
});
186+
140187
it("passes resolved threadId into shared subagent announce flow", async () => {
141188
await withTempCronHome(async (home) => {
142189
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
@@ -225,13 +272,13 @@ describe("runCronIsolatedAgentTurn", () => {
225272
});
226273
});
227274

228-
it("fails when direct delivery fails and best-effort is disabled", async () => {
275+
it("fails when structured direct delivery fails and best-effort is disabled", async () => {
229276
await withTempCronHome(async (home) => {
230277
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
231278
const deps = createCliDeps({
232279
sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")),
233280
});
234-
mockAgentPayloads([{ text: "hello from cron" }]);
281+
mockAgentPayloads([{ text: "hello from cron", mediaUrl: "https://example.com/img.png" }]);
235282
const res = await runTelegramAnnounceTurn({
236283
home,
237284
storePath,
@@ -246,7 +293,10 @@ describe("runCronIsolatedAgentTurn", () => {
246293
});
247294
});
248295

249-
it("ignores direct delivery failures when best-effort is enabled", async () => {
250-
await expectBestEffortTelegramNotDelivered({ text: "hello from cron" });
296+
it("ignores structured direct delivery failures when best-effort is enabled", async () => {
297+
await expectBestEffortTelegramNotDelivered({
298+
text: "hello from cron",
299+
mediaUrl: "https://example.com/img.png",
300+
});
251301
});
252302
});

src/cron/isolated-agent/run.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type { AgentDefaultsConfig } from "../../config/types.js";
4444
import { registerAgentRunContext } from "../../infra/agent-events.js";
4545
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
4646
import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js";
47+
import { resolveOutboundSessionRoute } from "../../infra/outbound/outbound-session.js";
4748
import { logWarn } from "../../logger.js";
4849
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
4950
import {
@@ -100,6 +101,40 @@ function resolveCronDeliveryBestEffort(job: CronJob): boolean {
100101
return false;
101102
}
102103

104+
async function resolveCronAnnounceSessionKey(params: {
105+
cfg: OpenClawConfig;
106+
agentId: string;
107+
fallbackSessionKey: string;
108+
delivery: {
109+
channel: Parameters<typeof resolveOutboundSessionRoute>[0]["channel"];
110+
to?: string;
111+
accountId?: string;
112+
threadId?: string | number;
113+
};
114+
}): Promise<string> {
115+
const to = params.delivery.to?.trim();
116+
if (!to) {
117+
return params.fallbackSessionKey;
118+
}
119+
try {
120+
const route = await resolveOutboundSessionRoute({
121+
cfg: params.cfg,
122+
channel: params.delivery.channel,
123+
agentId: params.agentId,
124+
accountId: params.delivery.accountId,
125+
target: to,
126+
threadId: params.delivery.threadId,
127+
});
128+
const resolved = route?.sessionKey?.trim();
129+
if (resolved) {
130+
return resolved;
131+
}
132+
} catch {
133+
// Fall back to main session routing if announce session resolution fails.
134+
}
135+
return params.fallbackSessionKey;
136+
}
137+
103138
export type RunCronAgentTurnResult = {
104139
/** Last non-empty agent text output (not truncated). */
105140
outputText?: string;
@@ -584,15 +619,14 @@ export async function runCronIsolatedAgentTurn(params: {
584619
}
585620
const identity = resolveAgentOutboundIdentity(cfgWithAgentDefaults, agentId);
586621

587-
// Shared subagent announce flow is text-based and prompts the main agent to
588-
// summarize. When we have an explicit delivery target (delivery.to), sender
589-
// identity, or structured content, prefer direct outbound delivery to send
590-
// the actual cron output without summarization.
591-
const hasExplicitDeliveryTarget = Boolean(deliveryPlan.to);
592-
if (deliveryPayloadHasStructuredContent || identity || hasExplicitDeliveryTarget) {
622+
// Route text-only cron announce output back through the main session so it
623+
// follows the same system-message injection path as subagent completions.
624+
// Keep direct outbound delivery only for structured payloads (media/channel
625+
// data), which cannot be represented by the shared announce flow.
626+
if (deliveryPayloadHasStructuredContent) {
593627
try {
594628
const payloadsForDelivery =
595-
deliveryPayloadHasStructuredContent && deliveryPayloads.length > 0
629+
deliveryPayloads.length > 0
596630
? deliveryPayloads
597631
: synthesizedText
598632
? [{ text: synthesizedText }]
@@ -624,10 +658,21 @@ export async function runCronIsolatedAgentTurn(params: {
624658
}
625659
}
626660
} else if (synthesizedText) {
627-
const announceSessionKey = resolveAgentMainSessionKey({
661+
const announceMainSessionKey = resolveAgentMainSessionKey({
628662
cfg: params.cfg,
629663
agentId,
630664
});
665+
const announceSessionKey = await resolveCronAnnounceSessionKey({
666+
cfg: cfgWithAgentDefaults,
667+
agentId,
668+
fallbackSessionKey: announceMainSessionKey,
669+
delivery: {
670+
channel: resolvedDelivery.channel,
671+
to: resolvedDelivery.to,
672+
accountId: resolvedDelivery.accountId,
673+
threadId: resolvedDelivery.threadId,
674+
},
675+
});
631676
const taskLabel =
632677
typeof params.job.name === "string" && params.job.name.trim()
633678
? params.job.name.trim()

src/cron/normalize.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,18 @@ describe("normalizeCronJobCreate", () => {
300300
expect(payload.allowUnsafeExternalContent).toBe(true);
301301
});
302302

303+
it("preserves timeoutSeconds=0 for no-timeout agentTurn payloads", () => {
304+
const normalized = normalizeCronJobCreate({
305+
name: "legacy no-timeout",
306+
schedule: { kind: "every", everyMs: 60_000 },
307+
payload: { kind: "agentTurn", message: "hello" },
308+
timeoutSeconds: 0,
309+
}) as unknown as Record<string, unknown>;
310+
311+
const payload = normalized.payload as Record<string, unknown>;
312+
expect(payload.timeoutSeconds).toBe(0);
313+
});
314+
303315
it("coerces sessionTarget and wakeMode casing", () => {
304316
const normalized = normalizeCronJobCreate({
305317
name: "casing",

src/cron/normalize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ function coercePayload(payload: UnknownRecord) {
131131
}
132132
if ("timeoutSeconds" in next) {
133133
if (typeof next.timeoutSeconds === "number" && Number.isFinite(next.timeoutSeconds)) {
134-
next.timeoutSeconds = Math.max(1, Math.floor(next.timeoutSeconds));
134+
next.timeoutSeconds = Math.max(0, Math.floor(next.timeoutSeconds));
135135
} else {
136136
delete next.timeoutSeconds;
137137
}

src/cron/service.issue-regressions.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,62 @@ describe("Cron issue regressions", () => {
642642
expect(job!.state.nextRunAtMs).toBeGreaterThanOrEqual(minNext);
643643
});
644644

645+
it("treats timeoutSeconds=0 as no timeout for isolated agentTurn jobs", async () => {
646+
const store = await makeStorePath();
647+
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
648+
649+
const cronJob: CronJob = {
650+
id: "no-timeout-0",
651+
name: "no-timeout",
652+
enabled: true,
653+
createdAtMs: scheduledAt - 86_400_000,
654+
updatedAtMs: scheduledAt - 86_400_000,
655+
schedule: { kind: "at", at: new Date(scheduledAt).toISOString() },
656+
sessionTarget: "isolated",
657+
wakeMode: "next-heartbeat",
658+
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 0 },
659+
delivery: { mode: "announce" },
660+
state: { nextRunAtMs: scheduledAt },
661+
};
662+
await fs.writeFile(
663+
store.storePath,
664+
JSON.stringify({ version: 1, jobs: [cronJob] }, null, 2),
665+
"utf-8",
666+
);
667+
668+
let now = scheduledAt;
669+
const deferredRun = createDeferred<{ status: "ok"; summary: string }>();
670+
const state = createCronServiceState({
671+
cronEnabled: true,
672+
storePath: store.storePath,
673+
log: noopLogger,
674+
nowMs: () => now,
675+
enqueueSystemEvent: vi.fn(),
676+
requestHeartbeatNow: vi.fn(),
677+
runIsolatedAgentJob: vi.fn(async () => {
678+
const result = await deferredRun.promise;
679+
now += 5;
680+
return result;
681+
}),
682+
});
683+
684+
const timerPromise = onTimer(state);
685+
let settled = false;
686+
void timerPromise.finally(() => {
687+
settled = true;
688+
});
689+
690+
await vi.advanceTimersByTimeAsync(0);
691+
await Promise.resolve();
692+
expect(settled).toBe(false);
693+
694+
deferredRun.resolve({ status: "ok", summary: "done" });
695+
await timerPromise;
696+
697+
const job = state.store?.jobs.find((j) => j.id === "no-timeout-0");
698+
expect(job?.state.lastStatus).toBe("ok");
699+
});
700+
645701
it("retries cron schedule computation from the next second when the first attempt returns undefined (#17821)", () => {
646702
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
647703
const cronJob: CronJob = {

0 commit comments

Comments
 (0)