Skip to content

Commit 9eee202

Browse files
galinilievGalin Iliev
andauthored
fix(cron): isolate main-session cron wake lanes (#82767)
* fix(cron): isolate main-session cron wake lanes * test(cron): expect dedicated main cron lanes * fix(cron): route global main cron wakes * docs(changelog): note cron main-session lane fix --------- Co-authored-by: Galin Iliev <Galin.Iliev@microsoft.com> Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>
1 parent a54c736 commit 9eee202

11 files changed

Lines changed: 312 additions & 47 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- Plugins/hooks: apply a default 30-second timeout to `before_compaction` and `after_compaction` hooks so a hung plugin handler no longer blocks compaction completion. (#84153)
2222
- Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.
2323
- Twitch: add a test-only client-manager registry reset helper so non-isolated Twitch tests can clear cached managers between cases. Fixes #83887. (#84244) Thanks @hclsys.
24+
- Cron: run main-session scheduled work on a cron-owned wake lane while preserving reply delivery context, so background cron turns no longer block human main-session chat. Fixes #82766. (#82767) Thanks @galiniliev.
2425
- Cron: use structured embedded-run denial metadata for isolated scheduled tasks so blocked exec requests fail the job without treating ordinary assistant prose as a denial. (#84067) Thanks @abnershang.
2526
- Cron: keep recovered tool warnings diagnostic for successful scheduled runs so final cron output is delivered instead of being replaced by a post-processing warning. (#84045) Thanks @abnershang.
2627
- Plugins/perf: thread explicit plugin discovery results through `loadBundledCapabilityRuntimeRegistry`, `resolveBundledPluginSources`, and `listChannelCatalogEntries` so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.

docs/automation/cron-jobs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,14 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
9090

9191
| Style | `--session` value | Runs in | Best for |
9292
| --------------- | ------------------- | ------------------------ | ------------------------------- |
93-
| Main session | `main` | Next heartbeat turn | Reminders, system events |
93+
| Main session | `main` | Dedicated cron wake lane | Reminders, system events |
9494
| Isolated | `isolated` | Dedicated `cron:<jobId>` | Reports, background chores |
9595
| Current session | `current` | Bound at creation time | Context-aware recurring work |
9696
| Custom session | `session:custom-id` | Persistent named session | Workflows that build on history |
9797

9898
<AccordionGroup>
9999
<Accordion title="Main session vs isolated vs custom">
100-
**Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). Those system events do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
100+
**Main session** jobs enqueue a system event into a cron-owned run lane and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). They can use the target main session's last delivery context for replies, but they do not append routine cron turns to the human chat lane and do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
101101
</Accordion>
102102
<Accordion title="What 'fresh session' means for isolated jobs">
103103
For isolated jobs, "fresh session" means a new transcript/session id for each run. OpenClaw may carry safe preferences such as thinking/fast/verbose settings, labels, and explicit user-selected model/auth overrides, but it does not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Use `current` or `session:<id>` when a recurring job should deliberately build on the same conversation context.

src/cron/service.every-jobs-fire.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const noopLogger = createNoopLogger();
1212
const { makeStorePath } = createCronStoreHarness();
1313
installCronTestHooks({ logger: noopLogger });
1414

15+
function expectCronRunSessionKey(value: unknown, jobId: string) {
16+
expect(value).toMatch(new RegExp(`^agent:main:cron:${jobId}:run:\\d+$`));
17+
}
18+
1519
describe("CronService interval/cron jobs fire on time", () => {
1620
const runLateTimerAndLoadJob = async ({
1721
cron,
@@ -34,14 +38,15 @@ describe("CronService interval/cron jobs fire on time", () => {
3438
const expectMainSystemEvent = (
3539
enqueueSystemEvent: ReturnType<typeof vi.fn>,
3640
expectedText: string,
41+
jobId: string,
3742
) => {
3843
const matchingCall = enqueueSystemEvent.mock.calls.find(([text]) => text === expectedText);
3944
if (!matchingCall) {
4045
throw new Error(`missing system event ${expectedText}`);
4146
}
4247
const options = matchingCall[1] as Record<string, unknown>;
4348
expect(options.agentId).toBeUndefined();
44-
expect(options.sessionKey).toBeUndefined();
49+
expectCronRunSessionKey(options.sessionKey, jobId);
4550
expect(typeof options.contextKey).toBe("string");
4651
expect(String(options.contextKey).startsWith("cron:")).toBe(true);
4752
};
@@ -85,7 +90,7 @@ describe("CronService interval/cron jobs fire on time", () => {
8590
jobId: job.id,
8691
firstDueAt,
8792
});
88-
expectMainSystemEvent(enqueueSystemEvent, "tick");
93+
expectMainSystemEvent(enqueueSystemEvent, "tick", job.id);
8994
expect(updated?.state.lastStatus).toBe("ok");
9095
// nextRunAtMs must advance by at least one full interval past the due time.
9196
expect(updated?.state.nextRunAtMs).toBeGreaterThanOrEqual(firstDueAt + 10_000);
@@ -122,7 +127,7 @@ describe("CronService interval/cron jobs fire on time", () => {
122127
jobId: job.id,
123128
firstDueAt,
124129
});
125-
expectMainSystemEvent(enqueueSystemEvent, "cron-tick");
130+
expectMainSystemEvent(enqueueSystemEvent, "cron-tick", job.id);
126131
expect(updated?.state.lastStatus).toBe("ok");
127132
// nextRunAtMs should be the next whole-minute boundary (60s later).
128133
expect(updated?.state.nextRunAtMs).toBe(firstDueAt + 60_000);

src/cron/service.main-job-passes-heartbeat-target-last.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe("cron main job passes heartbeat target=last", () => {
4343
runHeartbeatOnce: params.runHeartbeatOnce,
4444
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
4545
});
46-
return { cron, requestHeartbeat };
46+
return { cron, enqueueSystemEvent, requestHeartbeat };
4747
}
4848

4949
function requireRunHeartbeatOnceCall(
@@ -66,6 +66,7 @@ describe("cron main job passes heartbeat target=last", () => {
6666
source?: string;
6767
intent?: string;
6868
reason?: string;
69+
sessionKey?: string;
6970
heartbeat?: unknown;
7071
};
7172
}
@@ -109,6 +110,8 @@ describe("cron main job passes heartbeat target=last", () => {
109110
// heartbeat runner delivers the response to the last active channel.
110111
const callArgs = requireRunHeartbeatOnceCall(runHeartbeatOnce);
111112
expect(callArgs.heartbeat.target).toBe("last");
113+
expect(callArgs.sessionKey).toMatch(/^agent:main:cron:test-main-delivery:run:\d+$/);
114+
expect(callArgs.sessionKey).not.toBe("agent:main:main");
112115
});
113116

114117
it("should preserve heartbeat.target=last when wakeMode=now falls back to requestHeartbeat", async () => {
@@ -140,6 +143,10 @@ describe("cron main job passes heartbeat target=last", () => {
140143
expect(heartbeatRequest.source).toBe("cron");
141144
expect(heartbeatRequest.intent).toBe("immediate");
142145
expect(heartbeatRequest.reason).toBe("cron:test-main-delivery-busy");
146+
expect(heartbeatRequest.sessionKey).toMatch(
147+
/^agent:main:cron:test-main-delivery-busy:run:\d+$/,
148+
);
149+
expect(heartbeatRequest.sessionKey).not.toBe("agent:main:main");
143150
expect(heartbeatRequest.heartbeat).toEqual({ target: "last" });
144151
});
145152

@@ -160,7 +167,7 @@ describe("cron main job passes heartbeat target=last", () => {
160167
durationMs: 50,
161168
}));
162169

163-
const { cron, requestHeartbeat } = createCronWithSpies({
170+
const { cron, enqueueSystemEvent, requestHeartbeat } = createCronWithSpies({
164171
storePath,
165172
runHeartbeatOnce,
166173
});
@@ -172,7 +179,11 @@ describe("cron main job passes heartbeat target=last", () => {
172179
expect(heartbeatRequest.source).toBe("cron");
173180
expect(heartbeatRequest.intent).toBe("event");
174181
expect(heartbeatRequest.reason).toBe("cron:test-next-heartbeat");
182+
expect(heartbeatRequest.sessionKey).toMatch(/^agent:main:cron:test-next-heartbeat:run:\d+$/);
183+
expect(heartbeatRequest.sessionKey).not.toBe("agent:main:main");
175184
expect(heartbeatRequest.heartbeat).toEqual({ target: "last" });
176185
expect(runHeartbeatOnce).not.toHaveBeenCalled();
186+
const enqueueOptions = enqueueSystemEvent.mock.calls[0]?.[1] as { sessionKey?: string };
187+
expect(enqueueOptions.sessionKey).toBe(heartbeatRequest.sessionKey);
177188
});
178189
});

src/cron/service.runs-one-shot-main-job-disables-it.test.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const { makeStorePath } = createCronStoreHarness({
1919
prefix: "openclaw-cron-runs-one-shot-",
2020
});
2121

22+
function expectCronRunSessionKey(value: unknown, jobId: string) {
23+
expect(value).toMatch(new RegExp(`^agent:main:cron:${jobId}:run:\\d+$`));
24+
}
25+
2226
function createCronEventHarness() {
2327
const events: CronEvent[] = [];
2428
const waiters: Array<{
@@ -202,27 +206,33 @@ async function addMainOneShotHelloJob(
202206

203207
function expectMainSystemEventPosted(
204208
enqueueSystemEvent: ReturnType<typeof vi.fn>,
205-
params: { text: string; jobId: string; sessionKey?: string },
209+
params: { text: string; jobId: string },
206210
) {
207-
expect(enqueueSystemEvent).toHaveBeenCalledWith(params.text, {
211+
const matchingCall = enqueueSystemEvent.mock.calls.find(([text]) => text === params.text);
212+
if (!matchingCall) {
213+
throw new Error(`missing system event ${params.text}`);
214+
}
215+
const options = matchingCall[1] as Record<string, unknown>;
216+
expect(options).toMatchObject({
208217
agentId: undefined,
209-
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
210218
contextKey: `cron:${params.jobId}`,
211219
});
220+
expectCronRunSessionKey(options.sessionKey, params.jobId);
212221
}
213222

214223
function expectQueuedCronHeartbeat(
215224
requestHeartbeat: ReturnType<typeof vi.fn>,
216-
params: { jobId: string; sessionKey?: string },
225+
params: { jobId: string },
217226
) {
218-
expect(requestHeartbeat).toHaveBeenCalledWith({
227+
const request = requestHeartbeat.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
228+
expect(request).toMatchObject({
219229
source: "cron",
220230
intent: "immediate",
221231
reason: `cron:${params.jobId}`,
222232
agentId: undefined,
223-
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
224233
heartbeat: { target: "last" },
225234
});
235+
expectCronRunSessionKey(request?.sessionKey, params.jobId);
226236
}
227237

228238
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
@@ -403,7 +413,7 @@ describe("CronService", () => {
403413
await cron.run(job.id, "force");
404414

405415
expect(runHeartbeatOnce).toHaveBeenCalled();
406-
expectQueuedCronHeartbeat(requestHeartbeat, { jobId: job.id, sessionKey });
416+
expectQueuedCronHeartbeat(requestHeartbeat, { jobId: job.id });
407417
expect(job.state.lastStatus).toBe("ok");
408418
expect(job.state.lastError).toBeUndefined();
409419

@@ -430,7 +440,7 @@ describe("CronService", () => {
430440
await cron.run(job.id, "force");
431441

432442
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
433-
expectQueuedCronHeartbeat(requestHeartbeat, { jobId: job.id, sessionKey });
443+
expectQueuedCronHeartbeat(requestHeartbeat, { jobId: job.id });
434444
expect(job.state.lastStatus).toBe("ok");
435445
expect(job.state.lastError).toBeUndefined();
436446

src/cron/service/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CronConfig } from "../../config/types.cron.js";
22
import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heartbeat-wake.js";
3+
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
34
import type {
45
CronAgentExecutionPhaseUpdate,
56
CronAgentExecutionStarted,
@@ -82,6 +83,7 @@ export type CronServiceDeps = {
8283
agentId?: string;
8384
sessionKey?: string;
8485
contextKey?: string;
86+
deliveryContext?: DeliveryContext;
8587
forceSenderIsOwnerFalse?: boolean;
8688
},
8789
) => void;

src/cron/service/timer.test.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import fs from "node:fs/promises";
2+
import path from "node:path";
23
import { afterEach, describe, expect, it, vi } from "vitest";
34
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../../cron/service.test-harness.js";
45
import { createCronServiceState } from "../../cron/service/state.js";
5-
import { onTimer } from "../../cron/service/timer.js";
6+
import { executeJobCore, onTimer } from "../../cron/service/timer.js";
67
import { loadCronStore, saveCronStore } from "../../cron/store.js";
78
import type { CronJob } from "../../cron/types.js";
89
import * as detachedTaskRuntime from "../../tasks/detached-task-runtime.js";
@@ -49,6 +50,62 @@ afterEach(() => {
4950
});
5051

5152
describe("cron service timer seam coverage", () => {
53+
it("routes main cron jobs onto a cron run lane derived from the target agent", async () => {
54+
const { storePath } = await makeStorePath();
55+
const now = Date.parse("2026-03-23T12:00:00.000Z");
56+
const enqueueSystemEvent = vi.fn();
57+
const requestHeartbeat = vi.fn();
58+
const runHeartbeatOnce = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 }));
59+
const job = {
60+
...createDueMainJob({ now, wakeMode: "now" }),
61+
sessionKey: "agent:main-pr-router:main",
62+
state: { runningAtMs: now },
63+
};
64+
const cronRunSessionKey = `agent:main-pr-router:cron:main-heartbeat-job:run:${now}`;
65+
const sessionStorePath = path.join(path.dirname(path.dirname(storePath)), "sessions.json");
66+
await fs.writeFile(
67+
sessionStorePath,
68+
JSON.stringify({
69+
"agent:main-pr-router:main": {
70+
lastChannel: "discord",
71+
lastTo: "channel-1",
72+
lastAccountId: "default",
73+
},
74+
}),
75+
"utf8",
76+
);
77+
78+
const state = createCronServiceState({
79+
storePath,
80+
cronEnabled: true,
81+
log: logger,
82+
nowMs: () => now,
83+
resolveSessionStorePath: () => sessionStorePath,
84+
enqueueSystemEvent,
85+
requestHeartbeat,
86+
runHeartbeatOnce,
87+
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
88+
});
89+
90+
const result = await executeJobCore(state, job);
91+
92+
expect(result).toMatchObject({ status: "ok", sessionKey: cronRunSessionKey });
93+
expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", {
94+
agentId: undefined,
95+
sessionKey: cronRunSessionKey,
96+
contextKey: "cron:main-heartbeat-job",
97+
deliveryContext: { channel: "discord", to: "channel-1", accountId: "default" },
98+
});
99+
expect(runHeartbeatOnce).toHaveBeenCalledWith({
100+
source: "cron",
101+
intent: "immediate",
102+
reason: "cron:main-heartbeat-job",
103+
agentId: undefined,
104+
sessionKey: cronRunSessionKey,
105+
heartbeat: { target: "last" },
106+
});
107+
});
108+
52109
it("persists the next schedule and hands off next-heartbeat main jobs", async () => {
53110
const { storePath } = await makeStorePath();
54111
const now = Date.parse("2026-03-23T12:00:00.000Z");
@@ -73,17 +130,18 @@ describe("cron service timer seam coverage", () => {
73130

74131
await onTimer(state);
75132

133+
const cronRunSessionKey = `agent:main:cron:main-heartbeat-job:run:${now}`;
76134
expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", {
77135
agentId: undefined,
78-
sessionKey: "agent:main:main",
136+
sessionKey: cronRunSessionKey,
79137
contextKey: "cron:main-heartbeat-job",
80138
});
81139
expect(requestHeartbeat).toHaveBeenCalledWith({
82140
source: "cron",
83141
intent: "event",
84142
reason: "cron:main-heartbeat-job",
85143
agentId: undefined,
86-
sessionKey: "agent:main:main",
144+
sessionKey: cronRunSessionKey,
87145
heartbeat: { target: "last" },
88146
});
89147

@@ -103,7 +161,7 @@ describe("cron service timer seam coverage", () => {
103161
expect(task.sourceId).toBe("main-heartbeat-job");
104162
expect(task.ownerKey).toBe("");
105163
expect(task.scopeKind).toBe("system");
106-
expect(task.childSessionKey).toBe("agent:main:main");
164+
expect(task.childSessionKey).toBe(cronRunSessionKey);
107165
expect(task.runId).toBe(`cron:main-heartbeat-job:${now}`);
108166
expect(task.label).toBe("main heartbeat job");
109167
expect(task.task).toBe("main heartbeat job");
@@ -201,9 +259,10 @@ describe("cron service timer seam coverage", () => {
201259
{ jobId: "main-heartbeat-job", error: ledgerError },
202260
"cron: failed to create task ledger record",
203261
);
262+
const cronRunSessionKey = `agent:main:cron:main-heartbeat-job:run:${now}`;
204263
expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", {
205264
agentId: undefined,
206-
sessionKey: "agent:main:main",
265+
sessionKey: cronRunSessionKey,
207266
contextKey: "cron:main-heartbeat-job",
208267
});
209268

0 commit comments

Comments
 (0)