Skip to content

Commit e7d9648

Browse files
feat(cron): support custom session IDs and auto-bind to current session (openclaw#16511)
feat(cron): support persistent session targets for cron jobs (openclaw#9765) Add support for `sessionTarget: "current"` and `session:<id>` so cron jobs can bind to the creating session or a persistent named session instead of only `main` or ephemeral `isolated` sessions. Also: - preserve custom session targets across reloads and restarts - update gateway validation and normalization for the new target forms - add cron coverage for current/custom session targets and fallback behavior - fix merged CI regressions in Discord and diffs tests - add a changelog entry for the new cron session behavior Co-authored-by: kkhomej33-netizen <[email protected]> Co-authored-by: ImLukeF <[email protected]>
1 parent 61d171a commit e7d9648

33 files changed

+617
-118
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.
1616
- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
1717
- Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`.
18+
- Cron/sessions: add `sessionTarget: "current"` and `session:<id>` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF.
1819

1920
### Fixes
2021

apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ extension CronJobEditor {
1616
self.agentId = job.agentId ?? ""
1717
self.enabled = job.enabled
1818
self.deleteAfterRun = job.deleteAfterRun ?? false
19-
self.sessionTarget = job.sessionTarget
19+
switch job.parsedSessionTarget {
20+
case .predefined(let target):
21+
self.sessionTarget = target
22+
self.preservedSessionTargetRaw = nil
23+
case .session(let id):
24+
self.sessionTarget = .isolated
25+
self.preservedSessionTargetRaw = "session:\(id)"
26+
}
2027
self.wakeMode = job.wakeMode
2128

2229
switch job.schedule {
@@ -51,7 +58,7 @@ extension CronJobEditor {
5158
self.channel = trimmed.isEmpty ? "last" : trimmed
5259
self.to = delivery.to ?? ""
5360
self.bestEffortDeliver = delivery.bestEffort ?? false
54-
} else if self.sessionTarget == .isolated {
61+
} else if self.isIsolatedLikeSessionTarget {
5562
self.deliveryMode = .announce
5663
}
5764
}
@@ -80,7 +87,7 @@ extension CronJobEditor {
8087
"name": name,
8188
"enabled": self.enabled,
8289
"schedule": schedule,
83-
"sessionTarget": self.sessionTarget.rawValue,
90+
"sessionTarget": self.effectiveSessionTargetRaw,
8491
"wakeMode": self.wakeMode.rawValue,
8592
"payload": payload,
8693
]
@@ -92,7 +99,7 @@ extension CronJobEditor {
9299
root["agentId"] = NSNull()
93100
}
94101

95-
if self.sessionTarget == .isolated {
102+
if self.isIsolatedLikeSessionTarget {
96103
root["delivery"] = self.buildDelivery()
97104
}
98105

@@ -160,7 +167,7 @@ extension CronJobEditor {
160167
}
161168

162169
func buildSelectedPayload() throws -> [String: Any] {
163-
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
170+
if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() }
164171
switch self.payloadKind {
165172
case .systemEvent:
166173
let text = self.trimmed(self.systemEventText)
@@ -171,7 +178,7 @@ extension CronJobEditor {
171178
}
172179

173180
func validateSessionTarget(_ payload: [String: Any]) throws {
174-
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
181+
if self.effectiveSessionTargetRaw == "main", payload["kind"] as? String == "agentTurn" {
175182
throw NSError(
176183
domain: "Cron",
177184
code: 0,
@@ -181,7 +188,7 @@ extension CronJobEditor {
181188
])
182189
}
183190

184-
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
191+
if self.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" {
185192
throw NSError(
186193
domain: "Cron",
187194
code: 0,
@@ -257,6 +264,17 @@ extension CronJobEditor {
257264
return Int(floor(n * factor))
258265
}
259266

267+
var effectiveSessionTargetRaw: String {
268+
if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty {
269+
return preserved
270+
}
271+
return self.sessionTarget.rawValue
272+
}
273+
274+
var isIsolatedLikeSessionTarget: Bool {
275+
self.effectiveSessionTargetRaw != "main"
276+
}
277+
260278
func formatDuration(ms: Int) -> String {
261279
DurationFormattingSupport.conciseDuration(ms: ms)
262280
}

apps/macos/Sources/OpenClaw/CronJobEditor.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct CronJobEditor: View {
1616
+ "Use an isolated session for agent turns so your main chat stays clean."
1717
static let sessionTargetNote =
1818
"Main jobs post a system event into the current main session. "
19-
+ "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel."
19+
+ "Current and isolated-style jobs run agent turns and can announce results to a channel."
2020
static let scheduleKindNote =
2121
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
2222
static let isolatedPayloadNote =
@@ -29,6 +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 preservedSessionTargetRaw: String?
3233
@State var wakeMode: CronWakeMode = .now
3334
@State var deleteAfterRun: Bool = false
3435

@@ -117,6 +118,7 @@ struct CronJobEditor: View {
117118
Picker("", selection: self.$sessionTarget) {
118119
Text("main").tag(CronSessionTarget.main)
119120
Text("isolated").tag(CronSessionTarget.isolated)
121+
Text("current").tag(CronSessionTarget.current)
120122
}
121123
.labelsHidden()
122124
.pickerStyle(.segmented)
@@ -209,7 +211,7 @@ struct CronJobEditor: View {
209211

210212
GroupBox("Payload") {
211213
VStack(alignment: .leading, spacing: 10) {
212-
if self.sessionTarget == .isolated {
214+
if self.isIsolatedLikeSessionTarget {
213215
Text(Self.isolatedPayloadNote)
214216
.font(.footnote)
215217
.foregroundStyle(.secondary)
@@ -289,8 +291,11 @@ struct CronJobEditor: View {
289291
self.sessionTarget = .isolated
290292
}
291293
}
292-
.onChange(of: self.sessionTarget) { _, newValue in
293-
if newValue == .isolated {
294+
.onChange(of: self.sessionTarget) { oldValue, newValue in
295+
if oldValue != newValue {
296+
self.preservedSessionTargetRaw = nil
297+
}
298+
if newValue != .main {
294299
self.payloadKind = .agentTurn
295300
} else if newValue == .main, self.payloadKind == .agentTurn {
296301
self.payloadKind = .systemEvent

apps/macos/Sources/OpenClaw/CronModels.swift

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,39 @@ import Foundation
33
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
44
case main
55
case isolated
6+
case current
67

78
var id: String {
89
self.rawValue
910
}
1011
}
1112

13+
enum CronCustomSessionTarget: Codable, Equatable {
14+
case predefined(CronSessionTarget)
15+
case session(id: String)
16+
17+
var rawValue: String {
18+
switch self {
19+
case .predefined(let target):
20+
return target.rawValue
21+
case .session(let id):
22+
return "session:\(id)"
23+
}
24+
}
25+
26+
static func from(_ value: String) -> CronCustomSessionTarget {
27+
if let predefined = CronSessionTarget(rawValue: value) {
28+
return .predefined(predefined)
29+
}
30+
if value.hasPrefix("session:") {
31+
let sessionId = String(value.dropFirst(8))
32+
return .session(id: sessionId)
33+
}
34+
// Fallback to isolated for unknown values
35+
return .predefined(.isolated)
36+
}
37+
}
38+
1239
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
1340
case now
1441
case nextHeartbeat = "next-heartbeat"
@@ -204,12 +231,69 @@ struct CronJob: Identifiable, Codable, Equatable {
204231
let createdAtMs: Int
205232
let updatedAtMs: Int
206233
let schedule: CronSchedule
207-
let sessionTarget: CronSessionTarget
234+
private let sessionTargetRaw: String
208235
let wakeMode: CronWakeMode
209236
let payload: CronPayload
210237
let delivery: CronDelivery?
211238
let state: CronJobState
212239

240+
enum CodingKeys: String, CodingKey {
241+
case id
242+
case agentId
243+
case name
244+
case description
245+
case enabled
246+
case deleteAfterRun
247+
case createdAtMs
248+
case updatedAtMs
249+
case schedule
250+
case sessionTargetRaw = "sessionTarget"
251+
case wakeMode
252+
case payload
253+
case delivery
254+
case state
255+
}
256+
257+
/// Parsed session target (predefined or custom session ID)
258+
var parsedSessionTarget: CronCustomSessionTarget {
259+
CronCustomSessionTarget.from(self.sessionTargetRaw)
260+
}
261+
262+
/// Compatibility shim for existing editor/UI code paths that still use the
263+
/// predefined enum.
264+
var sessionTarget: CronSessionTarget {
265+
switch self.parsedSessionTarget {
266+
case .predefined(let target):
267+
return target
268+
case .session:
269+
return .isolated
270+
}
271+
}
272+
273+
var sessionTargetDisplayValue: String {
274+
self.parsedSessionTarget.rawValue
275+
}
276+
277+
var transcriptSessionKey: String? {
278+
switch self.parsedSessionTarget {
279+
case .predefined(.main):
280+
return nil
281+
case .predefined(.isolated), .predefined(.current):
282+
return "cron:\(self.id)"
283+
case .session(let id):
284+
return id
285+
}
286+
}
287+
288+
var supportsAnnounceDelivery: Bool {
289+
switch self.parsedSessionTarget {
290+
case .predefined(.main):
291+
return false
292+
case .predefined(.isolated), .predefined(.current), .session:
293+
return true
294+
}
295+
}
296+
213297
var displayName: String {
214298
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
215299
return trimmed.isEmpty ? "Untitled job" : trimmed

apps/macos/Sources/OpenClaw/CronSettings+Rows.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ extension CronSettings {
1818
}
1919
}
2020
HStack(spacing: 6) {
21-
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
21+
StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary)
2222
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
2323
if let agentId = job.agentId, !agentId.isEmpty {
2424
StatusPill(text: "agent \(agentId)", tint: .secondary)
@@ -34,9 +34,9 @@ extension CronSettings {
3434
@ViewBuilder
3535
func jobContextMenu(_ job: CronJob) -> some View {
3636
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
37-
if job.sessionTarget == .isolated {
37+
if let transcriptSessionKey = job.transcriptSessionKey {
3838
Button("Open transcript") {
39-
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
39+
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
4040
}
4141
}
4242
Divider()
@@ -75,9 +75,9 @@ extension CronSettings {
7575
.labelsHidden()
7676
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
7777
.buttonStyle(.borderedProminent)
78-
if job.sessionTarget == .isolated {
78+
if let transcriptSessionKey = job.transcriptSessionKey {
7979
Button("Transcript") {
80-
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
80+
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
8181
}
8282
.buttonStyle(.bordered)
8383
}
@@ -103,7 +103,7 @@ extension CronSettings {
103103
if let agentId = job.agentId, !agentId.isEmpty {
104104
LabeledContent("Agent") { Text(agentId) }
105105
}
106-
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
106+
LabeledContent("Session") { Text(job.sessionTargetDisplayValue) }
107107
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
108108
LabeledContent("Next run") {
109109
if let date = job.nextRunDate {
@@ -224,7 +224,7 @@ extension CronSettings {
224224
HStack(spacing: 8) {
225225
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
226226
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
227-
if job.sessionTarget == .isolated {
227+
if job.supportsAnnounceDelivery {
228228
let delivery = job.delivery
229229
if let delivery {
230230
if delivery.mode == .announce {

docs/automation/cron-jobs.md

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
2525
- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules.
2626
- Two execution styles:
2727
- **Main session**: enqueue a system event, then run on the next heartbeat.
28-
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
28+
- **Isolated**: run a dedicated agent turn in `cron:<jobId>` or a custom session, with delivery (announce by default or none).
29+
- **Current session**: bind to the session where the cron is created (`sessionTarget: "current"`).
30+
- **Custom session**: run in a persistent named session (`sessionTarget: "session:custom-id"`).
2931
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
3032
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
3133
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
@@ -86,6 +88,14 @@ Think of a cron job as: **when** to run + **what** to do.
8688
2. **Choose where it runs**
8789
- `sessionTarget: "main"` → run during the next heartbeat with main context.
8890
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
91+
- `sessionTarget: "current"` → bind to the current session (resolved at creation time to `session:<sessionKey>`).
92+
- `sessionTarget: "session:custom-id"` → run in a persistent named session that maintains context across runs.
93+
94+
Default behavior (unchanged):
95+
- `systemEvent` payloads default to `main`
96+
- `agentTurn` payloads default to `isolated`
97+
98+
To use current session binding, explicitly set `sessionTarget: "current"`.
8999

90100
3. **Choose the payload**
91101
- Main session → `payload.kind = "systemEvent"`
@@ -147,12 +157,13 @@ See [Heartbeat](/gateway/heartbeat).
147157

148158
#### Isolated jobs (dedicated cron sessions)
149159

150-
Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
160+
Isolated jobs run a dedicated agent turn in session `cron:<jobId>` or a custom session.
151161

152162
Key behaviors:
153163

154164
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
155-
- Each run starts a **fresh session id** (no prior conversation carry-over).
165+
- Each run starts a **fresh session id** (no prior conversation carry-over), unless using a custom session.
166+
- Custom sessions (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
156167
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
157168
- `delivery.mode` chooses what happens:
158169
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
@@ -321,12 +332,42 @@ Recurring, isolated job with delivery:
321332
}
322333
```
323334

335+
Recurring job bound to current session (auto-resolved at creation):
336+
337+
```json
338+
{
339+
"name": "Daily standup",
340+
"schedule": { "kind": "cron", "expr": "0 9 * * *" },
341+
"sessionTarget": "current",
342+
"payload": {
343+
"kind": "agentTurn",
344+
"message": "Summarize yesterday's progress."
345+
}
346+
}
347+
```
348+
349+
Recurring job in a custom persistent session:
350+
351+
```json
352+
{
353+
"name": "Project monitor",
354+
"schedule": { "kind": "every", "everyMs": 300000 },
355+
"sessionTarget": "session:project-alpha-monitor",
356+
"payload": {
357+
"kind": "agentTurn",
358+
"message": "Check project status and update the running log."
359+
}
360+
}
361+
```
362+
324363
Notes:
325364

326365
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
327366
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
328367
- `everyMs` is milliseconds.
329-
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
368+
- `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:<custom-id>"`.
369+
- `"current"` is resolved to `"session:<sessionKey>"` at creation time.
370+
- Custom sessions (`session:xxx`) maintain persistent context across runs.
330371
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
331372
`delivery`.
332373
- `wakeMode` defaults to `"now"` when omitted.

0 commit comments

Comments
 (0)