feat(cron): support custom session IDs and auto-bind to current session (#16511)

feat(cron): support persistent session targets for cron jobs (#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 <kkhomej33-netizen@users.noreply.github.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
This commit is contained in:
kkhomej33-netizen 2026-03-14 13:48:46 +08:00 committed by GitHub
parent 61d171ab0b
commit e7d9648fba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 617 additions and 118 deletions

View File

@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc. - Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.
- 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. - 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.
- Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`. - Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`.
- 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.
### Fixes ### Fixes

View File

@ -16,7 +16,14 @@ extension CronJobEditor {
self.agentId = job.agentId ?? "" self.agentId = job.agentId ?? ""
self.enabled = job.enabled self.enabled = job.enabled
self.deleteAfterRun = job.deleteAfterRun ?? false self.deleteAfterRun = job.deleteAfterRun ?? false
self.sessionTarget = job.sessionTarget switch job.parsedSessionTarget {
case .predefined(let target):
self.sessionTarget = target
self.preservedSessionTargetRaw = nil
case .session(let id):
self.sessionTarget = .isolated
self.preservedSessionTargetRaw = "session:\(id)"
}
self.wakeMode = job.wakeMode self.wakeMode = job.wakeMode
switch job.schedule { switch job.schedule {
@ -51,7 +58,7 @@ extension CronJobEditor {
self.channel = trimmed.isEmpty ? "last" : trimmed self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = delivery.to ?? "" self.to = delivery.to ?? ""
self.bestEffortDeliver = delivery.bestEffort ?? false self.bestEffortDeliver = delivery.bestEffort ?? false
} else if self.sessionTarget == .isolated { } else if self.isIsolatedLikeSessionTarget {
self.deliveryMode = .announce self.deliveryMode = .announce
} }
} }
@ -80,7 +87,7 @@ extension CronJobEditor {
"name": name, "name": name,
"enabled": self.enabled, "enabled": self.enabled,
"schedule": schedule, "schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue, "sessionTarget": self.effectiveSessionTargetRaw,
"wakeMode": self.wakeMode.rawValue, "wakeMode": self.wakeMode.rawValue,
"payload": payload, "payload": payload,
] ]
@ -92,7 +99,7 @@ extension CronJobEditor {
root["agentId"] = NSNull() root["agentId"] = NSNull()
} }
if self.sessionTarget == .isolated { if self.isIsolatedLikeSessionTarget {
root["delivery"] = self.buildDelivery() root["delivery"] = self.buildDelivery()
} }
@ -160,7 +167,7 @@ extension CronJobEditor {
} }
func buildSelectedPayload() throws -> [String: Any] { func buildSelectedPayload() throws -> [String: Any] {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() }
switch self.payloadKind { switch self.payloadKind {
case .systemEvent: case .systemEvent:
let text = self.trimmed(self.systemEventText) let text = self.trimmed(self.systemEventText)
@ -171,7 +178,7 @@ extension CronJobEditor {
} }
func validateSessionTarget(_ payload: [String: Any]) throws { func validateSessionTarget(_ payload: [String: Any]) throws {
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { if self.effectiveSessionTargetRaw == "main", payload["kind"] as? String == "agentTurn" {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
code: 0, code: 0,
@ -181,7 +188,7 @@ extension CronJobEditor {
]) ])
} }
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { if self.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
code: 0, code: 0,
@ -257,6 +264,17 @@ extension CronJobEditor {
return Int(floor(n * factor)) return Int(floor(n * factor))
} }
var effectiveSessionTargetRaw: String {
if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty {
return preserved
}
return self.sessionTarget.rawValue
}
var isIsolatedLikeSessionTarget: Bool {
self.effectiveSessionTargetRaw != "main"
}
func formatDuration(ms: Int) -> String { func formatDuration(ms: Int) -> String {
DurationFormattingSupport.conciseDuration(ms: ms) DurationFormattingSupport.conciseDuration(ms: ms)
} }

View File

@ -16,7 +16,7 @@ struct CronJobEditor: View {
+ "Use an isolated session for agent turns so your main chat stays clean." + "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote = static let sessionTargetNote =
"Main jobs post a system event into the current main session. " "Main jobs post a system event into the current main session. "
+ "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel." + "Current and isolated-style jobs run agent turns and can announce results to a channel."
static let scheduleKindNote = static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote = static let isolatedPayloadNote =
@ -29,6 +29,7 @@ struct CronJobEditor: View {
@State var agentId: String = "" @State var agentId: String = ""
@State var enabled: Bool = true @State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main @State var sessionTarget: CronSessionTarget = .main
@State var preservedSessionTargetRaw: String?
@State var wakeMode: CronWakeMode = .now @State var wakeMode: CronWakeMode = .now
@State var deleteAfterRun: Bool = false @State var deleteAfterRun: Bool = false
@ -117,6 +118,7 @@ struct CronJobEditor: View {
Picker("", selection: self.$sessionTarget) { Picker("", selection: self.$sessionTarget) {
Text("main").tag(CronSessionTarget.main) Text("main").tag(CronSessionTarget.main)
Text("isolated").tag(CronSessionTarget.isolated) Text("isolated").tag(CronSessionTarget.isolated)
Text("current").tag(CronSessionTarget.current)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)
@ -209,7 +211,7 @@ struct CronJobEditor: View {
GroupBox("Payload") { GroupBox("Payload") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if self.sessionTarget == .isolated { if self.isIsolatedLikeSessionTarget {
Text(Self.isolatedPayloadNote) Text(Self.isolatedPayloadNote)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -289,8 +291,11 @@ struct CronJobEditor: View {
self.sessionTarget = .isolated self.sessionTarget = .isolated
} }
} }
.onChange(of: self.sessionTarget) { _, newValue in .onChange(of: self.sessionTarget) { oldValue, newValue in
if newValue == .isolated { if oldValue != newValue {
self.preservedSessionTargetRaw = nil
}
if newValue != .main {
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
} else if newValue == .main, self.payloadKind == .agentTurn { } else if newValue == .main, self.payloadKind == .agentTurn {
self.payloadKind = .systemEvent self.payloadKind = .systemEvent

View File

@ -3,12 +3,39 @@ import Foundation
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable { enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
case main case main
case isolated case isolated
case current
var id: String { var id: String {
self.rawValue self.rawValue
} }
} }
enum CronCustomSessionTarget: Codable, Equatable {
case predefined(CronSessionTarget)
case session(id: String)
var rawValue: String {
switch self {
case .predefined(let target):
return target.rawValue
case .session(let id):
return "session:\(id)"
}
}
static func from(_ value: String) -> CronCustomSessionTarget {
if let predefined = CronSessionTarget(rawValue: value) {
return .predefined(predefined)
}
if value.hasPrefix("session:") {
let sessionId = String(value.dropFirst(8))
return .session(id: sessionId)
}
// Fallback to isolated for unknown values
return .predefined(.isolated)
}
}
enum CronWakeMode: String, CaseIterable, Identifiable, Codable { enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
case now case now
case nextHeartbeat = "next-heartbeat" case nextHeartbeat = "next-heartbeat"
@ -204,12 +231,69 @@ struct CronJob: Identifiable, Codable, Equatable {
let createdAtMs: Int let createdAtMs: Int
let updatedAtMs: Int let updatedAtMs: Int
let schedule: CronSchedule let schedule: CronSchedule
let sessionTarget: CronSessionTarget private let sessionTargetRaw: String
let wakeMode: CronWakeMode let wakeMode: CronWakeMode
let payload: CronPayload let payload: CronPayload
let delivery: CronDelivery? let delivery: CronDelivery?
let state: CronJobState let state: CronJobState
enum CodingKeys: String, CodingKey {
case id
case agentId
case name
case description
case enabled
case deleteAfterRun
case createdAtMs
case updatedAtMs
case schedule
case sessionTargetRaw = "sessionTarget"
case wakeMode
case payload
case delivery
case state
}
/// Parsed session target (predefined or custom session ID)
var parsedSessionTarget: CronCustomSessionTarget {
CronCustomSessionTarget.from(self.sessionTargetRaw)
}
/// Compatibility shim for existing editor/UI code paths that still use the
/// predefined enum.
var sessionTarget: CronSessionTarget {
switch self.parsedSessionTarget {
case .predefined(let target):
return target
case .session:
return .isolated
}
}
var sessionTargetDisplayValue: String {
self.parsedSessionTarget.rawValue
}
var transcriptSessionKey: String? {
switch self.parsedSessionTarget {
case .predefined(.main):
return nil
case .predefined(.isolated), .predefined(.current):
return "cron:\(self.id)"
case .session(let id):
return id
}
}
var supportsAnnounceDelivery: Bool {
switch self.parsedSessionTarget {
case .predefined(.main):
return false
case .predefined(.isolated), .predefined(.current), .session:
return true
}
}
var displayName: String { var displayName: String {
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Untitled job" : trimmed return trimmed.isEmpty ? "Untitled job" : trimmed

View File

@ -18,7 +18,7 @@ extension CronSettings {
} }
} }
HStack(spacing: 6) { HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary) StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let agentId = job.agentId, !agentId.isEmpty { if let agentId = job.agentId, !agentId.isEmpty {
StatusPill(text: "agent \(agentId)", tint: .secondary) StatusPill(text: "agent \(agentId)", tint: .secondary)
@ -34,9 +34,9 @@ extension CronSettings {
@ViewBuilder @ViewBuilder
func jobContextMenu(_ job: CronJob) -> some View { func jobContextMenu(_ job: CronJob) -> some View {
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
if job.sessionTarget == .isolated { if let transcriptSessionKey = job.transcriptSessionKey {
Button("Open transcript") { Button("Open transcript") {
WebChatManager.shared.show(sessionKey: "cron:\(job.id)") WebChatManager.shared.show(sessionKey: transcriptSessionKey)
} }
} }
Divider() Divider()
@ -75,9 +75,9 @@ extension CronSettings {
.labelsHidden() .labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
if job.sessionTarget == .isolated { if let transcriptSessionKey = job.transcriptSessionKey {
Button("Transcript") { Button("Transcript") {
WebChatManager.shared.show(sessionKey: "cron:\(job.id)") WebChatManager.shared.show(sessionKey: transcriptSessionKey)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
@ -103,7 +103,7 @@ extension CronSettings {
if let agentId = job.agentId, !agentId.isEmpty { if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) } LabeledContent("Agent") { Text(agentId) }
} }
LabeledContent("Session") { Text(job.sessionTarget.rawValue) } LabeledContent("Session") { Text(job.sessionTargetDisplayValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") { LabeledContent("Next run") {
if let date = job.nextRunDate { if let date = job.nextRunDate {
@ -224,7 +224,7 @@ extension CronSettings {
HStack(spacing: 8) { HStack(spacing: 8) {
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if job.sessionTarget == .isolated { if job.supportsAnnounceDelivery {
let delivery = job.delivery let delivery = job.delivery
if let delivery { if let delivery {
if delivery.mode == .announce { if delivery.mode == .announce {

View File

@ -25,7 +25,9 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
- Jobs persist under `~/.openclaw/cron/` so restarts dont lose schedules. - Jobs persist under `~/.openclaw/cron/` so restarts dont lose schedules.
- Two execution styles: - Two execution styles:
- **Main session**: enqueue a system event, then run on the next heartbeat. - **Main session**: enqueue a system event, then run on the next heartbeat.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none). - **Isolated**: run a dedicated agent turn in `cron:<jobId>` or a custom session, with delivery (announce by default or none).
- **Current session**: bind to the session where the cron is created (`sessionTarget: "current"`).
- **Custom session**: run in a persistent named session (`sessionTarget: "session:custom-id"`).
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`. - Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. - 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.
2. **Choose where it runs** 2. **Choose where it runs**
- `sessionTarget: "main"` → run during the next heartbeat with main context. - `sessionTarget: "main"` → run during the next heartbeat with main context.
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`. - `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
- `sessionTarget: "current"` → bind to the current session (resolved at creation time to `session:<sessionKey>`).
- `sessionTarget: "session:custom-id"` → run in a persistent named session that maintains context across runs.
Default behavior (unchanged):
- `systemEvent` payloads default to `main`
- `agentTurn` payloads default to `isolated`
To use current session binding, explicitly set `sessionTarget: "current"`.
3. **Choose the payload** 3. **Choose the payload**
- Main session → `payload.kind = "systemEvent"` - Main session → `payload.kind = "systemEvent"`
@ -147,12 +157,13 @@ See [Heartbeat](/gateway/heartbeat).
#### Isolated jobs (dedicated cron sessions) #### Isolated jobs (dedicated cron sessions)
Isolated jobs run a dedicated agent turn in session `cron:<jobId>`. Isolated jobs run a dedicated agent turn in session `cron:<jobId>` or a custom session.
Key behaviors: Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability. - Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over). - Each run starts a **fresh session id** (no prior conversation carry-over), unless using a custom session.
- Custom sessions (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). - Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
- `delivery.mode` chooses what happens: - `delivery.mode` chooses what happens:
- `announce`: deliver a summary to the target channel and post a brief summary to the main session. - `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:
} }
``` ```
Recurring job bound to current session (auto-resolved at creation):
```json
{
"name": "Daily standup",
"schedule": { "kind": "cron", "expr": "0 9 * * *" },
"sessionTarget": "current",
"payload": {
"kind": "agentTurn",
"message": "Summarize yesterday's progress."
}
}
```
Recurring job in a custom persistent session:
```json
{
"name": "Project monitor",
"schedule": { "kind": "every", "everyMs": 300000 },
"sessionTarget": "session:project-alpha-monitor",
"payload": {
"kind": "agentTurn",
"message": "Check project status and update the running log."
}
}
```
Notes: Notes:
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). - `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). - `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
- `everyMs` is milliseconds. - `everyMs` is milliseconds.
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. - `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:<custom-id>"`.
- `"current"` is resolved to `"session:<sessionKey>"` at creation time.
- Custom sessions (`session:xxx`) maintain persistent context across runs.
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), - Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
`delivery`. `delivery`.
- `wakeMode` defaults to `"now"` when omitted. - `wakeMode` defaults to `"now"` when omitted.

View File

@ -219,13 +219,13 @@ See [Lobster](/tools/lobster) for full usage and examples.
Both heartbeat and cron can interact with the main session, but differently: Both heartbeat and cron can interact with the main session, but differently:
| | Heartbeat | Cron (main) | Cron (isolated) | | | Heartbeat | Cron (main) | Cron (isolated) |
| ------- | ------------------------------- | ------------------------ | -------------------------- | | ------- | ------------------------------- | ------------------------ | ----------------------------------------------- |
| Session | Main | Main (via system event) | `cron:<jobId>` | | Session | Main | Main (via system event) | `cron:<jobId>` or custom session |
| History | Shared | Shared | Fresh each run | | History | Shared | Shared | Fresh each run (isolated) / Persistent (custom) |
| Context | Full | Full | None (starts clean) | | Context | Full | Full | None (isolated) / Cumulative (custom) |
| Model | Main session model | Main session model | Can override | | Model | Main session model | Main session model | Can override |
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | | Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
### When to use main session cron ### When to use main session cron

View File

@ -200,7 +200,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Legacy `group:<id>` keys are still recognized for migration. - Legacy `group:<id>` keys are still recognized for migration.
- Inbound contexts may still use `group:<id>`; the channel is inferred from `Provider` and normalized to the canonical `agent:<agentId>:<channel>:group:<id>` form. - Inbound contexts may still use `group:<id>`; the channel is inferred from `Provider` and normalized to the canonical `agent:<agentId>:<channel>:group:<id>` form.
- Other sources: - Other sources:
- Cron jobs: `cron:<job.id>` - Cron jobs: `cron:<job.id>` (isolated) or custom `session:<custom-id>` (persistent)
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook) - Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node runs: `node-<nodeId>` - Node runs: `node-<nodeId>`

View File

@ -28,7 +28,9 @@ x-i18n:
- 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。 - 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。
- 两种执行方式: - 两种执行方式:
- **主会话**:入队一个系统事件,然后在下一次心跳时运行。 - **主会话**:入队一个系统事件,然后在下一次心跳时运行。
- **隔离式**:在 `cron:<jobId>` 中运行专用智能体轮次,可投递摘要(默认 announce或不投递。 - **隔离式**:在 `cron:<jobId>` 或自定义会话中运行专用智能体轮次,可投递摘要(默认 announce或不投递。
- **当前会话**:绑定到创建定时任务时的会话 (`sessionTarget: "current"`)。
- **自定义会话**:在持久化的命名会话中运行 (`sessionTarget: "session:custom-id"`)。
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。 - 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。
## 快速开始(可操作) ## 快速开始(可操作)
@ -83,6 +85,14 @@ openclaw cron add \
2. **选择运行位置** 2. **选择运行位置**
- `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。 - `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用智能体轮次。 - `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用智能体轮次。
- `sessionTarget: "current"` → 绑定到当前会话(创建时解析为 `session:<sessionKey>`)。
- `sessionTarget: "session:custom-id"` → 在持久化的命名会话中运行,跨运行保持上下文。
默认行为(保持不变):
- `systemEvent` 负载默认使用 `main`
- `agentTurn` 负载默认使用 `isolated`
要使用当前会话绑定,需显式设置 `sessionTarget: "current"`
3. **选择负载** 3. **选择负载**
- 主会话 → `payload.kind = "systemEvent"` - 主会话 → `payload.kind = "systemEvent"`
@ -129,12 +139,13 @@ Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主
#### 隔离任务(专用定时会话) #### 隔离任务(专用定时会话)
隔离任务在会话 `cron:<jobId>` 中运行专用智能体轮次。 隔离任务在会话 `cron:<jobId>` 或自定义会话中运行专用智能体轮次。
关键行为: 关键行为:
- 提示以 `[cron:<jobId> <任务名称>]` 为前缀,便于追踪。 - 提示以 `[cron:<jobId> <任务名称>]` 为前缀,便于追踪。
- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。 - 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话),除非使用自定义会话。
- 自定义会话(`session:xxx`)可跨运行保持上下文,适用于如每日站会等需要基于前次摘要的工作流。
- 如果未指定 `delivery`隔离任务会默认以“announce”方式投递摘要。 - 如果未指定 `delivery`隔离任务会默认以“announce”方式投递摘要。
- `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。 - `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。

View File

@ -23,8 +23,7 @@ describe("renderDiffDocument", () => {
expect(rendered.html).toContain("data-openclaw-diff-root"); expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts"); expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js"); expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).not.toContain("/plugins/diffs/assets/viewer.js"); expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain('data-openclaw-diffs-ready="true"');
expect(rendered.imageHtml).toContain("max-width: 960px;"); expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;"); expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain("min-height: 100vh;"); expect(rendered.html).toContain("min-height: 100vh;");

View File

@ -241,14 +241,6 @@ function renderDiffCard(payload: DiffViewerPayload): string {
</section>`; </section>`;
} }
function renderStaticDiffCard(prerenderedHTML: string): string {
return `<section class="oc-diff-card">
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
<template shadowrootmode="open">${prerenderedHTML}</template>
</diffs-container>
</section>`;
}
function buildHtmlDocument(params: { function buildHtmlDocument(params: {
title: string; title: string;
bodyHtml: string; bodyHtml: string;
@ -257,7 +249,7 @@ function buildHtmlDocument(params: {
runtimeMode: "viewer" | "image"; runtimeMode: "viewer" | "image";
}): string { }): string {
return `<!doctype html> return `<!doctype html>
<html lang="en"${params.runtimeMode === "image" ? ' data-openclaw-diffs-ready="true"' : ""}> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -349,7 +341,7 @@ function buildHtmlDocument(params: {
${params.bodyHtml} ${params.bodyHtml}
</div> </div>
</main> </main>
${params.runtimeMode === "viewer" ? `<script type="module" src="${VIEWER_LOADER_PATH}"></script>` : ""} <script type="module" src="${VIEWER_LOADER_PATH}"></script>
</body> </body>
</html>`; </html>`;
} }
@ -360,16 +352,12 @@ type RenderedSection = {
}; };
function buildRenderedSection(params: { function buildRenderedSection(params: {
viewerPrerenderedHtml: string; viewerPayload: DiffViewerPayload;
imagePrerenderedHtml: string; imagePayload: DiffViewerPayload;
payload: Omit<DiffViewerPayload, "prerenderedHTML">;
}): RenderedSection { }): RenderedSection {
return { return {
viewer: renderDiffCard({ viewer: renderDiffCard(params.viewerPayload),
prerenderedHTML: params.viewerPrerenderedHtml, image: renderDiffCard(params.imagePayload),
...params.payload,
}),
image: renderStaticDiffCard(params.imagePrerenderedHtml),
}; };
} }
@ -401,21 +389,20 @@ async function renderBeforeAfterDiff(
}; };
const { viewerOptions, imageOptions } = buildRenderVariants(options); const { viewerOptions, imageOptions } = buildRenderVariants(options);
const [viewerResult, imageResult] = await Promise.all([ const [viewerResult, imageResult] = await Promise.all([
preloadMultiFileDiff({ preloadMultiFileDiffWithFallback({
oldFile, oldFile,
newFile, newFile,
options: viewerOptions, options: viewerOptions,
}), }),
preloadMultiFileDiff({ preloadMultiFileDiffWithFallback({
oldFile, oldFile,
newFile, newFile,
options: imageOptions, options: imageOptions,
}), }),
]); ]);
const section = buildRenderedSection({ const section = buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML, viewerPayload: {
imagePrerenderedHtml: imageResult.prerenderedHTML, prerenderedHTML: viewerResult.prerenderedHTML,
payload: {
oldFile: viewerResult.oldFile, oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile, newFile: viewerResult.newFile,
options: viewerOptions, options: viewerOptions,
@ -424,6 +411,16 @@ async function renderBeforeAfterDiff(
newFile: viewerResult.newFile, newFile: viewerResult.newFile,
}), }),
}, },
imagePayload: {
prerenderedHTML: imageResult.prerenderedHTML,
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
options: imageOptions,
langs: buildPayloadLanguages({
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
}),
},
}); });
return { return {
@ -456,24 +453,29 @@ async function renderPatchDiff(
const sections = await Promise.all( const sections = await Promise.all(
files.map(async (fileDiff) => { files.map(async (fileDiff) => {
const [viewerResult, imageResult] = await Promise.all([ const [viewerResult, imageResult] = await Promise.all([
preloadFileDiff({ preloadFileDiffWithFallback({
fileDiff, fileDiff,
options: viewerOptions, options: viewerOptions,
}), }),
preloadFileDiff({ preloadFileDiffWithFallback({
fileDiff, fileDiff,
options: imageOptions, options: imageOptions,
}), }),
]); ]);
return buildRenderedSection({ return buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML, viewerPayload: {
imagePrerenderedHtml: imageResult.prerenderedHTML, prerenderedHTML: viewerResult.prerenderedHTML,
payload: {
fileDiff: viewerResult.fileDiff, fileDiff: viewerResult.fileDiff,
options: viewerOptions, options: viewerOptions,
langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }), langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }),
}, },
imagePayload: {
prerenderedHTML: imageResult.prerenderedHTML,
fileDiff: imageResult.fileDiff,
options: imageOptions,
langs: buildPayloadLanguages({ fileDiff: imageResult.fileDiff }),
},
}); });
}), }),
); );
@ -514,3 +516,49 @@ export async function renderDiffDocument(
inputKind: input.kind, inputKind: input.kind,
}; };
} }
type PreloadedFileDiffResult = Awaited<ReturnType<typeof preloadFileDiff>>;
type PreloadedMultiFileDiffResult = Awaited<ReturnType<typeof preloadMultiFileDiff>>;
function shouldFallbackToClientHydration(error: unknown): boolean {
return (
error instanceof TypeError &&
error.message.includes('needs an import attribute of "type: json"')
);
}
async function preloadFileDiffWithFallback(params: {
fileDiff: FileDiffMetadata;
options: DiffViewerOptions;
}): Promise<PreloadedFileDiffResult> {
try {
return await preloadFileDiff(params);
} catch (error) {
if (!shouldFallbackToClientHydration(error)) {
throw error;
}
return {
fileDiff: params.fileDiff,
prerenderedHTML: "",
};
}
}
async function preloadMultiFileDiffWithFallback(params: {
oldFile: FileContents;
newFile: FileContents;
options: DiffViewerOptions;
}): Promise<PreloadedMultiFileDiffResult> {
try {
return await preloadMultiFileDiff(params);
} catch (error) {
if (!shouldFallbackToClientHydration(error)) {
throw error;
}
return {
oldFile: params.oldFile,
newFile: params.newFile,
prerenderedHTML: "",
};
}
}

View File

@ -57,7 +57,7 @@ describe("diffs tool", () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup"); const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = createPngScreenshotter({ const screenshotter = createPngScreenshotter({
assertHtml: (html) => { assertHtml: (html) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); expect(html).toContain("/plugins/diffs/assets/viewer.js");
}, },
assertImage: (image) => { assertImage: (image) => {
expect(image).toMatchObject({ expect(image).toMatchObject({
@ -332,13 +332,13 @@ describe("diffs tool", () => {
const html = await store.readHtml(id); const html = await store.readHtml(id);
expect(html).toContain('body data-theme="light"'); expect(html).toContain('body data-theme="light"');
expect(html).toContain("--diffs-font-size: 17px;"); expect(html).toContain("--diffs-font-size: 17px;");
expect(html).toContain('--diffs-font-family: "JetBrains Mono"'); expect(html).toContain("JetBrains Mono");
}); });
it("prefers explicit tool params over configured defaults", async () => { it("prefers explicit tool params over configured defaults", async () => {
const screenshotter = createPngScreenshotter({ const screenshotter = createPngScreenshotter({
assertHtml: (html) => { assertHtml: (html) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); expect(html).toContain("/plugins/diffs/assets/viewer.js");
}, },
assertImage: (image) => { assertImage: (image) => {
expect(image).toMatchObject({ expect(image).toMatchObject({

View File

@ -230,11 +230,22 @@ JOB SCHEMA (for add action):
"name": "string (optional)", "name": "string (optional)",
"schedule": { ... }, // Required: when to run "schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute "payload": { ... }, // Required: what to execute
"delivery": { ... }, // Optional: announce summary or webhook POST "delivery": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST
"sessionTarget": "main" | "isolated", // Required "sessionTarget": "main" | "isolated" | "current" | "session:<custom-id>", // Optional, defaults based on context
"enabled": true | false // Optional, default true "enabled": true | false // Optional, default true
} }
SESSION TARGET OPTIONS:
- "main": Run in the main session (requires payload.kind="systemEvent")
- "isolated": Run in an ephemeral isolated session (requires payload.kind="agentTurn")
- "current": Bind to the current session where the cron is created (resolved at creation time)
- "session:<custom-id>": Run in a persistent named session (e.g., "session:project-alpha-daily")
DEFAULT BEHAVIOR (unchanged for backward compatibility):
- payload.kind="systemEvent" defaults to "main"
- payload.kind="agentTurn" defaults to "isolated"
To use current session binding, explicitly set sessionTarget="current".
SCHEDULE TYPES (schedule.kind): SCHEDULE TYPES (schedule.kind):
- "at": One-shot at absolute time - "at": One-shot at absolute time
{ "kind": "at", "at": "<ISO-8601 timestamp>" } { "kind": "at", "at": "<ISO-8601 timestamp>" }
@ -260,9 +271,9 @@ DELIVERY (top-level):
CRITICAL CONSTRAINTS: CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn" - sessionTarget="isolated" | "current" | "session:xxx" REQUIRES payload.kind="agentTurn"
- For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL. - For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL.
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. Default: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.
WAKE MODES (for wake action): WAKE MODES (for wake action):
- "next-heartbeat" (default): Wake on next heartbeat - "next-heartbeat" (default): Wake on next heartbeat
@ -346,7 +357,10 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
if (!params.job || typeof params.job !== "object") { if (!params.job || typeof params.job !== "object") {
throw new Error("job required"); throw new Error("job required");
} }
const job = normalizeCronJobCreate(params.job) ?? params.job; const job =
normalizeCronJobCreate(params.job, {
sessionContext: { sessionKey: opts?.agentSessionKey },
}) ?? params.job;
if (job && typeof job === "object") { if (job && typeof job === "object") {
const cfg = loadConfig(); const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg); const { mainKey, alias } = resolveMainSessionAlias(cfg);

View File

@ -194,8 +194,13 @@ export function registerCronAddCommand(cron: Command) {
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main"; const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
const sessionTarget = const sessionTarget =
sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget; sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget;
if (sessionTarget !== "main" && sessionTarget !== "isolated") { const isCustomSessionTarget =
throw new Error("--session must be main or isolated"); sessionTarget.toLowerCase().startsWith("session:") &&
sessionTarget.slice(8).trim().length > 0;
const isIsolatedLikeSessionTarget =
sessionTarget === "isolated" || sessionTarget === "current" || isCustomSessionTarget;
if (sessionTarget !== "main" && !isIsolatedLikeSessionTarget) {
throw new Error("--session must be main, isolated, current, or session:<id>");
} }
if (opts.deleteAfterRun && opts.keepAfterRun) { if (opts.deleteAfterRun && opts.keepAfterRun) {
@ -205,14 +210,14 @@ export function registerCronAddCommand(cron: Command) {
if (sessionTarget === "main" && payload.kind !== "systemEvent") { if (sessionTarget === "main" && payload.kind !== "systemEvent") {
throw new Error("Main jobs require --system-event (systemEvent)."); throw new Error("Main jobs require --system-event (systemEvent).");
} }
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") { if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") {
throw new Error("Isolated jobs require --message (agentTurn)."); throw new Error("Isolated/current/custom-session jobs require --message (agentTurn).");
} }
if ( if (
(opts.announce || typeof opts.deliver === "boolean") && (opts.announce || typeof opts.deliver === "boolean") &&
(sessionTarget !== "isolated" || payload.kind !== "agentTurn") (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
) { ) {
throw new Error("--announce/--no-deliver require --session isolated."); throw new Error("--announce/--no-deliver require a non-main agentTurn session target.");
} }
const accountId = const accountId =
@ -220,12 +225,12 @@ export function registerCronAddCommand(cron: Command) {
? opts.account.trim() ? opts.account.trim()
: undefined; : undefined;
if (accountId && (sessionTarget !== "isolated" || payload.kind !== "agentTurn")) { if (accountId && (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")) {
throw new Error("--account requires an isolated agentTurn job with delivery."); throw new Error("--account requires a non-main agentTurn job with delivery.");
} }
const deliveryMode = const deliveryMode =
sessionTarget === "isolated" && payload.kind === "agentTurn" isIsolatedLikeSessionTarget && payload.kind === "agentTurn"
? hasAnnounce ? hasAnnounce
? "announce" ? "announce"
: hasNoDeliver : hasNoDeliver

View File

@ -247,9 +247,9 @@ export function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
})(); })();
const coloredTarget = const coloredTarget =
job.sessionTarget === "isolated" job.sessionTarget === "main"
? colorize(rich, theme.accentBright, targetLabel) ? colorize(rich, theme.accent, targetLabel)
: colorize(rich, theme.accent, targetLabel); : colorize(rich, theme.accentBright, targetLabel);
const coloredAgent = job.agentId const coloredAgent = job.agentId
? colorize(rich, theme.info, agentLabel) ? colorize(rich, theme.info, agentLabel)
: colorize(rich, theme.muted, agentLabel); : colorize(rich, theme.muted, agentLabel);

View File

@ -414,6 +414,42 @@ describe("normalizeCronJobCreate", () => {
expect(delivery.mode).toBeUndefined(); expect(delivery.mode).toBeUndefined();
expect(delivery.to).toBe("123"); expect(delivery.to).toBe("123");
}); });
it("resolves current sessionTarget to a persistent session when context is available", () => {
const normalized = normalizeCronJobCreate(
{
name: "current-session",
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "current",
payload: { kind: "agentTurn", message: "hello" },
},
{ sessionContext: { sessionKey: "agent:main:discord:group:ops" } },
) as unknown as Record<string, unknown>;
expect(normalized.sessionTarget).toBe("session:agent:main:discord:group:ops");
});
it("falls back current sessionTarget to isolated without context", () => {
const normalized = normalizeCronJobCreate({
name: "current-without-context",
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "current",
payload: { kind: "agentTurn", message: "hello" },
}) as unknown as Record<string, unknown>;
expect(normalized.sessionTarget).toBe("isolated");
});
it("preserves custom session ids with a session: prefix", () => {
const normalized = normalizeCronJobCreate({
name: "custom-session",
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "session:MySessionID",
payload: { kind: "agentTurn", message: "hello" },
}) as unknown as Record<string, unknown>;
expect(normalized.sessionTarget).toBe("session:MySessionID");
});
}); });
describe("normalizeCronJobPatch", () => { describe("normalizeCronJobPatch", () => {

View File

@ -11,6 +11,8 @@ type UnknownRecord = Record<string, unknown>;
type NormalizeOptions = { type NormalizeOptions = {
applyDefaults?: boolean; applyDefaults?: boolean;
/** Session context for resolving "current" sessionTarget or auto-binding when not specified */
sessionContext?: { sessionKey?: string };
}; };
const DEFAULT_OPTIONS: NormalizeOptions = { const DEFAULT_OPTIONS: NormalizeOptions = {
@ -218,9 +220,17 @@ function normalizeSessionTarget(raw: unknown) {
if (typeof raw !== "string") { if (typeof raw !== "string") {
return undefined; return undefined;
} }
const trimmed = raw.trim().toLowerCase(); const trimmed = raw.trim();
if (trimmed === "main" || trimmed === "isolated") { const lower = trimmed.toLowerCase();
return trimmed; if (lower === "main" || lower === "isolated" || lower === "current") {
return lower;
}
// Support custom session IDs with "session:" prefix
if (lower.startsWith("session:")) {
const sessionId = trimmed.slice(8).trim();
if (sessionId) {
return `session:${sessionId}`;
}
} }
return undefined; return undefined;
} }
@ -431,10 +441,37 @@ export function normalizeCronJobInput(
} }
if (!next.sessionTarget && isRecord(next.payload)) { if (!next.sessionTarget && isRecord(next.payload)) {
const kind = typeof next.payload.kind === "string" ? next.payload.kind : ""; const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
// Keep default behavior unchanged for backward compatibility:
// - systemEvent defaults to "main"
// - agentTurn defaults to "isolated" (NOT "current", to avoid token accumulation)
// Users must explicitly specify "current" or "session:xxx" for custom session binding
if (kind === "systemEvent") { if (kind === "systemEvent") {
next.sessionTarget = "main"; next.sessionTarget = "main";
} else if (kind === "agentTurn") {
next.sessionTarget = "isolated";
} }
if (kind === "agentTurn") { }
// Resolve "current" sessionTarget to the actual sessionKey from context
if (next.sessionTarget === "current") {
if (options.sessionContext?.sessionKey) {
const sessionKey = options.sessionContext.sessionKey.trim();
if (sessionKey) {
// Store as session:customId format for persistence
next.sessionTarget = `session:${sessionKey}`;
}
}
// If "current" wasn't resolved, fall back to "isolated" behavior
// This handles CLI/headless usage where no session context exists
if (next.sessionTarget === "current") {
next.sessionTarget = "isolated";
}
}
if (next.sessionTarget === "current") {
const sessionKey = options.sessionContext?.sessionKey?.trim();
if (sessionKey) {
next.sessionTarget = `session:${sessionKey}`;
} else {
next.sessionTarget = "isolated"; next.sessionTarget = "isolated";
} }
} }
@ -462,8 +499,12 @@ export function normalizeCronJobInput(
const payload = isRecord(next.payload) ? next.payload : null; const payload = isRecord(next.payload) ? next.payload : null;
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : ""; const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : ""; const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
// Support "isolated", custom session IDs (session:xxx), and resolved "current" as isolated-like targets
const isIsolatedAgentTurn = const isIsolatedAgentTurn =
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); sessionTarget === "isolated" ||
sessionTarget === "current" ||
sessionTarget.startsWith("session:") ||
(sessionTarget === "" && payloadKind === "agentTurn");
const hasDelivery = "delivery" in next && next.delivery !== undefined; const hasDelivery = "delivery" in next && next.delivery !== undefined;
const normalizedLegacy = normalizeLegacyDeliveryInput({ const normalizedLegacy = normalizeLegacyDeliveryInput({
delivery: isRecord(next.delivery) ? next.delivery : null, delivery: isRecord(next.delivery) ? next.delivery : null,
@ -487,7 +528,7 @@ export function normalizeCronJobInput(
export function normalizeCronJobCreate( export function normalizeCronJobCreate(
raw: unknown, raw: unknown,
options?: NormalizeOptions, options?: Omit<NormalizeOptions, "applyDefaults">,
): CronJobCreate | null { ): CronJobCreate | null {
return normalizeCronJobInput(raw, { return normalizeCronJobInput(raw, {
applyDefaults: true, applyDefaults: true,

View File

@ -103,6 +103,29 @@ describe("applyJobPatch", () => {
}); });
}); });
it("maps legacy payload delivery updates for custom session targets", () => {
const job = createIsolatedAgentTurnJob(
"job-custom-session",
{
mode: "announce",
channel: "telegram",
to: "123",
},
{ sessionTarget: "session:project-alpha" },
);
applyJobPatch(job, {
payload: { kind: "agentTurn", to: "555" },
});
expect(job.delivery).toEqual({
mode: "announce",
channel: "telegram",
to: "555",
bestEffort: undefined,
});
});
it("treats legacy payload targets as announce requests", () => { it("treats legacy payload targets as announce requests", () => {
const job = createIsolatedAgentTurnJob("job-3", { const job = createIsolatedAgentTurnJob("job-3", {
mode: "none", mode: "none",

View File

@ -759,7 +759,7 @@ describe("CronService", () => {
wakeMode: "next-heartbeat", wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "nope" }, payload: { kind: "systemEvent", text: "nope" },
}), }),
).rejects.toThrow(/isolated cron jobs require/); ).rejects.toThrow(/isolated.*cron jobs require/);
cron.stop(); cron.stop();
await store.cleanup(); await store.cleanup();

View File

@ -72,6 +72,39 @@ function createLegacyIsolatedAgentTurnJob(
} }
describe("CronService store migrations", () => { describe("CronService store migrations", () => {
it("treats stored current session targets as isolated-like for default delivery migration", async () => {
const { store, cron } = await startCronWithStoredJobs([
createLegacyIsolatedAgentTurnJob({
id: "stored-current-job",
name: "stored current",
sessionTarget: "current",
}),
]);
const job = await listJobById(cron, "stored-current-job");
expect(job).toBeDefined();
expect(job?.sessionTarget).toBe("isolated");
expect(job?.delivery).toEqual({ mode: "announce" });
await stopCronAndCleanup(cron, store);
});
it("preserves stored custom session targets", async () => {
const { store, cron } = await startCronWithStoredJobs([
createLegacyIsolatedAgentTurnJob({
id: "custom-session-job",
name: "custom session",
sessionTarget: "session:ProjectAlpha",
}),
]);
const job = await listJobById(cron, "custom-session-job");
expect(job?.sessionTarget).toBe("session:ProjectAlpha");
expect(job?.delivery).toEqual({ mode: "announce" });
await stopCronAndCleanup(cron, store);
});
it("migrates legacy top-level agentTurn fields and initializes missing state", async () => { it("migrates legacy top-level agentTurn fields and initializes missing state", async () => {
const { store, cron } = await startCronWithStoredJobs([ const { store, cron } = await startCronWithStoredJobs([
createLegacyIsolatedAgentTurnJob({ createLegacyIsolatedAgentTurnJob({

View File

@ -133,6 +133,24 @@ describe("cron store migration", () => {
expect(schedule.at).toBe(new Date(atMs).toISOString()); expect(schedule.at).toBe(new Date(atMs).toISOString());
}); });
it("preserves stored custom session targets", async () => {
const migrated = await migrateLegacyJob(
makeLegacyJob({
id: "job-custom-session",
name: "Custom session",
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
sessionTarget: "session:ProjectAlpha",
payload: {
kind: "agentTurn",
message: "hello",
},
}),
);
expect(migrated.sessionTarget).toBe("session:ProjectAlpha");
expect(migrated.delivery).toEqual({ mode: "announce" });
});
it("adds anchorMs to legacy every schedules", async () => { it("adds anchorMs to legacy every schedules", async () => {
const createdAtMs = 1_700_000_000_000; const createdAtMs = 1_700_000_000_000;
const migrated = await migrateLegacyJob( const migrated = await migrateLegacyJob(

View File

@ -132,11 +132,15 @@ function resolveEveryAnchorMs(params: {
} }
export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "payload">) { export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "payload">) {
const isIsolatedLike =
job.sessionTarget === "isolated" ||
job.sessionTarget === "current" ||
job.sessionTarget.startsWith("session:");
if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") { if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") {
throw new Error('main cron jobs require payload.kind="systemEvent"'); throw new Error('main cron jobs require payload.kind="systemEvent"');
} }
if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") { if (isIsolatedLike && job.payload.kind !== "agentTurn") {
throw new Error('isolated cron jobs require payload.kind="agentTurn"'); throw new Error('isolated/current/session cron jobs require payload.kind="agentTurn"');
} }
} }
@ -181,6 +185,7 @@ function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">)
if (!job.delivery || job.delivery.mode === "none") { if (!job.delivery || job.delivery.mode === "none") {
return; return;
} }
// Webhook delivery is allowed for any session target
if (job.delivery.mode === "webhook") { if (job.delivery.mode === "webhook") {
const target = normalizeHttpWebhookUrl(job.delivery.to); const target = normalizeHttpWebhookUrl(job.delivery.to);
if (!target) { if (!target) {
@ -189,7 +194,11 @@ function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">)
job.delivery.to = target; job.delivery.to = target;
return; return;
} }
if (job.sessionTarget !== "isolated") { const isIsolatedLike =
job.sessionTarget === "isolated" ||
job.sessionTarget === "current" ||
job.sessionTarget.startsWith("session:");
if (!isIsolatedLike) {
throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"'); throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"');
} }
if (job.delivery.channel === "telegram") { if (job.delivery.channel === "telegram") {
@ -606,11 +615,11 @@ export function applyJobPatch(
if (!patch.delivery && patch.payload?.kind === "agentTurn") { if (!patch.delivery && patch.payload?.kind === "agentTurn") {
// Back-compat: legacy clients still update delivery via payload fields. // Back-compat: legacy clients still update delivery via payload fields.
const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload); const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload);
if ( const isIsolatedLike =
legacyDeliveryPatch && job.sessionTarget === "isolated" ||
job.sessionTarget === "isolated" && job.sessionTarget === "current" ||
job.payload.kind === "agentTurn" job.sessionTarget.startsWith("session:");
) { if (legacyDeliveryPatch && isIsolatedLike && job.payload.kind === "agentTurn") {
job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch); job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch);
} }
} }

View File

@ -451,11 +451,25 @@ export function normalizeStoredCronJobs(
const payloadKind = const payloadKind =
payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : "";
const normalizedSessionTarget = const rawSessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim() : "";
typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; const loweredSessionTarget = rawSessionTarget.toLowerCase();
if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { if (loweredSessionTarget === "main" || loweredSessionTarget === "isolated") {
if (raw.sessionTarget !== normalizedSessionTarget) { if (raw.sessionTarget !== loweredSessionTarget) {
raw.sessionTarget = normalizedSessionTarget; raw.sessionTarget = loweredSessionTarget;
mutated = true;
}
} else if (loweredSessionTarget.startsWith("session:")) {
const customSessionId = rawSessionTarget.slice(8).trim();
if (customSessionId) {
const normalizedSessionTarget = `session:${customSessionId}`;
if (raw.sessionTarget !== normalizedSessionTarget) {
raw.sessionTarget = normalizedSessionTarget;
mutated = true;
}
}
} else if (loweredSessionTarget === "current") {
if (raw.sessionTarget !== "isolated") {
raw.sessionTarget = "isolated";
mutated = true; mutated = true;
} }
} else { } else {
@ -469,7 +483,10 @@ export function normalizeStoredCronJobs(
const sessionTarget = const sessionTarget =
typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : "";
const isIsolatedAgentTurn = const isIsolatedAgentTurn =
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); sessionTarget === "isolated" ||
sessionTarget === "current" ||
sessionTarget.startsWith("session:") ||
(sessionTarget === "" && payloadKind === "agentTurn");
const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery);
const normalizedLegacy = normalizeLegacyDeliveryInput({ const normalizedLegacy = normalizeLegacyDeliveryInput({
delivery: hasDelivery ? (delivery as Record<string, unknown>) : null, delivery: hasDelivery ? (delivery as Record<string, unknown>) : null,

View File

@ -13,7 +13,7 @@ export type CronSchedule =
staggerMs?: number; staggerMs?: number;
}; };
export type CronSessionTarget = "main" | "isolated"; export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`;
export type CronWakeMode = "next-heartbeat" | "now"; export type CronWakeMode = "next-heartbeat" | "now";
export type CronMessageChannel = ChannelId | "last"; export type CronMessageChannel = ChannelId | "last";

View File

@ -21,6 +21,29 @@ describe("cron protocol validators", () => {
expect(validateCronAddParams(minimalAddParams)).toBe(true); expect(validateCronAddParams(minimalAddParams)).toBe(true);
}); });
it("accepts current and custom session targets", () => {
expect(
validateCronAddParams({
...minimalAddParams,
sessionTarget: "current",
payload: { kind: "agentTurn", message: "tick" },
}),
).toBe(true);
expect(
validateCronAddParams({
...minimalAddParams,
sessionTarget: "session:project-alpha",
payload: { kind: "agentTurn", message: "tick" },
}),
).toBe(true);
expect(
validateCronUpdateParams({
id: "job-1",
patch: { sessionTarget: "session:project-alpha" },
}),
).toBe(true);
});
it("rejects add params when required scheduling fields are missing", () => { it("rejects add params when required scheduling fields are missing", () => {
const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams; const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams;
expect(validateCronAddParams(withoutWakeMode)).toBe(false); expect(validateCronAddParams(withoutWakeMode)).toBe(false);

View File

@ -21,7 +21,12 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) {
); );
} }
const CronSessionTargetSchema = Type.Union([Type.Literal("main"), Type.Literal("isolated")]); const CronSessionTargetSchema = Type.Union([
Type.Literal("main"),
Type.Literal("isolated"),
Type.Literal("current"),
Type.String({ pattern: "^session:.+" }),
]);
const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]); const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]);
const CronRunStatusSchema = Type.Union([ const CronRunStatusSchema = Type.Union([
Type.Literal("ok"), Type.Literal("ok"),

View File

@ -5,10 +5,19 @@ import type { CliDeps } from "../cli/deps.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { SsrFBlockedError } from "../infra/net/ssrf.js";
const enqueueSystemEventMock = vi.fn(); const {
const requestHeartbeatNowMock = vi.fn(); enqueueSystemEventMock,
const loadConfigMock = vi.fn(); requestHeartbeatNowMock,
const fetchWithSsrFGuardMock = vi.fn(); loadConfigMock,
fetchWithSsrFGuardMock,
runCronIsolatedAgentTurnMock,
} = vi.hoisted(() => ({
enqueueSystemEventMock: vi.fn(),
requestHeartbeatNowMock: vi.fn(),
loadConfigMock: vi.fn(),
fetchWithSsrFGuardMock: vi.fn(),
runCronIsolatedAgentTurnMock: vi.fn(async () => ({ status: "ok" as const, summary: "ok" })),
}));
function enqueueSystemEvent(...args: unknown[]) { function enqueueSystemEvent(...args: unknown[]) {
return enqueueSystemEventMock(...args); return enqueueSystemEventMock(...args);
@ -35,7 +44,11 @@ vi.mock("../config/config.js", async () => {
}); });
vi.mock("../infra/net/fetch-guard.js", () => ({ vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
vi.mock("../cron/isolated-agent.js", () => ({
runCronIsolatedAgentTurn: runCronIsolatedAgentTurnMock,
})); }));
import { buildGatewayCronService } from "./server-cron.js"; import { buildGatewayCronService } from "./server-cron.js";
@ -58,6 +71,7 @@ describe("buildGatewayCronService", () => {
requestHeartbeatNowMock.mockClear(); requestHeartbeatNowMock.mockClear();
loadConfigMock.mockClear(); loadConfigMock.mockClear();
fetchWithSsrFGuardMock.mockClear(); fetchWithSsrFGuardMock.mockClear();
runCronIsolatedAgentTurnMock.mockClear();
}); });
it("routes main-target jobs to the scoped session for enqueue + wake", async () => { it("routes main-target jobs to the scoped session for enqueue + wake", async () => {
@ -142,4 +156,44 @@ describe("buildGatewayCronService", () => {
state.cron.stop(); state.cron.stop();
} }
}); });
it("passes custom session targets through to isolated cron runs", async () => {
const tmpDir = path.join(os.tmpdir(), `server-cron-custom-session-${Date.now()}`);
const cfg = {
session: {
mainKey: "main",
},
cron: {
store: path.join(tmpDir, "cron.json"),
},
} as OpenClawConfig;
loadConfigMock.mockReturnValue(cfg);
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const job = await state.cron.add({
name: "custom-session",
enabled: true,
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "session:project-alpha-monitor",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
});
await state.cron.run(job.id, "force");
expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
job: expect.objectContaining({ id: job.id }),
sessionKey: "project-alpha-monitor",
}),
);
} finally {
state.cron.stop();
}
});
}); });

View File

@ -284,6 +284,13 @@ export function buildGatewayCronService(params: {
}, },
runIsolatedAgentJob: async ({ job, message, abortSignal }) => { runIsolatedAgentJob: async ({ job, message, abortSignal }) => {
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId); const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
let sessionKey = `cron:${job.id}`;
if (job.sessionTarget.startsWith("session:")) {
const customSessionId = job.sessionTarget.slice(8).trim();
if (customSessionId) {
sessionKey = customSessionId;
}
}
return await runCronIsolatedAgentTurn({ return await runCronIsolatedAgentTurn({
cfg: runtimeConfig, cfg: runtimeConfig,
deps: params.deps, deps: params.deps,
@ -291,7 +298,7 @@ export function buildGatewayCronService(params: {
message, message,
abortSignal, abortSignal,
agentId, agentId,
sessionKey: `cron:${job.id}`, sessionKey,
lane: "cron", lane: "cron",
}); });
}, },

View File

@ -89,7 +89,14 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(true, status, undefined); respond(true, status, undefined);
}, },
"cron.add": async ({ params, respond, context }) => { "cron.add": async ({ params, respond, context }) => {
const normalized = normalizeCronJobCreate(params) ?? params; const sessionKey =
typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string"
? (params as { sessionKey: string }).sessionKey
: undefined;
const normalized =
normalizeCronJobCreate(params, {
sessionContext: { sessionKey },
}) ?? params;
if (!validateCronAddParams(normalized)) { if (!validateCronAddParams(normalized)) {
respond( respond(
false, false,

View File

@ -84,7 +84,7 @@ export type CronModelSuggestionsState = {
export function supportsAnnounceDelivery( export function supportsAnnounceDelivery(
form: Pick<CronFormState, "sessionTarget" | "payloadKind">, form: Pick<CronFormState, "sessionTarget" | "payloadKind">,
) { ) {
return form.sessionTarget === "isolated" && form.payloadKind === "agentTurn"; return form.sessionTarget !== "main" && form.payloadKind === "agentTurn";
} }
export function normalizeCronFormState(form: CronFormState): CronFormState { export function normalizeCronFormState(form: CronFormState): CronFormState {

View File

@ -427,7 +427,7 @@ export type CronSchedule =
| { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number }; | { kind: "cron"; expr: string; tz?: string; staggerMs?: number };
export type CronSessionTarget = "main" | "isolated"; export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`;
export type CronWakeMode = "next-heartbeat" | "now"; export type CronWakeMode = "next-heartbeat" | "now";
export type CronPayload = export type CronPayload =

View File

@ -33,7 +33,7 @@ export type CronFormState = {
scheduleExact: boolean; scheduleExact: boolean;
staggerAmount: string; staggerAmount: string;
staggerUnit: "seconds" | "minutes"; staggerUnit: "seconds" | "minutes";
sessionTarget: "main" | "isolated"; sessionTarget: "main" | "isolated" | "current" | `session:${string}`;
wakeMode: "next-heartbeat" | "now"; wakeMode: "next-heartbeat" | "now";
payloadKind: "systemEvent" | "agentTurn"; payloadKind: "systemEvent" | "agentTurn";
payloadText: string; payloadText: string;

View File

@ -374,7 +374,7 @@ export function renderCron(props: CronProps) {
const statusSummary = summarizeSelection(selectedStatusLabels, t("cron.runs.allStatuses")); const statusSummary = summarizeSelection(selectedStatusLabels, t("cron.runs.allStatuses"));
const deliverySummary = summarizeSelection(selectedDeliveryLabels, t("cron.runs.allDelivery")); const deliverySummary = summarizeSelection(selectedDeliveryLabels, t("cron.runs.allDelivery"));
const supportsAnnounce = const supportsAnnounce =
props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; props.form.sessionTarget !== "main" && props.form.payloadKind === "agentTurn";
const selectedDeliveryMode = const selectedDeliveryMode =
props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode;
const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode);