Skip to content

Commit be5906e

Browse files
authored
fix(cron): restore persistent session targets (#98947)
Cron jobs run in the session they were created from unless the job explicitly requests an isolated fresh session. Co-authored-by: Peter Steinberger <[email protected]>
1 parent 2913d3a commit be5906e

17 files changed

Lines changed: 90 additions & 187 deletions

docs/automation/cron-jobs.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,16 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
8787

8888
## Execution styles
8989

90-
| Style | `--session` value | Runs in | Best for |
91-
| --------------- | ------------------- | ------------------------ | ------------------------------ |
92-
| Main session | `main` | Dedicated cron wake lane | Reminders, system events |
93-
| Isolated | `isolated` | Dedicated `cron:<jobId>` | Reports, background chores |
94-
| Current session | `current` | Detached cron run | Context-aware recurring work |
95-
| Custom session | `session:custom-id` | Detached cron run | Targeting a known chat/session |
90+
| Style | `--session` value | Runs in | Best for |
91+
| --------------- | ------------------- | ------------------------ | ------------------------------- |
92+
| Main session | `main` | Dedicated cron wake lane | Reminders, system events |
93+
| Isolated | `isolated` | Dedicated `cron:<jobId>` | Reports, background chores |
94+
| Current session | `current` | Bound at creation time | Context-aware recurring work |
95+
| Custom session | `session:custom-id` | Persistent named session | Workflows that build on history |
9696

9797
<AccordionGroup>
9898
<Accordion title="Main session vs isolated vs custom">
99-
**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. **Current** and **custom** session jobs (`current`, `session:xxx`) can use the selected chat/session for delivery context and safe preference seeding, but each run still executes in a detached cron session so scheduled work does not block or pollute the live conversation transcript.
99+
**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.
100100

101101
Main-session cron events are self-contained system-event reminders. They do
102102
not automatically include the default heartbeat prompt's "Read
@@ -105,8 +105,8 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
105105
agent's own instructions.
106106

107107
</Accordion>
108-
<Accordion title="What 'fresh session' means for detached jobs">
109-
For isolated, current-session, and custom-session 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. Detached runs do not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Put durable recurring-work state in the prompt, workspace files, tools, or the system the job operates on rather than relying on a live chat transcript as cron memory.
108+
<Accordion title="What 'fresh session' means for isolated jobs">
109+
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.
110110
</Accordion>
111111
<Accordion title="Runtime cleanup">
112112
For isolated jobs, runtime teardown now includes best-effort browser cleanup for that cron session. Cleanup failures are ignored so the actual cron result still wins.

docs/automation/taskflow.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Use Task Flow when work spans multiple sequential or branching steps and you nee
2525
For recurring workflows such as market intelligence briefings, treat the schedule, orchestration, and reliability checks as separate layers:
2626

2727
1. Use [Scheduled Tasks](/automation/cron-jobs) for timing.
28-
2. Store prior context in the workflow's own files, database, or tool state.
28+
2. Use a persistent cron session when the workflow should build on prior context.
2929
3. Use [Lobster](/tools/lobster) for deterministic steps, approval gates, and resume tokens.
3030
4. Use Task Flow to track the multi-step run across child tasks, waits, retries, and gateway restarts.
3131

@@ -43,7 +43,7 @@ openclaw cron add \
4343
--to "channel:C1234567890"
4444
```
4545

46-
Use `session:<id>` when the job should target a known chat/session for delivery context or safe preference seeding. Cron still executes each run in a detached session, so put previous run summaries and standing workflow state in explicit storage the job can read.
46+
Use `session:<id>` instead of `isolated` when the recurring workflow needs deliberate history, previous run summaries, or standing context. Use `isolated` when each run should start fresh and all required state is explicit in the workflow.
4747

4848
Inside the workflow, put reliability checks before the LLM summary step:
4949

src/cron/delivery-plan.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from "@openclaw/normalization-core/string-coerce";
88
import type { CronFailureDestinationConfig } from "../config/types.cron.js";
99
import { resolveTargetPrefixedChannel } from "../infra/outbound/channel-target-prefix.js";
10-
import { isDetachedCronSessionTarget } from "./session-target.js";
1110
import type { CronDelivery, CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js";
1211

1312
/** Normalized routing plan for a cron job's primary delivery behavior. */
@@ -106,7 +105,10 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
106105

107106
const isDetachedOutputJob =
108107
(job.payload.kind === "agentTurn" || job.payload.kind === "command") &&
109-
isDetachedCronSessionTarget(job.sessionTarget);
108+
typeof job.sessionTarget === "string" &&
109+
(job.sessionTarget === "isolated" ||
110+
job.sessionTarget === "current" ||
111+
job.sessionTarget.startsWith("session:"));
110112
// Isolated/current/session output jobs default to announce delivery so their
111113
// result reaches the initiating session unless the job opts out.
112114
const resolvedMode = isDetachedOutputJob ? "announce" : "none";

src/cron/isolated-agent.session-identity.test.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,12 @@ describe("runCronIsolatedAgentTurn session identity", () => {
153153
});
154154
});
155155

156-
it("persists rotated transcript identity for current-bound cron runs under the cron key", async () => {
156+
it("persists rotated transcript identity for current-bound cron runs", async () => {
157157
await withTempHome(async (home) => {
158158
const deps = makeDeps();
159159
const boundSessionKey = "agent:main:telegram:direct:42";
160160
const originalSessionFile = path.join(home, "bound-session.jsonl");
161161
const rotatedSessionFile = path.join(home, "bound-session-rotated.jsonl");
162-
await fs.writeFile(rotatedSessionFile, "", "utf-8");
163162
const storePath = await writeSessionStoreEntries(home, {
164163
[boundSessionKey]: {
165164
sessionId: "bound-session",
@@ -197,7 +196,6 @@ describe("runCronIsolatedAgentTurn session identity", () => {
197196
},
198197
{ sessionContext: { sessionKey: boundSessionKey } },
199198
) as CronJob;
200-
const executionSessionKey = `agent:main:cron:${currentBoundJob.id}`;
201199

202200
const res = await runCronIsolatedAgentTurn({
203201
cfg: makeCfg(home, storePath),
@@ -218,27 +216,21 @@ describe("runCronIsolatedAgentTurn session identity", () => {
218216
expect(finalPersist?.[0]).toBe(storePath);
219217
const persistedStore: Record<string, { [key: string]: unknown }> = {};
220218
(finalPersist![1] as (store: typeof persistedStore) => void)(persistedStore);
221-
expect(persistedStore[executionSessionKey]).toEqual(
219+
expect(persistedStore[boundSessionKey]).toEqual(
222220
expect.objectContaining({
223221
sessionId: "bound-session-rotated",
224222
sessionFile: rotatedSessionFile,
225-
usageFamilyKey: executionSessionKey,
226-
usageFamilySessionIds: expect.arrayContaining(["bound-session-rotated"]),
223+
usageFamilyKey: boundSessionKey,
224+
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
227225
}),
228226
);
229227

230-
await expect(readSessionEntry(storePath, executionSessionKey)).resolves.toEqual(
228+
await expect(readSessionEntry(storePath, boundSessionKey)).resolves.toEqual(
231229
expect.objectContaining({
232230
sessionId: "bound-session-rotated",
233231
sessionFile: rotatedSessionFile,
234232
}),
235233
);
236-
await expect(readSessionEntry(storePath, boundSessionKey)).resolves.toEqual(
237-
expect.objectContaining({
238-
sessionId: "bound-session",
239-
sessionFile: originalSessionFile,
240-
}),
241-
);
242234
});
243235
});
244236

src/cron/isolated-agent/run-executor.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** Executes detached cron prompts with model fallbacks and interim-ack retries. */
1+
/** Executes isolated cron prompts with model fallbacks and interim-ack retries. */
22
import { createHash } from "node:crypto";
33
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
44
import type { BootstrapContextMode } from "../../agents/bootstrap-files.js";
@@ -12,7 +12,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
1212
import type { SourceDeliveryPlan } from "../../infra/outbound/source-delivery-plan.js";
1313
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
1414
import type { SkillSnapshot } from "../../skills/types.js";
15-
import { isDetachedCronSessionTarget } from "../session-target.js";
1615
import type { CronAgentExecutionPhaseUpdate, CronJob } from "../types.js";
1716
import {
1817
resolveCronChannelOutputPolicy,
@@ -67,27 +66,27 @@ const COMMAND_STYLE_CRON_PREFIX =
6766
/^(?:(?:[A-Z_][A-Z0-9_]*=\S+\s+)+)?(?:cd\s+\S+|(?:\.{1,2}|~)?\/\S+|[A-Za-z]:[\\/]\S+|(?:bash|bun|cargo|deno|docker|gh|git|go|make|node|npm|npx|pnpm|python|python3|ruby|sh|tsx|uv|zsh)\b)/u;
6867
const MAX_CRON_DELIVERY_TARGET_CONTEXT_CHARS = 1000;
6968

70-
function resolveDetachedCronPromptCacheKey(params: {
69+
function resolveIsolatedCronPromptCacheKey(params: {
7170
job: CronJob;
7271
agentId: string;
7372
agentSessionKey: string;
7473
provider: string;
7574
model: string;
7675
}): string | undefined {
77-
if (!isDetachedCronSessionTarget(params.job.sessionTarget)) {
76+
if (params.job.sessionTarget !== "isolated") {
7877
return undefined;
7978
}
8079
const material = JSON.stringify({
8180
version: 1,
82-
kind: "detached-cron",
81+
kind: "isolated-cron",
8382
jobId: params.job.id,
8483
agentId: params.agentId,
8584
agentSessionKey: params.agentSessionKey,
8685
provider: params.provider,
8786
model: params.model,
8887
});
8988
const digest = createHash("sha256").update(material).digest("hex").slice(0, 32);
90-
// Detached cron rotates transcript/session ids per run; keep cache affinity
89+
// Isolated cron rotates transcript/session ids per run; keep cache affinity
9190
// on stable job identity without sending raw local session labels upstream.
9291
return `openclaw-cron-${digest}`;
9392
}
@@ -333,7 +332,7 @@ export function createCronPromptExecutor(params: {
333332
agentId: params.agentId,
334333
trigger: "cron",
335334
jobId: params.job.id,
336-
cleanupCliLiveSessionOnRunEnd: isDetachedCronSessionTarget(params.job.sessionTarget),
335+
cleanupCliLiveSessionOnRunEnd: params.job.sessionTarget === "isolated",
337336
sessionFile,
338337
workspaceDir: params.workspaceDir,
339338
config: params.cfgWithAgentDefaults,
@@ -371,7 +370,7 @@ export function createCronPromptExecutor(params: {
371370
return result;
372371
}
373372
const { resolveFastModeState, runEmbeddedAgent } = await loadCronEmbeddedRuntime();
374-
const promptCacheKey = resolveDetachedCronPromptCacheKey({
373+
const promptCacheKey = resolveIsolatedCronPromptCacheKey({
375374
job: params.job,
376375
agentId: params.agentId,
377376
agentSessionKey: params.agentSessionKey,
@@ -392,7 +391,7 @@ export function createCronPromptExecutor(params: {
392391
agentId: params.agentId,
393392
trigger: "cron",
394393
jobId: params.job.id,
395-
cleanupBundleMcpOnRunEnd: isDetachedCronSessionTarget(params.job.sessionTarget),
394+
cleanupBundleMcpOnRunEnd: params.job.sessionTarget === "isolated",
396395
allowGatewaySubagentBinding: true,
397396
messageChannel,
398397
agentAccountId: params.resolvedDelivery.accountId,

src/cron/isolated-agent/run-session-state.test.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,32 +144,26 @@ describe("createPersistCronSessionEntry", () => {
144144
});
145145
});
146146

147-
it("persists non-resumable cron state under the supplied execution session key", async () => {
147+
it("persists explicit session-bound cron state under the requested session key", async () => {
148148
const cronSession = makeCronSession();
149149
const updateSessionStore = vi.fn(
150150
async (_storePath, update: (store: Record<string, SessionEntry>) => void) => {
151151
const store: Record<string, SessionEntry> = {};
152152
update(store);
153-
expect(store["agent:main:cron:job"]).toEqual({
154-
updatedAt: 1000,
155-
systemSent: true,
156-
});
153+
expect(store["agent:main:session"]).toBe(cronSession.sessionEntry);
157154
},
158155
);
159156

160157
const persist = createPersistCronSessionEntry({
161158
isFastTestEnv: false,
162159
cronSession,
163-
agentSessionKey: "agent:main:cron:job",
160+
agentSessionKey: "agent:main:session",
164161
updateSessionStore,
165162
});
166163

167164
await persist();
168165

169-
expect(cronSession.store["agent:main:cron:job"]).toEqual({
170-
updatedAt: 1000,
171-
systemSent: true,
172-
});
166+
expect(cronSession.store["agent:main:session"]).toBe(cronSession.sessionEntry);
173167
});
174168

175169
it("adopts rotated run transcript metadata before persisting session-bound cron state", async () => {

src/cron/isolated-agent/run.fast-mode.test.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -128,22 +128,25 @@ async function runFastModeCase(params: {
128128
params.expectedCleanupBundleMcpOnRunEnd ?? true,
129129
);
130130
expect(embeddedRunParams.allowGatewaySubagentBinding).toBe(true);
131-
const isDetached =
132-
(params.sessionTarget ?? "isolated") === "isolated" ||
133-
params.sessionTarget === "current" ||
134-
params.sessionTarget?.startsWith("session:");
131+
const isIsolated = (params.sessionTarget ?? "isolated") === "isolated";
135132
if (params.expectedRetiredSessionId) {
136-
const retireParams = retireSessionMcpRuntimeMock.mock.calls[0]?.[0];
133+
expect(retireSessionMcpRuntimeMock).toHaveBeenCalledOnce();
134+
const [retireParams] = requireFirstMockCall(
135+
retireSessionMcpRuntimeMock,
136+
"retire session mcp runtime",
137+
);
137138
expect(retireParams.sessionId).toBe(params.expectedRetiredSessionId);
138139
expect(retireParams.reason).toBe("cron-session-rollover");
140+
return;
139141
}
140-
if (isDetached) {
141-
// disposeCronRunContext retires MCP for detached cron sessions.
142-
expect(retireSessionMcpRuntimeMock).toHaveBeenCalledTimes(
143-
params.expectedRetiredSessionId ? 2 : 1,
142+
if (isIsolated) {
143+
// disposeCronRunContext now retires MCP for isolated sessions
144+
expect(retireSessionMcpRuntimeMock).toHaveBeenCalledOnce();
145+
const [disposeRetireParams] = requireFirstMockCall(
146+
retireSessionMcpRuntimeMock,
147+
"dispose retire session mcp runtime",
144148
);
145-
const disposeRetireParams = retireSessionMcpRuntimeMock.mock.calls.at(-1)?.[0];
146-
expect(disposeRetireParams.reason).toBe("detached-cron-dispose");
149+
expect(disposeRetireParams.reason).toBe("isolated-cron-dispose");
147150
} else {
148151
expect(retireSessionMcpRuntimeMock).not.toHaveBeenCalled();
149152
}
@@ -245,21 +248,23 @@ describe("runCronIsolatedAgentTurn — fast mode", () => {
245248
});
246249
});
247250

248-
it("cleans up bundled MCP runtime state for explicit session cron targets", async () => {
251+
it("preserves bundled MCP runtime state for persistent cron session targets", async () => {
249252
await runFastModeCase({
250253
configFastMode: true,
251254
expectedFastMode: true,
252-
message: "test explicit session cron target",
255+
expectedCleanupBundleMcpOnRunEnd: false,
256+
message: "test persistent cron session",
253257
sessionTarget: "session:agent:main:main:thread:9999",
254258
});
255259
});
256260

257-
it("cleans up bundled MCP runtime state when a detached cron session rolls over", async () => {
261+
it("retires the previous bundled MCP runtime when a persistent cron session rolls over", async () => {
258262
await runFastModeCase({
259263
configFastMode: true,
260264
expectedFastMode: true,
265+
expectedCleanupBundleMcpOnRunEnd: false,
261266
expectedRetiredSessionId: "stale-session-id",
262-
message: "test detached cron session rollover",
267+
message: "test persistent cron session rollover",
263268
previousSessionId: "stale-session-id",
264269
sessionId: "rotated-session-id",
265270
sessionTarget: "session:agent:main:main:thread:9999",

src/cron/isolated-agent/run.message-tool-policy.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -996,12 +996,12 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
996996
expect(getAgentRunContext("test-session-id")).toBeUndefined();
997997
});
998998

999-
it("releases preexisting run context after detached current-session completion", async () => {
999+
it("keeps shared cron run context references active after completion", async () => {
10001000
const cronSession = makeCronSession({
10011001
store: { "agent:default:cron:message-tool-policy": { retained: true } },
10021002
});
10031003
resolveCronSessionMock.mockReturnValue(cronSession);
1004-
const { getAgentRunContext, registerAgentRunContext } =
1004+
const { clearAgentRunContext, getAgentRunContext, registerAgentRunContext } =
10051005
await import("../../infra/agent-events.js");
10061006
registerAgentRunContext("test-session-id", {
10071007
sessionKey: "agent:default:cron:message-tool-policy",
@@ -1015,8 +1015,11 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
10151015
job: currentSessionJob as never,
10161016
});
10171017

1018-
expect(getAgentRunContext("test-session-id")).toBeUndefined();
1018+
expect(getAgentRunContext("test-session-id")).toMatchObject({
1019+
sessionKey: "agent:default:cron:message-tool-policy",
1020+
});
10191021
expect(cronSession.store).toBeUndefined();
1022+
clearAgentRunContext("test-session-id");
10201023
});
10211024

10221025
it("releases a shared cron run context created by this invocation", async () => {

0 commit comments

Comments
 (0)