Skip to content

Commit 821520a

Browse files
authored
fix cron scheduling and reminder delivery regressions (#9733)
* fix(cron): prevent timer from allowing process exit (fixes #9694) The cron timer was using .unref(), which caused the Node.js event loop to exit or sleep if no other handles were active. This prevented cron jobs from firing in some environments. * fix(cron): infer delivery target for isolated jobs (fixes #9683) When creating isolated agentTurn jobs (e.g. reminders) without explicit delivery options, the job would default to 'announce' but fail to resolve the target conversation. Now, we infer the channel and recipient from the agent's current session key. * fix(cron): enhance delivery inference for threaded sessions and null inputs (#9733) Improves the delivery inference logic in the cron tool to correctly handle threaded session keys and cases where delivery is explicitly set to null. This ensures that the appropriate delivery mode and target are inferred based on the agent's session key, enhancing the reliability of job execution. * fix: preserve telegram topic delivery inference (#9733) (thanks @tyler6204) * fix: simplify cron delivery merge spread (#9733) (thanks @tyler6204)
1 parent f32eeae commit 821520a

File tree

4 files changed

+191
-1
lines changed

4 files changed

+191
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
5151
- Cron: reload store data when the store file is recreated or mtime changes.
5252
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
53+
- Cron: correct announce delivery inference for thread session keys and null delivery inputs. (#9733) Thanks @tyler6204.
5354
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
5455
- Telegram: preserve DM topic threadId in deliveryContext. (#9039) Thanks @lailoo.
5556
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.

src/agents/tools/cron-tool.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,97 @@ describe("cron tool", () => {
233233
expect(call.method).toBe("cron.add");
234234
expect(call.params?.agentId).toBeNull();
235235
});
236+
237+
it("infers delivery from threaded session keys", async () => {
238+
callGatewayMock.mockResolvedValueOnce({ ok: true });
239+
240+
const tool = createCronTool({
241+
agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001",
242+
});
243+
await tool.execute("call-thread", {
244+
action: "add",
245+
job: {
246+
name: "reminder",
247+
schedule: { at: new Date(123).toISOString() },
248+
payload: { kind: "agentTurn", message: "hello" },
249+
},
250+
});
251+
252+
const call = callGatewayMock.mock.calls[0]?.[0] as {
253+
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
254+
};
255+
expect(call?.params?.delivery).toEqual({
256+
mode: "announce",
257+
channel: "slack",
258+
to: "general",
259+
});
260+
});
261+
262+
it("preserves telegram forum topics when inferring delivery", async () => {
263+
callGatewayMock.mockResolvedValueOnce({ ok: true });
264+
265+
const tool = createCronTool({
266+
agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
267+
});
268+
await tool.execute("call-telegram-topic", {
269+
action: "add",
270+
job: {
271+
name: "reminder",
272+
schedule: { at: new Date(123).toISOString() },
273+
payload: { kind: "agentTurn", message: "hello" },
274+
},
275+
});
276+
277+
const call = callGatewayMock.mock.calls[0]?.[0] as {
278+
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
279+
};
280+
expect(call?.params?.delivery).toEqual({
281+
mode: "announce",
282+
channel: "telegram",
283+
to: "-1001234567890:topic:99",
284+
});
285+
});
286+
287+
it("infers delivery when delivery is null", async () => {
288+
callGatewayMock.mockResolvedValueOnce({ ok: true });
289+
290+
const tool = createCronTool({ agentSessionKey: "agent:main:dm:alice" });
291+
await tool.execute("call-null-delivery", {
292+
action: "add",
293+
job: {
294+
name: "reminder",
295+
schedule: { at: new Date(123).toISOString() },
296+
payload: { kind: "agentTurn", message: "hello" },
297+
delivery: null,
298+
},
299+
});
300+
301+
const call = callGatewayMock.mock.calls[0]?.[0] as {
302+
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
303+
};
304+
expect(call?.params?.delivery).toEqual({
305+
mode: "announce",
306+
to: "alice",
307+
});
308+
});
309+
310+
it("does not infer delivery when mode is none", async () => {
311+
callGatewayMock.mockResolvedValueOnce({ ok: true });
312+
313+
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
314+
await tool.execute("call-none", {
315+
action: "add",
316+
job: {
317+
name: "reminder",
318+
schedule: { at: new Date(123).toISOString() },
319+
payload: { kind: "agentTurn", message: "hello" },
320+
delivery: { mode: "none" },
321+
},
322+
});
323+
324+
const call = callGatewayMock.mock.calls[0]?.[0] as {
325+
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
326+
};
327+
expect(call?.params?.delivery).toEqual({ mode: "none" });
328+
});
236329
});

src/agents/tools/cron-tool.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Type } from "@sinclair/typebox";
2+
import type { CronDelivery, CronMessageChannel } from "../../cron/types.js";
23
import { loadConfig } from "../../config/config.js";
34
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
5+
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
46
import { truncateUtf16Safe } from "../../utils.js";
57
import { resolveSessionAgentId } from "../agent-scope.js";
68
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
@@ -153,6 +155,72 @@ async function buildReminderContextLines(params: {
153155
}
154156
}
155157

158+
function isRecord(value: unknown): value is Record<string, unknown> {
159+
return typeof value === "object" && value !== null && !Array.isArray(value);
160+
}
161+
162+
function stripThreadSuffixFromSessionKey(sessionKey: string): string {
163+
const normalized = sessionKey.toLowerCase();
164+
const idx = normalized.lastIndexOf(":thread:");
165+
if (idx <= 0) {
166+
return sessionKey;
167+
}
168+
const parent = sessionKey.slice(0, idx).trim();
169+
return parent ? parent : sessionKey;
170+
}
171+
172+
function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | null {
173+
const rawSessionKey = agentSessionKey?.trim();
174+
if (!rawSessionKey) {
175+
return null;
176+
}
177+
const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));
178+
if (!parsed || !parsed.rest) {
179+
return null;
180+
}
181+
const parts = parsed.rest.split(":").filter(Boolean);
182+
if (parts.length === 0) {
183+
return null;
184+
}
185+
const head = parts[0]?.trim().toLowerCase();
186+
if (!head || head === "main" || head === "subagent" || head === "acp") {
187+
return null;
188+
}
189+
190+
// buildAgentPeerSessionKey encodes peers as:
191+
// - dm:<peerId>
192+
// - <channel>:dm:<peerId>
193+
// - <channel>:<accountId>:dm:<peerId>
194+
// - <channel>:group:<peerId>
195+
// - <channel>:channel:<peerId>
196+
// Threaded sessions append :thread:<id>, which we strip so delivery targets the parent peer.
197+
// NOTE: Telegram forum topics encode as <chatId>:topic:<topicId> and should be preserved.
198+
const markerIndex = parts.findIndex(
199+
(part) => part === "dm" || part === "group" || part === "channel",
200+
);
201+
if (markerIndex === -1) {
202+
return null;
203+
}
204+
const peerId = parts
205+
.slice(markerIndex + 1)
206+
.join(":")
207+
.trim();
208+
if (!peerId) {
209+
return null;
210+
}
211+
212+
let channel: CronMessageChannel | undefined;
213+
if (markerIndex >= 1) {
214+
channel = parts[0]?.trim().toLowerCase() as CronMessageChannel;
215+
}
216+
217+
const delivery: CronDelivery = { mode: "announce", to: peerId };
218+
if (channel) {
219+
delivery.channel = channel;
220+
}
221+
return delivery;
222+
}
223+
156224
export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
157225
return {
158226
label: "Cron",
@@ -243,6 +311,35 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
243311
(job as { agentId?: string }).agentId = agentId;
244312
}
245313
}
314+
315+
// [Fix Issue 3] Infer delivery target from session key for isolated jobs if not provided
316+
if (
317+
opts?.agentSessionKey &&
318+
job &&
319+
typeof job === "object" &&
320+
"payload" in job &&
321+
(job as { payload?: { kind?: string } }).payload?.kind === "agentTurn"
322+
) {
323+
const deliveryValue = (job as { delivery?: unknown }).delivery;
324+
const delivery = isRecord(deliveryValue) ? deliveryValue : undefined;
325+
const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : "";
326+
const mode = modeRaw.trim().toLowerCase();
327+
const hasTarget =
328+
(typeof delivery?.channel === "string" && delivery.channel.trim()) ||
329+
(typeof delivery?.to === "string" && delivery.to.trim());
330+
const shouldInfer =
331+
(deliveryValue == null || delivery) && mode !== "none" && !hasTarget;
332+
if (shouldInfer) {
333+
const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey);
334+
if (inferred) {
335+
(job as { delivery?: unknown }).delivery = {
336+
...delivery,
337+
...inferred,
338+
} satisfies CronDelivery;
339+
}
340+
}
341+
}
342+
246343
const contextMessages =
247344
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
248345
? params.contextMessages

src/cron/service/timer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ export function armTimer(state: CronServiceState) {
2727
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
2828
});
2929
}, clampedDelay);
30-
state.timer.unref?.();
3130
}
3231

3332
export async function onTimer(state: CronServiceState) {

0 commit comments

Comments
 (0)