Skip to content

Commit 8fae55e

Browse files
authored
fix(cron): share isolated announce flow + harden cron scheduling/delivery (#11641)
* fix(cron): comprehensive cron scheduling and delivery fixes - Fix delivery target resolution for isolated agent cron jobs - Improve schedule parsing and validation - Add job retry logic and error handling - Enhance cron ops with better state management - Add timer improvements for more reliable cron execution - Add cron event type to protocol schema - Support cron events in heartbeat runner (skip empty-heartbeat check, use dedicated CRON_EVENT_PROMPT for relay) * fix: remove cron debug test and add changelog/docs notes (#11641) (thanks @tyler6204)
1 parent ebe5730 commit 8fae55e

19 files changed

Lines changed: 488 additions & 150 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,4 @@ USER.md
7575
memory/
7676
.agent/*.json
7777
!.agent/workflows/
78+
local/

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010

1111
### Fixes
1212

13+
- Cron: route text-only isolated agent announces through the shared subagent announce flow; add exponential backoff for repeated errors; preserve future `nextRunAtMs` on restart; include current-boundary schedule matches; prevent stale threadId reuse across targets; and add per-job execution timeout. (#11641) Thanks @tyler6204.
1314
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
1415
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
1516
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.

docs/automation/cron-jobs.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,13 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
464464
- Check the Gateway is running continuously (cron runs inside the Gateway process).
465465
- For `cron` schedules: confirm timezone (`--tz`) vs the host timezone.
466466

467+
### A recurring job keeps delaying after failures
468+
469+
- OpenClaw applies exponential retry backoff for recurring jobs after consecutive errors:
470+
30s, 1m, 5m, 15m, then 60m between retries.
471+
- Backoff resets automatically after the next successful run.
472+
- One-shot (`at`) jobs disable after a terminal run (`ok`, `error`, or `skipped`) and do not retry.
473+
467474
### Telegram delivers to the wrong place
468475

469476
- For forum topics, use `-100…:topic:<id>` so it’s explicit and unambiguous.

docs/cli/cron.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ output internal. `--deliver` remains as a deprecated alias for `--announce`.
2121

2222
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
2323

24+
Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run.
25+
2426
## Common edits
2527

2628
Update delivery settings without changing the message:

src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe("openclaw-tools: subagents", () => {
245245
| undefined;
246246
expect(second?.sessionKey).toBe("discord:group:req");
247247
expect(second?.deliver).toBe(true);
248-
expect(second?.message).toContain("background task");
248+
expect(second?.message).toContain("subagent task");
249249

250250
const sendCalls = calls.filter((c) => c.method === "send");
251251
expect(sendCalls.length).toBe(0);

src/agents/openclaw-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe("openclaw-tools: subagents", () => {
153153
// Second call: main agent trigger (not "Sub-agent announce step." anymore)
154154
const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined;
155155
expect(second?.sessionKey).toBe("main");
156-
expect(second?.message).toContain("background task");
156+
expect(second?.message).toContain("subagent task");
157157

158158
// No direct send to external channel (main agent handles delivery)
159159
const sendCalls = calls.filter((c) => c.method === "send");

src/agents/subagent-announce.format.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe("subagent announce formatting", () => {
9494
};
9595
const msg = call?.params?.message as string;
9696
expect(call?.params?.sessionKey).toBe("agent:main:main");
97-
expect(msg).toContain("background task");
97+
expect(msg).toContain("subagent task");
9898
expect(msg).toContain("failed");
9999
expect(msg).toContain("boom");
100100
expect(msg).toContain("Findings:");
@@ -155,7 +155,7 @@ describe("subagent announce formatting", () => {
155155
expect(didAnnounce).toBe(true);
156156
expect(embeddedRunMock.queueEmbeddedPiMessage).toHaveBeenCalledWith(
157157
"session-123",
158-
expect.stringContaining("background task"),
158+
expect.stringContaining("subagent task"),
159159
);
160160
expect(agentSpy).not.toHaveBeenCalled();
161161
});

src/agents/subagent-announce.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ export type SubagentRunOutcome = {
345345
error?: string;
346346
};
347347

348+
export type SubagentAnnounceType = "subagent task" | "cron job";
349+
348350
export async function runSubagentAnnounceFlow(params: {
349351
childSessionKey: string;
350352
childRunId: string;
@@ -360,6 +362,7 @@ export async function runSubagentAnnounceFlow(params: {
360362
endedAt?: number;
361363
label?: string;
362364
outcome?: SubagentRunOutcome;
365+
announceType?: SubagentAnnounceType;
363366
}): Promise<boolean> {
364367
let didAnnounce = false;
365368
try {
@@ -433,17 +436,18 @@ export async function runSubagentAnnounceFlow(params: {
433436
: "finished with unknown status";
434437

435438
// Build instructional message for main agent
436-
const taskLabel = params.label || params.task || "background task";
439+
const announceType = params.announceType ?? "subagent task";
440+
const taskLabel = params.label || params.task || "task";
437441
const triggerMessage = [
438-
`A background task "${taskLabel}" just ${statusLabel}.`,
442+
`A ${announceType} "${taskLabel}" just ${statusLabel}.`,
439443
"",
440444
"Findings:",
441445
reply || "(no output)",
442446
"",
443447
statsLine,
444448
"",
445449
"Summarize this naturally for the user. Keep it brief (1-2 sentences). Flow it into the conversation naturally.",
446-
"Do not mention technical details like tokens, stats, or that this was a background task.",
450+
`Do not mention technical details like tokens, stats, or that this was a ${announceType}.`,
447451
"You can respond with NO_REPLY if no announcement is needed (e.g., internal task with no user-facing result).",
448452
].join("\n");
449453

src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ vi.mock("../agents/pi-embedded.js", () => ({
1717
vi.mock("../agents/model-catalog.js", () => ({
1818
loadModelCatalog: vi.fn(),
1919
}));
20+
vi.mock("../agents/subagent-announce.js", () => ({
21+
runSubagentAnnounceFlow: vi.fn(),
22+
}));
2023

2124
import { loadModelCatalog } from "../agents/model-catalog.js";
2225
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
26+
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
2327
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
2428

2529
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
@@ -86,6 +90,7 @@ describe("runCronIsolatedAgentTurn", () => {
8690
beforeEach(() => {
8791
vi.mocked(runEmbeddedPiAgent).mockReset();
8892
vi.mocked(loadModelCatalog).mockResolvedValue([]);
93+
vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true);
8994
setActivePluginRegistry(
9095
createTestRegistry([
9196
{
@@ -136,10 +141,11 @@ describe("runCronIsolatedAgentTurn", () => {
136141

137142
expect(res.status).toBe("ok");
138143
expect(deps.sendMessageTelegram).toHaveBeenCalled();
144+
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
139145
});
140146
});
141147

142-
it("delivers when heartbeat ack padding exceeds configured limit", async () => {
148+
it("uses shared announce flow when heartbeat ack padding exceeds configured limit", async () => {
143149
await withTempHome(async (home) => {
144150
const storePath = await writeSessionStore(home);
145151
const deps: CliDeps = {
@@ -185,7 +191,8 @@ describe("runCronIsolatedAgentTurn", () => {
185191
});
186192

187193
expect(res.status).toBe("ok");
188-
expect(deps.sendMessageTelegram).toHaveBeenCalled();
194+
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
195+
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
189196
});
190197
});
191198
});

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

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ vi.mock("../agents/pi-embedded.js", () => ({
1717
vi.mock("../agents/model-catalog.js", () => ({
1818
loadModelCatalog: vi.fn(),
1919
}));
20+
vi.mock("../agents/subagent-announce.js", () => ({
21+
runSubagentAnnounceFlow: vi.fn(),
22+
}));
2023

2124
import { loadModelCatalog } from "../agents/model-catalog.js";
2225
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
26+
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
2327
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
2428

2529
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
@@ -86,6 +90,7 @@ describe("runCronIsolatedAgentTurn", () => {
8690
beforeEach(() => {
8791
vi.mocked(runEmbeddedPiAgent).mockReset();
8892
vi.mocked(loadModelCatalog).mockResolvedValue([]);
93+
vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true);
8994
setActivePluginRegistry(
9095
createTestRegistry([
9196
{
@@ -97,7 +102,7 @@ describe("runCronIsolatedAgentTurn", () => {
97102
);
98103
});
99104

100-
it("announces when delivery is requested", async () => {
105+
it("announces via shared subagent flow when delivery is requested", async () => {
101106
await withTempHome(async (home) => {
102107
const storePath = await writeSessionStore(home);
103108
const deps: CliDeps = {
@@ -130,11 +135,16 @@ describe("runCronIsolatedAgentTurn", () => {
130135
});
131136

132137
expect(res.status).toBe("ok");
133-
expect(deps.sendMessageTelegram).toHaveBeenCalled();
138+
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
139+
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
140+
| { announceType?: string }
141+
| undefined;
142+
expect(announceArgs?.announceType).toBe("cron job");
143+
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
134144
});
135145
});
136146

137-
it("announces only the final payload text", async () => {
147+
it("passes final payload text into shared subagent announce flow", async () => {
138148
await withTempHome(async (home) => {
139149
const storePath = await writeSessionStore(home);
140150
const deps: CliDeps = {
@@ -167,12 +177,71 @@ describe("runCronIsolatedAgentTurn", () => {
167177
});
168178

169179
expect(res.status).toBe("ok");
170-
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
171-
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
172-
"123",
173-
"Final weather summary",
174-
expect.any(Object),
180+
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
181+
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
182+
| { roundOneReply?: string; requesterOrigin?: { threadId?: string | number } }
183+
| undefined;
184+
expect(announceArgs?.roundOneReply).toBe("Final weather summary");
185+
expect(announceArgs?.requesterOrigin?.threadId).toBeUndefined();
186+
});
187+
});
188+
189+
it("passes resolved threadId into shared subagent announce flow", async () => {
190+
await withTempHome(async (home) => {
191+
const storePath = await writeSessionStore(home);
192+
await fs.writeFile(
193+
storePath,
194+
JSON.stringify(
195+
{
196+
"agent:main:main": {
197+
sessionId: "main-session",
198+
updatedAt: Date.now(),
199+
lastChannel: "telegram",
200+
lastTo: "123",
201+
lastThreadId: 42,
202+
},
203+
},
204+
null,
205+
2,
206+
),
207+
"utf-8",
175208
);
209+
const deps: CliDeps = {
210+
sendMessageWhatsApp: vi.fn(),
211+
sendMessageTelegram: vi.fn(),
212+
sendMessageDiscord: vi.fn(),
213+
sendMessageSignal: vi.fn(),
214+
sendMessageIMessage: vi.fn(),
215+
};
216+
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
217+
payloads: [{ text: "Final weather summary" }],
218+
meta: {
219+
durationMs: 5,
220+
agentMeta: { sessionId: "s", provider: "p", model: "m" },
221+
},
222+
});
223+
224+
const res = await runCronIsolatedAgentTurn({
225+
cfg: makeCfg(home, storePath, {
226+
channels: { telegram: { botToken: "t-1" } },
227+
}),
228+
deps,
229+
job: {
230+
...makeJob({ kind: "agentTurn", message: "do it" }),
231+
delivery: { mode: "announce", channel: "last" },
232+
},
233+
message: "do it",
234+
sessionKey: "cron:job-1",
235+
lane: "cron",
236+
});
237+
238+
expect(res.status).toBe("ok");
239+
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
240+
| { requesterOrigin?: { threadId?: string | number; channel?: string; to?: string } }
241+
| undefined;
242+
expect(announceArgs?.requesterOrigin?.channel).toBe("telegram");
243+
expect(announceArgs?.requesterOrigin?.to).toBe("123");
244+
expect(announceArgs?.requesterOrigin?.threadId).toBe(42);
176245
});
177246
});
178247

@@ -211,6 +280,7 @@ describe("runCronIsolatedAgentTurn", () => {
211280
});
212281

213282
expect(res.status).toBe("ok");
283+
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
214284
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
215285
});
216286
});
@@ -248,11 +318,12 @@ describe("runCronIsolatedAgentTurn", () => {
248318
});
249319

250320
expect(res.status).toBe("ok");
321+
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
251322
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
252323
});
253324
});
254325

255-
it("fails when announce delivery fails and best-effort is disabled", async () => {
326+
it("fails when shared announce flow fails and best-effort is disabled", async () => {
256327
await withTempHome(async (home) => {
257328
const storePath = await writeSessionStore(home);
258329
const deps: CliDeps = {
@@ -269,6 +340,7 @@ describe("runCronIsolatedAgentTurn", () => {
269340
agentMeta: { sessionId: "s", provider: "p", model: "m" },
270341
},
271342
});
343+
vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(false);
272344
const res = await runCronIsolatedAgentTurn({
273345
cfg: makeCfg(home, storePath, {
274346
channels: { telegram: { botToken: "t-1" } },
@@ -284,11 +356,11 @@ describe("runCronIsolatedAgentTurn", () => {
284356
});
285357

286358
expect(res.status).toBe("error");
287-
expect(res.error).toBe("Error: boom");
359+
expect(res.error).toBe("cron announce delivery failed");
288360
});
289361
});
290362

291-
it("ignores announce delivery failures when best-effort is enabled", async () => {
363+
it("ignores shared announce flow failures when best-effort is enabled", async () => {
292364
await withTempHome(async (home) => {
293365
const storePath = await writeSessionStore(home);
294366
const deps: CliDeps = {
@@ -305,6 +377,7 @@ describe("runCronIsolatedAgentTurn", () => {
305377
agentMeta: { sessionId: "s", provider: "p", model: "m" },
306378
},
307379
});
380+
vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(false);
308381
const res = await runCronIsolatedAgentTurn({
309382
cfg: makeCfg(home, storePath, {
310383
channels: { telegram: { botToken: "t-1" } },
@@ -325,7 +398,8 @@ describe("runCronIsolatedAgentTurn", () => {
325398
});
326399

327400
expect(res.status).toBe("ok");
328-
expect(deps.sendMessageTelegram).toHaveBeenCalled();
401+
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
402+
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
329403
});
330404
});
331405
});

0 commit comments

Comments
 (0)