Skip to content

Commit 30deebe

Browse files
committed
fix(cron): isolate main-session cron wake lanes
1 parent 93bc994 commit 30deebe

6 files changed

Lines changed: 163 additions & 23 deletions

File tree

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.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/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";
@@ -33,6 +34,62 @@ afterEach(() => {
3334
});
3435

3536
describe("cron service timer seam coverage", () => {
37+
it("routes main cron jobs onto a cron run lane derived from the target agent", async () => {
38+
const { storePath } = await makeStorePath();
39+
const now = Date.parse("2026-03-23T12:00:00.000Z");
40+
const enqueueSystemEvent = vi.fn();
41+
const requestHeartbeat = vi.fn();
42+
const runHeartbeatOnce = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 }));
43+
const job = {
44+
...createDueMainJob({ now, wakeMode: "now" }),
45+
sessionKey: "agent:main-pr-router:main",
46+
state: { runningAtMs: now },
47+
};
48+
const cronRunSessionKey = `agent:main-pr-router:cron:main-heartbeat-job:run:${now}`;
49+
const sessionStorePath = path.join(path.dirname(path.dirname(storePath)), "sessions.json");
50+
await fs.writeFile(
51+
sessionStorePath,
52+
JSON.stringify({
53+
"agent:main-pr-router:main": {
54+
lastChannel: "discord",
55+
lastTo: "channel-1",
56+
lastAccountId: "default",
57+
},
58+
}),
59+
"utf8",
60+
);
61+
62+
const state = createCronServiceState({
63+
storePath,
64+
cronEnabled: true,
65+
log: logger,
66+
nowMs: () => now,
67+
resolveSessionStorePath: () => sessionStorePath,
68+
enqueueSystemEvent,
69+
requestHeartbeat,
70+
runHeartbeatOnce,
71+
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
72+
});
73+
74+
const result = await executeJobCore(state, job);
75+
76+
expect(result).toMatchObject({ status: "ok", sessionKey: cronRunSessionKey });
77+
expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", {
78+
agentId: undefined,
79+
sessionKey: cronRunSessionKey,
80+
contextKey: "cron:main-heartbeat-job",
81+
deliveryContext: { channel: "discord", to: "channel-1", accountId: "default" },
82+
});
83+
expect(runHeartbeatOnce).toHaveBeenCalledWith({
84+
source: "cron",
85+
intent: "immediate",
86+
reason: "cron:main-heartbeat-job",
87+
agentId: undefined,
88+
sessionKey: cronRunSessionKey,
89+
heartbeat: { target: "last" },
90+
});
91+
});
92+
3693
it("persists the next schedule and hands off next-heartbeat main jobs", async () => {
3794
const { storePath } = await makeStorePath();
3895
const now = Date.parse("2026-03-23T12:00:00.000Z");
@@ -57,17 +114,18 @@ describe("cron service timer seam coverage", () => {
57114

58115
await onTimer(state);
59116

117+
const cronRunSessionKey = `agent:main:cron:main-heartbeat-job:run:${now}`;
60118
expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", {
61119
agentId: undefined,
62-
sessionKey: "agent:main:main",
120+
sessionKey: cronRunSessionKey,
63121
contextKey: "cron:main-heartbeat-job",
64122
});
65123
expect(requestHeartbeat).toHaveBeenCalledWith({
66124
source: "cron",
67125
intent: "event",
68126
reason: "cron:main-heartbeat-job",
69127
agentId: undefined,
70-
sessionKey: "agent:main:main",
128+
sessionKey: cronRunSessionKey,
71129
heartbeat: { target: "last" },
72130
});
73131

@@ -87,7 +145,7 @@ describe("cron service timer seam coverage", () => {
87145
expect(task.sourceId).toBe("main-heartbeat-job");
88146
expect(task.ownerKey).toBe("");
89147
expect(task.scopeKind).toBe("system");
90-
expect(task.childSessionKey).toBe("agent:main:main");
148+
expect(task.childSessionKey).toBe(cronRunSessionKey);
91149
expect(task.runId).toBe(`cron:main-heartbeat-job:${now}`);
92150
expect(task.label).toBe("main heartbeat job");
93151
expect(task.task).toBe("main heartbeat job");
@@ -142,9 +200,10 @@ describe("cron service timer seam coverage", () => {
142200
{ jobId: "main-heartbeat-job", error: ledgerError },
143201
"cron: failed to create task ledger record",
144202
);
203+
const cronRunSessionKey = `agent:main:cron:main-heartbeat-job:run:${now}`;
145204
expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", {
146205
agentId: undefined,
147-
sessionKey: "agent:main:main",
206+
sessionKey: cronRunSessionKey,
148207
contextKey: "cron:main-heartbeat-job",
149208
});
150209

src/cron/service/timer.ts

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,21 @@ import {
66
HEARTBEAT_SKIP_CRON_IN_PROGRESS,
77
isRetryableHeartbeatBusySkipReason,
88
} from "../../infra/heartbeat-wake.js";
9-
import { DEFAULT_AGENT_ID, isSubagentSessionKey } from "../../routing/session-key.js";
9+
import { loadSessionStore } from "../../config/sessions/store-load.js";
10+
import {
11+
DEFAULT_AGENT_ID,
12+
isSubagentSessionKey,
13+
normalizeAgentId,
14+
resolveAgentIdFromSessionKey,
15+
} from "../../routing/session-key.js";
1016
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
1117
import {
1218
completeTaskRunByRunId,
1319
createRunningTaskRun,
1420
failTaskRunByRunId,
1521
} from "../../tasks/detached-task-runtime.js";
22+
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
23+
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
1624
import { clearCronJobActive, markCronJobActive } from "../active-jobs.js";
1725
import { resolveCronDeliveryPlan, resolveFailureDestination } from "../delivery-plan.js";
1826
import {
@@ -414,19 +422,64 @@ export function normalizeCronRunErrorText(err: unknown): string {
414422
return String(err);
415423
}
416424

425+
function normalizeCronLaneSegment(value: string | undefined, fallback: string): string {
426+
const normalized = normalizeOptionalLowercaseString(value)
427+
?.replace(/[^a-z0-9_-]+/g, "-")
428+
.replace(/^-+|-+$/g, "")
429+
.slice(0, 64);
430+
return normalized || fallback;
431+
}
432+
433+
function resolveMainSessionCronRunSessionKey(job: CronJob, startedAt: number): string {
434+
const explicitAgentId = job.agentId?.trim();
435+
const agentId = normalizeAgentId(
436+
explicitAgentId || resolveAgentIdFromSessionKey(job.sessionKey),
437+
);
438+
const jobSegment = normalizeCronLaneSegment(job.id, "job");
439+
const runSegment = normalizeCronLaneSegment(String(Math.max(0, Math.floor(startedAt))), "run");
440+
return `agent:${agentId}:cron:${jobSegment}:run:${runSegment}`;
441+
}
442+
443+
function resolveMainSessionCronDeliveryContext(
444+
state: CronServiceState,
445+
job: CronJob,
446+
): DeliveryContext | undefined {
447+
const targetSessionKey = job.sessionKey?.trim();
448+
if (!targetSessionKey) {
449+
return undefined;
450+
}
451+
const explicitAgentId = job.agentId?.trim();
452+
const agentId = normalizeAgentId(
453+
explicitAgentId || resolveAgentIdFromSessionKey(targetSessionKey),
454+
);
455+
const storePath = state.deps.resolveSessionStorePath?.(agentId) ?? state.deps.sessionStorePath;
456+
if (!storePath) {
457+
return undefined;
458+
}
459+
try {
460+
return deliveryContextFromSession(loadSessionStore(storePath)[targetSessionKey]);
461+
} catch {
462+
return undefined;
463+
}
464+
}
465+
417466
function tryCreateCronTaskRun(params: {
418467
state: CronServiceState;
419468
job: CronJob;
420469
startedAt: number;
421470
}): string | undefined {
422471
const runId = createCronExecutionId(params.job.id, params.startedAt);
472+
const childSessionKey =
473+
params.job.sessionTarget === "main"
474+
? resolveMainSessionCronRunSessionKey(params.job, params.startedAt)
475+
: params.job.sessionKey;
423476
try {
424477
createRunningTaskRun({
425478
runtime: "cron",
426479
sourceId: params.job.id,
427480
ownerKey: "",
428481
scopeKind: "system",
429-
childSessionKey: params.job.sessionKey,
482+
childSessionKey,
430483
agentId: params.job.agentId,
431484
runId,
432485
label: params.job.name,
@@ -1683,11 +1736,15 @@ async function executeMainSessionCronJob(
16831736
: 'main job requires payload.kind="systemEvent"',
16841737
};
16851738
}
1686-
const targetMainSessionKey = job.sessionKey;
1739+
const cronStartedAt =
1740+
typeof job.state.runningAtMs === "number" ? job.state.runningAtMs : state.deps.nowMs();
1741+
const cronRunSessionKey = resolveMainSessionCronRunSessionKey(job, cronStartedAt);
1742+
const deliveryContext = resolveMainSessionCronDeliveryContext(state, job);
16871743
state.deps.enqueueSystemEvent(text, {
16881744
agentId: job.agentId,
1689-
sessionKey: targetMainSessionKey,
1745+
sessionKey: cronRunSessionKey,
16901746
contextKey: `cron:${job.id}`,
1747+
...(deliveryContext ? { deliveryContext } : {}),
16911748
});
16921749
if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) {
16931750
const reason = `cron:${job.id}`;
@@ -1705,7 +1762,7 @@ async function executeMainSessionCronJob(
17051762
intent: "immediate",
17061763
reason,
17071764
agentId: job.agentId,
1708-
sessionKey: targetMainSessionKey,
1765+
sessionKey: cronRunSessionKey,
17091766
heartbeat: { target: "last" },
17101767
});
17111768
if (
@@ -1721,10 +1778,10 @@ async function executeMainSessionCronJob(
17211778
intent: "immediate",
17221779
reason,
17231780
agentId: job.agentId,
1724-
sessionKey: targetMainSessionKey,
1781+
sessionKey: cronRunSessionKey,
17251782
heartbeat: { target: "last" },
17261783
});
1727-
return { status: "ok", summary: text };
1784+
return { status: "ok", summary: text, sessionKey: cronRunSessionKey };
17281785
}
17291786
if (abortSignal?.aborted) {
17301787
return { status: "error", error: timeoutErrorMessage() };
@@ -1738,21 +1795,31 @@ async function executeMainSessionCronJob(
17381795
intent: "immediate",
17391796
reason,
17401797
agentId: job.agentId,
1741-
sessionKey: targetMainSessionKey,
1798+
sessionKey: cronRunSessionKey,
17421799
heartbeat: { target: "last" },
17431800
});
1744-
return { status: "ok", summary: text };
1801+
return { status: "ok", summary: text, sessionKey: cronRunSessionKey };
17451802
}
17461803
await waitWithAbort(retryDelayMs);
17471804
}
17481805

17491806
if (heartbeatResult.status === "ran") {
1750-
return { status: "ok", summary: text };
1807+
return { status: "ok", summary: text, sessionKey: cronRunSessionKey };
17511808
}
17521809
if (heartbeatResult.status === "skipped") {
1753-
return { status: "skipped", error: heartbeatResult.reason, summary: text };
1810+
return {
1811+
status: "skipped",
1812+
error: heartbeatResult.reason,
1813+
summary: text,
1814+
sessionKey: cronRunSessionKey,
1815+
};
17541816
}
1755-
return { status: "error", error: heartbeatResult.reason, summary: text };
1817+
return {
1818+
status: "error",
1819+
error: heartbeatResult.reason,
1820+
summary: text,
1821+
sessionKey: cronRunSessionKey,
1822+
};
17561823
}
17571824

17581825
if (abortSignal?.aborted) {
@@ -1763,10 +1830,10 @@ async function executeMainSessionCronJob(
17631830
intent: job.wakeMode === "now" ? "immediate" : "event",
17641831
reason: `cron:${job.id}`,
17651832
agentId: job.agentId,
1766-
sessionKey: targetMainSessionKey,
1833+
sessionKey: cronRunSessionKey,
17671834
heartbeat: { target: "last" },
17681835
});
1769-
return { status: "ok", summary: text };
1836+
return { status: "ok", summary: text, sessionKey: cronRunSessionKey };
17701837
}
17711838

17721839
async function executeDetachedCronJob(

src/gateway/server-cron.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ export function buildGatewayCronService(params: {
301301
enqueueSystemEvent(text, {
302302
sessionKey,
303303
contextKey: opts?.contextKey,
304+
deliveryContext: opts?.deliveryContext,
304305
forceSenderIsOwnerFalse: opts?.forceSenderIsOwnerFalse,
305306
trusted: opts?.forceSenderIsOwnerFalse !== true,
306307
});

0 commit comments

Comments
 (0)