Skip to content

Commit d90cac9

Browse files
authored
fix: cron scheduler reliability, store hardening, and UX improvements (#10776)
* refactor: update cron job wake mode and run mode handling - Changed default wake mode from 'next-heartbeat' to 'now' in CronJobEditor and related CLI commands. - Updated cron-tool tests to reflect changes in run mode, introducing 'due' and 'force' options. - Enhanced cron-tool logic to handle new run modes and ensure compatibility with existing job structures. - Added new tests for delivery plan consistency and job execution behavior under various conditions. - Improved normalization functions to handle wake mode and session target casing. This refactor aims to streamline cron job configurations and enhance the overall user experience with clearer defaults and improved functionality. * test: enhance cron job functionality and UI - Added tests to ensure the isolated agent correctly announces the final payload text when delivering messages via Telegram. - Implemented a new function to pick the last deliverable payload from a list of delivery payloads. - Enhanced the cron service to maintain legacy "every" jobs while minute cron jobs recompute schedules. - Updated the cron store migration tests to verify the addition of anchorMs to legacy every schedules. - Improved the UI for displaying cron job details, including job state and delivery information, with new styles and layout adjustments. These changes aim to improve the reliability and user experience of the cron job system. * test: enhance sessions thinking level handling - Added tests to verify that the correct thinking levels are applied during session spawning. - Updated the sessions-spawn-tool to include a new parameter for overriding thinking levels. - Enhanced the UI to support additional thinking levels, including "xhigh" and "full", and improved the handling of current options in dropdowns. These changes aim to improve the flexibility and accuracy of thinking level configurations in session management. * feat: enhance session management and cron job functionality - Introduced passthrough arguments in the test-parallel script to allow for flexible command-line options. - Updated session handling to hide cron run alias session keys from the sessions list, improving clarity. - Enhanced the cron service to accurately record job start times and durations, ensuring better tracking of job execution. - Added tests to verify the correct behavior of the cron service under various conditions, including zero-delay timers. These changes aim to improve the usability and reliability of session and cron job management. * feat: implement job running state checks in cron service - Added functionality to prevent manual job runs if a job is already in progress, enhancing job management. - Updated the `isJobDue` function to include checks for running jobs, ensuring accurate scheduling. - Enhanced the `run` function to return a specific reason when a job is already running. - Introduced a new test case to verify the behavior of forced manual runs during active job execution. These changes aim to improve the reliability and clarity of cron job execution and management. * feat: add session ID and key to CronRunLogEntry model - Introduced `sessionid` and `sessionkey` properties to the `CronRunLogEntry` struct for enhanced tracking of session-related information. - Updated the initializer and Codable conformance to accommodate the new properties, ensuring proper serialization and deserialization. These changes aim to improve the granularity of logging and session management within the cron job system. * fix: improve session display name resolution - Updated the `resolveSessionDisplayName` function to ensure that both label and displayName are trimmed and default to an empty string if not present. - Enhanced the logic to prevent returning the key if it matches the label or displayName, improving clarity in session naming. These changes aim to enhance the accuracy and usability of session display names in the UI. * perf: skip cron store persist when idle timer tick produces no changes recomputeNextRuns now returns a boolean indicating whether any job state was mutated. The idle path in onTimer only persists when the return value is true, eliminating unnecessary file writes every 60s for far-future or idle schedules. * fix: prep for merge - explicit delivery mode migration, docs + changelog (#10776) (thanks @tyler6204)
1 parent 0dd7033 commit d90cac9

58 files changed

Lines changed: 2952 additions & 250 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
10+
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
911
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
1012
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
1113
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
@@ -14,8 +16,16 @@ Docs: https://docs.openclaw.ai
1416
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
1517
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
1618

19+
### Added
20+
21+
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
22+
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
23+
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
24+
1725
### Fixes
1826

27+
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
28+
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
1929
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
2030
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
2131
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.

apps/macos/Sources/OpenClaw/CronJobEditor.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ struct CronJobEditor: View {
2929
@State var agentId: String = ""
3030
@State var enabled: Bool = true
3131
@State var sessionTarget: CronSessionTarget = .main
32-
@State var wakeMode: CronWakeMode = .nextHeartbeat
32+
@State var wakeMode: CronWakeMode = .now
3333
@State var deleteAfterRun: Bool = false
3434

3535
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@@ -119,8 +119,8 @@ struct CronJobEditor: View {
119119
GridRow {
120120
self.gridLabel("Wake mode")
121121
Picker("", selection: self.$wakeMode) {
122-
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
123122
Text("now").tag(CronWakeMode.now)
123+
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
124124
}
125125
.labelsHidden()
126126
.pickerStyle(.segmented)

apps/macos/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2025,6 +2025,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20252025
public let status: AnyCodable?
20262026
public let error: String?
20272027
public let summary: String?
2028+
public let sessionid: String?
2029+
public let sessionkey: String?
20282030
public let runatms: Int?
20292031
public let durationms: Int?
20302032
public let nextrunatms: Int?
@@ -2036,6 +2038,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20362038
status: AnyCodable?,
20372039
error: String?,
20382040
summary: String?,
2041+
sessionid: String?,
2042+
sessionkey: String?,
20392043
runatms: Int?,
20402044
durationms: Int?,
20412045
nextrunatms: Int?
@@ -2046,6 +2050,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20462050
self.status = status
20472051
self.error = error
20482052
self.summary = summary
2053+
self.sessionid = sessionid
2054+
self.sessionkey = sessionkey
20492055
self.runatms = runatms
20502056
self.durationms = durationms
20512057
self.nextrunatms = nextrunatms
@@ -2057,6 +2063,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20572063
case status
20582064
case error
20592065
case summary
2066+
case sessionid = "sessionId"
2067+
case sessionkey = "sessionKey"
20602068
case runatms = "runAtMs"
20612069
case durationms = "durationMs"
20622070
case nextrunatms = "nextRunAtMs"

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2025,6 +2025,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20252025
public let status: AnyCodable?
20262026
public let error: String?
20272027
public let summary: String?
2028+
public let sessionid: String?
2029+
public let sessionkey: String?
20282030
public let runatms: Int?
20292031
public let durationms: Int?
20302032
public let nextrunatms: Int?
@@ -2036,6 +2038,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20362038
status: AnyCodable?,
20372039
error: String?,
20382040
summary: String?,
2041+
sessionid: String?,
2042+
sessionkey: String?,
20392043
runatms: Int?,
20402044
durationms: Int?,
20412045
nextrunatms: Int?
@@ -2046,6 +2050,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20462050
self.status = status
20472051
self.error = error
20482052
self.summary = summary
2053+
self.sessionid = sessionid
2054+
self.sessionkey = sessionkey
20492055
self.runatms = runatms
20502056
self.durationms = durationms
20512057
self.nextrunatms = nextrunatms
@@ -2057,6 +2063,8 @@ public struct CronRunLogEntry: Codable, Sendable {
20572063
case status
20582064
case error
20592065
case summary
2066+
case sessionid = "sessionId"
2067+
case sessionkey = "sessionKey"
20602068
case runatms = "runAtMs"
20612069
case durationms = "durationMs"
20622070
case nextrunatms = "nextRunAtMs"

docs/automation/cron-jobs.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ openclaw cron add \
4040
--delete-after-run
4141

4242
openclaw cron list
43-
openclaw cron run <job-id> --force
43+
openclaw cron run <job-id>
4444
openclaw cron runs --id <job-id>
4545
```
4646

@@ -123,8 +123,8 @@ local timezone is used.
123123
Main jobs enqueue a system event and optionally wake the heartbeat runner.
124124
They must use `payload.kind = "systemEvent"`.
125125

126-
- `wakeMode: "next-heartbeat"` (default): event waits for the next scheduled heartbeat.
127-
- `wakeMode: "now"`: event triggers an immediate heartbeat run.
126+
- `wakeMode: "now"` (default): event triggers an immediate heartbeat run.
127+
- `wakeMode: "next-heartbeat"`: event waits for the next scheduled heartbeat.
128128

129129
This is the best fit when you want the normal heartbeat prompt + main-session context.
130130
See [Heartbeat](/gateway/heartbeat).
@@ -288,7 +288,7 @@ Notes:
288288
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
289289
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
290290
`delivery`.
291-
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
291+
- `wakeMode` defaults to `"now"` when omitted.
292292

293293
### cron.update params
294294

@@ -420,10 +420,11 @@ openclaw cron edit <jobId> --agent ops
420420
openclaw cron edit <jobId> --clear-agent
421421
```
422422

423-
Manual run (debug):
423+
Manual run (force is the default, use `--due` to only run when due):
424424

425425
```bash
426-
openclaw cron run <jobId> --force
426+
openclaw cron run <jobId>
427+
openclaw cron run <jobId> --due
427428
```
428429

429430
Edit an existing job (patch fields):

scripts/test-parallel.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const shardCount = isWindowsCi
3232
const windowsCiArgs = isWindowsCi
3333
? ["--no-file-parallelism", "--dangerouslyIgnoreUnhandledErrors"]
3434
: [];
35+
const passthroughArgs = process.argv.slice(2);
3536
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
3637
const resolvedOverride =
3738
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
@@ -96,6 +97,30 @@ const shutdown = (signal) => {
9697
process.on("SIGINT", () => shutdown("SIGINT"));
9798
process.on("SIGTERM", () => shutdown("SIGTERM"));
9899

100+
if (passthroughArgs.length > 0) {
101+
const args = maxWorkers
102+
? ["vitest", "run", "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...passthroughArgs]
103+
: ["vitest", "run", ...windowsCiArgs, ...passthroughArgs];
104+
const nodeOptions = process.env.NODE_OPTIONS ?? "";
105+
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
106+
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
107+
nodeOptions,
108+
);
109+
const code = await new Promise((resolve) => {
110+
const child = spawn(pnpm, args, {
111+
stdio: "inherit",
112+
env: { ...process.env, NODE_OPTIONS: nextNodeOptions },
113+
shell: process.platform === "win32",
114+
});
115+
children.add(child);
116+
child.on("exit", (exitCode, signal) => {
117+
children.delete(child);
118+
resolve(exitCode ?? (signal ? 1 : 0));
119+
});
120+
});
121+
process.exit(Number(code) || 0);
122+
}
123+
99124
const parallelCodes = await Promise.all(parallelRuns.map(run));
100125
const failedParallel = parallelCodes.find((code) => code !== 0);
101126
if (failedParallel !== undefined) {

src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,12 @@ describe("sessions_spawn thinking defaults", () => {
4545
const agentCall = calls
4646
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
4747
.findLast((call) => call.method === "agent");
48+
const thinkingPatch = calls
49+
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
50+
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
4851

4952
expect(agentCall?.params?.thinking).toBe("high");
53+
expect(thinkingPatch?.params?.thinkingLevel).toBe("high");
5054
});
5155

5256
it("prefers explicit sessions_spawn.thinking over config default", async () => {
@@ -60,7 +64,11 @@ describe("sessions_spawn thinking defaults", () => {
6064
const agentCall = calls
6165
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
6266
.findLast((call) => call.method === "agent");
67+
const thinkingPatch = calls
68+
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
69+
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
6370

6471
expect(agentCall?.params?.thinking).toBe("low");
72+
expect(thinkingPatch?.params?.thinkingLevel).toBe("low");
6573
});
6674
});

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ describe("cron tool", () => {
3030
],
3131
["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }],
3232
["remove", { action: "remove", id: "job-2" }, { id: "job-2" }],
33-
["run", { action: "run", jobId: "job-1" }, { id: "job-1" }],
34-
["run", { action: "run", id: "job-2" }, { id: "job-2" }],
33+
["run", { action: "run", jobId: "job-1" }, { id: "job-1", mode: "force" }],
34+
["run", { action: "run", id: "job-2" }, { id: "job-2", mode: "force" }],
3535
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
3636
["runs", { action: "runs", id: "job-2" }, { id: "job-2" }],
3737
])("%s sends id to gateway", async (action, args, expectedParams) => {
@@ -58,7 +58,21 @@ describe("cron tool", () => {
5858
const call = callGatewayMock.mock.calls[0]?.[0] as {
5959
params?: unknown;
6060
};
61-
expect(call?.params).toEqual({ id: "job-primary" });
61+
expect(call?.params).toEqual({ id: "job-primary", mode: "force" });
62+
});
63+
64+
it("supports due-only run mode", async () => {
65+
const tool = createCronTool();
66+
await tool.execute("call-due", {
67+
action: "run",
68+
jobId: "job-due",
69+
runMode: "due",
70+
});
71+
72+
const call = callGatewayMock.mock.calls[0]?.[0] as {
73+
params?: unknown;
74+
};
75+
expect(call?.params).toEqual({ id: "job-due", mode: "due" });
6276
});
6377

6478
it("normalizes cron.add job payloads", async () => {
@@ -86,7 +100,7 @@ describe("cron tool", () => {
86100
deleteAfterRun: true,
87101
schedule: { kind: "at", at: new Date(123).toISOString() },
88102
sessionTarget: "main",
89-
wakeMode: "next-heartbeat",
103+
wakeMode: "now",
90104
payload: { kind: "systemEvent", text: "hello" },
91105
});
92106
});

src/agents/tools/cron-tool.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-h
1818
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const;
1919

2020
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
21+
const CRON_RUN_MODES = ["due", "force"] as const;
2122

2223
const REMINDER_CONTEXT_MESSAGES_MAX = 10;
2324
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
@@ -37,6 +38,7 @@ const CronToolSchema = Type.Object({
3738
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
3839
text: Type.Optional(Type.String()),
3940
mode: optionalStringEnum(CRON_WAKE_MODES),
41+
runMode: optionalStringEnum(CRON_RUN_MODES),
4042
contextMessages: Type.Optional(
4143
Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }),
4244
),
@@ -312,7 +314,6 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
312314
}
313315
}
314316

315-
// [Fix Issue 3] Infer delivery target from session key for isolated jobs if not provided
316317
if (
317318
opts?.agentSessionKey &&
318319
job &&
@@ -393,7 +394,9 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
393394
if (!id) {
394395
throw new Error("jobId required (id accepted for backward compatibility)");
395396
}
396-
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id }));
397+
const runMode =
398+
params.runMode === "due" || params.runMode === "force" ? params.runMode : "force";
399+
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id, mode: runMode }));
397400
}
398401
case "runs": {
399402
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");

src/agents/tools/sessions-spawn-tool.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,26 @@ export function createSessionsSpawnTool(opts?: {
214214
modelWarning = messageText;
215215
}
216216
}
217+
if (thinkingOverride !== undefined) {
218+
try {
219+
await callGateway({
220+
method: "sessions.patch",
221+
params: {
222+
key: childSessionKey,
223+
thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride,
224+
},
225+
timeoutMs: 10_000,
226+
});
227+
} catch (err) {
228+
const messageText =
229+
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
230+
return jsonResult({
231+
status: "error",
232+
error: messageText,
233+
childSessionKey,
234+
});
235+
}
236+
}
217237
const childSystemPrompt = buildSubagentSystemPrompt({
218238
requesterSessionKey,
219239
requesterOrigin,

0 commit comments

Comments
 (0)