mirror of https://github.com/openclaw/openclaw.git
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:
parent
61d171ab0b
commit
e7d9648fba
|
|
@ -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.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,14 @@ extension CronJobEditor {
|
|||
self.agentId = job.agentId ?? ""
|
||||
self.enabled = job.enabled
|
||||
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
|
||||
|
||||
switch job.schedule {
|
||||
|
|
@ -51,7 +58,7 @@ extension CronJobEditor {
|
|||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = delivery.to ?? ""
|
||||
self.bestEffortDeliver = delivery.bestEffort ?? false
|
||||
} else if self.sessionTarget == .isolated {
|
||||
} else if self.isIsolatedLikeSessionTarget {
|
||||
self.deliveryMode = .announce
|
||||
}
|
||||
}
|
||||
|
|
@ -80,7 +87,7 @@ extension CronJobEditor {
|
|||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"sessionTarget": self.effectiveSessionTargetRaw,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
|
|
@ -92,7 +99,7 @@ extension CronJobEditor {
|
|||
root["agentId"] = NSNull()
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
if self.isIsolatedLikeSessionTarget {
|
||||
root["delivery"] = self.buildDelivery()
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +167,7 @@ extension CronJobEditor {
|
|||
}
|
||||
|
||||
func buildSelectedPayload() throws -> [String: Any] {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.trimmed(self.systemEventText)
|
||||
|
|
@ -171,7 +178,7 @@ extension CronJobEditor {
|
|||
}
|
||||
|
||||
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(
|
||||
domain: "Cron",
|
||||
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(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
|
|
@ -257,6 +264,17 @@ extension CronJobEditor {
|
|||
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 {
|
||||
DurationFormattingSupport.conciseDuration(ms: ms)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ struct CronJobEditor: View {
|
|||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||
static let sessionTargetNote =
|
||||
"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 =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
|
|
@ -29,6 +29,7 @@ struct CronJobEditor: View {
|
|||
@State var agentId: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var preservedSessionTargetRaw: String?
|
||||
@State var wakeMode: CronWakeMode = .now
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
|
|
@ -117,6 +118,7 @@ struct CronJobEditor: View {
|
|||
Picker("", selection: self.$sessionTarget) {
|
||||
Text("main").tag(CronSessionTarget.main)
|
||||
Text("isolated").tag(CronSessionTarget.isolated)
|
||||
Text("current").tag(CronSessionTarget.current)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
|
@ -209,7 +211,7 @@ struct CronJobEditor: View {
|
|||
|
||||
GroupBox("Payload") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.sessionTarget == .isolated {
|
||||
if self.isIsolatedLikeSessionTarget {
|
||||
Text(Self.isolatedPayloadNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
|
@ -289,8 +291,11 @@ struct CronJobEditor: View {
|
|||
self.sessionTarget = .isolated
|
||||
}
|
||||
}
|
||||
.onChange(of: self.sessionTarget) { _, newValue in
|
||||
if newValue == .isolated {
|
||||
.onChange(of: self.sessionTarget) { oldValue, newValue in
|
||||
if oldValue != newValue {
|
||||
self.preservedSessionTargetRaw = nil
|
||||
}
|
||||
if newValue != .main {
|
||||
self.payloadKind = .agentTurn
|
||||
} else if newValue == .main, self.payloadKind == .agentTurn {
|
||||
self.payloadKind = .systemEvent
|
||||
|
|
|
|||
|
|
@ -3,12 +3,39 @@ import Foundation
|
|||
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
|
||||
case main
|
||||
case isolated
|
||||
case current
|
||||
|
||||
var id: String {
|
||||
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 {
|
||||
case now
|
||||
case nextHeartbeat = "next-heartbeat"
|
||||
|
|
@ -204,12 +231,69 @@ struct CronJob: Identifiable, Codable, Equatable {
|
|||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
let sessionTarget: CronSessionTarget
|
||||
private let sessionTargetRaw: String
|
||||
let wakeMode: CronWakeMode
|
||||
let payload: CronPayload
|
||||
let delivery: CronDelivery?
|
||||
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 {
|
||||
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Untitled job" : trimmed
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ extension CronSettings {
|
|||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
StatusPill(text: "agent \(agentId)", tint: .secondary)
|
||||
|
|
@ -34,9 +34,9 @@ extension CronSettings {
|
|||
@ViewBuilder
|
||||
func jobContextMenu(_ job: CronJob) -> some View {
|
||||
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") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
|
|
@ -75,9 +75,9 @@ extension CronSettings {
|
|||
.labelsHidden()
|
||||
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
if job.sessionTarget == .isolated {
|
||||
if let transcriptSessionKey = job.transcriptSessionKey {
|
||||
Button("Transcript") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ extension CronSettings {
|
|||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
LabeledContent("Agent") { Text(agentId) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Session") { Text(job.sessionTargetDisplayValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
if let date = job.nextRunDate {
|
||||
|
|
@ -224,7 +224,7 @@ extension CronSettings {
|
|||
HStack(spacing: 8) {
|
||||
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if job.sessionTarget == .isolated {
|
||||
if job.supportsAnnounceDelivery {
|
||||
let delivery = job.delivery
|
||||
if let delivery {
|
||||
if delivery.mode == .announce {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
|||
- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules.
|
||||
- Two execution styles:
|
||||
- **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”.
|
||||
- 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.
|
||||
|
|
@ -86,6 +88,14 @@ Think of a cron job as: **when** to run + **what** to do.
|
|||
2. **Choose where it runs**
|
||||
- `sessionTarget: "main"` → run during the next heartbeat with main context.
|
||||
- `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**
|
||||
- Main session → `payload.kind = "systemEvent"`
|
||||
|
|
@ -147,12 +157,13 @@ See [Heartbeat](/gateway/heartbeat).
|
|||
|
||||
#### 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:
|
||||
|
||||
- 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"`).
|
||||
- `delivery.mode` chooses what happens:
|
||||
- `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:
|
||||
|
||||
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
|
||||
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
|
||||
- `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`),
|
||||
`delivery`.
|
||||
- `wakeMode` defaults to `"now"` when omitted.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||
| ------- | ------------------------------- | ------------------------ | -------------------------- |
|
||||
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
||||
| History | Shared | Shared | Fresh each run |
|
||||
| Context | Full | Full | None (starts clean) |
|
||||
| Model | Main session model | Main session model | Can override |
|
||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
|
||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||
| ------- | ------------------------------- | ------------------------ | ----------------------------------------------- |
|
||||
| Session | Main | Main (via system event) | `cron:<jobId>` or custom session |
|
||||
| History | Shared | Shared | Fresh each run (isolated) / Persistent (custom) |
|
||||
| Context | Full | Full | None (isolated) / Cumulative (custom) |
|
||||
| Model | Main session model | Main session model | Can override |
|
||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
|
||||
|
||||
### When to use main session cron
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
|||
- 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.
|
||||
- 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)
|
||||
- Node runs: `node-<nodeId>`
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ x-i18n:
|
|||
- 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。
|
||||
- 两种执行方式:
|
||||
- **主会话**:入队一个系统事件,然后在下一次心跳时运行。
|
||||
- **隔离式**:在 `cron:<jobId>` 中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。
|
||||
- **隔离式**:在 `cron:<jobId>` 或自定义会话中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。
|
||||
- **当前会话**:绑定到创建定时任务时的会话 (`sessionTarget: "current"`)。
|
||||
- **自定义会话**:在持久化的命名会话中运行 (`sessionTarget: "session:custom-id"`)。
|
||||
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。
|
||||
|
||||
## 快速开始(可操作)
|
||||
|
|
@ -83,6 +85,14 @@ openclaw cron add \
|
|||
2. **选择运行位置**
|
||||
- `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。
|
||||
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用智能体轮次。
|
||||
- `sessionTarget: "current"` → 绑定到当前会话(创建时解析为 `session:<sessionKey>`)。
|
||||
- `sessionTarget: "session:custom-id"` → 在持久化的命名会话中运行,跨运行保持上下文。
|
||||
|
||||
默认行为(保持不变):
|
||||
- `systemEvent` 负载默认使用 `main`
|
||||
- `agentTurn` 负载默认使用 `isolated`
|
||||
|
||||
要使用当前会话绑定,需显式设置 `sessionTarget: "current"`。
|
||||
|
||||
3. **选择负载**
|
||||
- 主会话 → `payload.kind = "systemEvent"`
|
||||
|
|
@ -129,12 +139,13 @@ Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主
|
|||
|
||||
#### 隔离任务(专用定时会话)
|
||||
|
||||
隔离任务在会话 `cron:<jobId>` 中运行专用智能体轮次。
|
||||
隔离任务在会话 `cron:<jobId>` 或自定义会话中运行专用智能体轮次。
|
||||
|
||||
关键行为:
|
||||
|
||||
- 提示以 `[cron:<jobId> <任务名称>]` 为前缀,便于追踪。
|
||||
- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。
|
||||
- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话),除非使用自定义会话。
|
||||
- 自定义会话(`session:xxx`)可跨运行保持上下文,适用于如每日站会等需要基于前次摘要的工作流。
|
||||
- 如果未指定 `delivery`,隔离任务会默认以“announce”方式投递摘要。
|
||||
- `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ describe("renderDiffDocument", () => {
|
|||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).not.toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain('data-openclaw-diffs-ready="true"');
|
||||
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
|
|
|
|||
|
|
@ -241,14 +241,6 @@ function renderDiffCard(payload: DiffViewerPayload): string {
|
|||
</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: {
|
||||
title: string;
|
||||
bodyHtml: string;
|
||||
|
|
@ -257,7 +249,7 @@ function buildHtmlDocument(params: {
|
|||
runtimeMode: "viewer" | "image";
|
||||
}): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en"${params.runtimeMode === "image" ? ' data-openclaw-diffs-ready="true"' : ""}>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
|
@ -349,7 +341,7 @@ function buildHtmlDocument(params: {
|
|||
${params.bodyHtml}
|
||||
</div>
|
||||
</main>
|
||||
${params.runtimeMode === "viewer" ? `<script type="module" src="${VIEWER_LOADER_PATH}"></script>` : ""}
|
||||
<script type="module" src="${VIEWER_LOADER_PATH}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
|
@ -360,16 +352,12 @@ type RenderedSection = {
|
|||
};
|
||||
|
||||
function buildRenderedSection(params: {
|
||||
viewerPrerenderedHtml: string;
|
||||
imagePrerenderedHtml: string;
|
||||
payload: Omit<DiffViewerPayload, "prerenderedHTML">;
|
||||
viewerPayload: DiffViewerPayload;
|
||||
imagePayload: DiffViewerPayload;
|
||||
}): RenderedSection {
|
||||
return {
|
||||
viewer: renderDiffCard({
|
||||
prerenderedHTML: params.viewerPrerenderedHtml,
|
||||
...params.payload,
|
||||
}),
|
||||
image: renderStaticDiffCard(params.imagePrerenderedHtml),
|
||||
viewer: renderDiffCard(params.viewerPayload),
|
||||
image: renderDiffCard(params.imagePayload),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -401,21 +389,20 @@ async function renderBeforeAfterDiff(
|
|||
};
|
||||
const { viewerOptions, imageOptions } = buildRenderVariants(options);
|
||||
const [viewerResult, imageResult] = await Promise.all([
|
||||
preloadMultiFileDiff({
|
||||
preloadMultiFileDiffWithFallback({
|
||||
oldFile,
|
||||
newFile,
|
||||
options: viewerOptions,
|
||||
}),
|
||||
preloadMultiFileDiff({
|
||||
preloadMultiFileDiffWithFallback({
|
||||
oldFile,
|
||||
newFile,
|
||||
options: imageOptions,
|
||||
}),
|
||||
]);
|
||||
const section = buildRenderedSection({
|
||||
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
|
||||
imagePrerenderedHtml: imageResult.prerenderedHTML,
|
||||
payload: {
|
||||
viewerPayload: {
|
||||
prerenderedHTML: viewerResult.prerenderedHTML,
|
||||
oldFile: viewerResult.oldFile,
|
||||
newFile: viewerResult.newFile,
|
||||
options: viewerOptions,
|
||||
|
|
@ -424,6 +411,16 @@ async function renderBeforeAfterDiff(
|
|||
newFile: viewerResult.newFile,
|
||||
}),
|
||||
},
|
||||
imagePayload: {
|
||||
prerenderedHTML: imageResult.prerenderedHTML,
|
||||
oldFile: imageResult.oldFile,
|
||||
newFile: imageResult.newFile,
|
||||
options: imageOptions,
|
||||
langs: buildPayloadLanguages({
|
||||
oldFile: imageResult.oldFile,
|
||||
newFile: imageResult.newFile,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -456,24 +453,29 @@ async function renderPatchDiff(
|
|||
const sections = await Promise.all(
|
||||
files.map(async (fileDiff) => {
|
||||
const [viewerResult, imageResult] = await Promise.all([
|
||||
preloadFileDiff({
|
||||
preloadFileDiffWithFallback({
|
||||
fileDiff,
|
||||
options: viewerOptions,
|
||||
}),
|
||||
preloadFileDiff({
|
||||
preloadFileDiffWithFallback({
|
||||
fileDiff,
|
||||
options: imageOptions,
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildRenderedSection({
|
||||
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
|
||||
imagePrerenderedHtml: imageResult.prerenderedHTML,
|
||||
payload: {
|
||||
viewerPayload: {
|
||||
prerenderedHTML: viewerResult.prerenderedHTML,
|
||||
fileDiff: viewerResult.fileDiff,
|
||||
options: viewerOptions,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ describe("diffs tool", () => {
|
|||
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
|
||||
const screenshotter = createPngScreenshotter({
|
||||
assertHtml: (html) => {
|
||||
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
},
|
||||
assertImage: (image) => {
|
||||
expect(image).toMatchObject({
|
||||
|
|
@ -332,13 +332,13 @@ describe("diffs tool", () => {
|
|||
const html = await store.readHtml(id);
|
||||
expect(html).toContain('body data-theme="light"');
|
||||
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 () => {
|
||||
const screenshotter = createPngScreenshotter({
|
||||
assertHtml: (html) => {
|
||||
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
},
|
||||
assertImage: (image) => {
|
||||
expect(image).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -230,11 +230,22 @@ JOB SCHEMA (for add action):
|
|||
"name": "string (optional)",
|
||||
"schedule": { ... }, // Required: when to run
|
||||
"payload": { ... }, // Required: what to execute
|
||||
"delivery": { ... }, // Optional: announce summary or webhook POST
|
||||
"sessionTarget": "main" | "isolated", // Required
|
||||
"delivery": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST
|
||||
"sessionTarget": "main" | "isolated" | "current" | "session:<custom-id>", // Optional, defaults based on context
|
||||
"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):
|
||||
- "at": One-shot at absolute time
|
||||
{ "kind": "at", "at": "<ISO-8601 timestamp>" }
|
||||
|
|
@ -260,9 +271,9 @@ DELIVERY (top-level):
|
|||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- 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.
|
||||
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):
|
||||
- "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") {
|
||||
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") {
|
||||
const cfg = loadConfig();
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
|
|
|
|||
|
|
@ -194,8 +194,13 @@ export function registerCronAddCommand(cron: Command) {
|
|||
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
|
||||
const sessionTarget =
|
||||
sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget;
|
||||
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
|
||||
throw new Error("--session must be main or isolated");
|
||||
const isCustomSessionTarget =
|
||||
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) {
|
||||
|
|
@ -205,14 +210,14 @@ export function registerCronAddCommand(cron: Command) {
|
|||
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
|
||||
throw new Error("Main jobs require --system-event (systemEvent).");
|
||||
}
|
||||
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") {
|
||||
throw new Error("Isolated jobs require --message (agentTurn).");
|
||||
if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") {
|
||||
throw new Error("Isolated/current/custom-session jobs require --message (agentTurn).");
|
||||
}
|
||||
if (
|
||||
(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 =
|
||||
|
|
@ -220,12 +225,12 @@ export function registerCronAddCommand(cron: Command) {
|
|||
? opts.account.trim()
|
||||
: undefined;
|
||||
|
||||
if (accountId && (sessionTarget !== "isolated" || payload.kind !== "agentTurn")) {
|
||||
throw new Error("--account requires an isolated agentTurn job with delivery.");
|
||||
if (accountId && (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")) {
|
||||
throw new Error("--account requires a non-main agentTurn job with delivery.");
|
||||
}
|
||||
|
||||
const deliveryMode =
|
||||
sessionTarget === "isolated" && payload.kind === "agentTurn"
|
||||
isIsolatedLikeSessionTarget && payload.kind === "agentTurn"
|
||||
? hasAnnounce
|
||||
? "announce"
|
||||
: hasNoDeliver
|
||||
|
|
|
|||
|
|
@ -247,9 +247,9 @@ export function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
|||
})();
|
||||
|
||||
const coloredTarget =
|
||||
job.sessionTarget === "isolated"
|
||||
? colorize(rich, theme.accentBright, targetLabel)
|
||||
: colorize(rich, theme.accent, targetLabel);
|
||||
job.sessionTarget === "main"
|
||||
? colorize(rich, theme.accent, targetLabel)
|
||||
: colorize(rich, theme.accentBright, targetLabel);
|
||||
const coloredAgent = job.agentId
|
||||
? colorize(rich, theme.info, agentLabel)
|
||||
: colorize(rich, theme.muted, agentLabel);
|
||||
|
|
|
|||
|
|
@ -414,6 +414,42 @@ describe("normalizeCronJobCreate", () => {
|
|||
expect(delivery.mode).toBeUndefined();
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ type UnknownRecord = Record<string, unknown>;
|
|||
|
||||
type NormalizeOptions = {
|
||||
applyDefaults?: boolean;
|
||||
/** Session context for resolving "current" sessionTarget or auto-binding when not specified */
|
||||
sessionContext?: { sessionKey?: string };
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: NormalizeOptions = {
|
||||
|
|
@ -218,9 +220,17 @@ function normalizeSessionTarget(raw: unknown) {
|
|||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === "main" || trimmed === "isolated") {
|
||||
return trimmed;
|
||||
const trimmed = raw.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
|
@ -431,10 +441,37 @@ export function normalizeCronJobInput(
|
|||
}
|
||||
if (!next.sessionTarget && isRecord(next.payload)) {
|
||||
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") {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -462,8 +499,12 @@ export function normalizeCronJobInput(
|
|||
const payload = isRecord(next.payload) ? next.payload : null;
|
||||
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
|
||||
const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
|
||||
// Support "isolated", custom session IDs (session:xxx), and resolved "current" as isolated-like targets
|
||||
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 normalizedLegacy = normalizeLegacyDeliveryInput({
|
||||
delivery: isRecord(next.delivery) ? next.delivery : null,
|
||||
|
|
@ -487,7 +528,7 @@ export function normalizeCronJobInput(
|
|||
|
||||
export function normalizeCronJobCreate(
|
||||
raw: unknown,
|
||||
options?: NormalizeOptions,
|
||||
options?: Omit<NormalizeOptions, "applyDefaults">,
|
||||
): CronJobCreate | null {
|
||||
return normalizeCronJobInput(raw, {
|
||||
applyDefaults: true,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-3", {
|
||||
mode: "none",
|
||||
|
|
|
|||
|
|
@ -759,7 +759,7 @@ describe("CronService", () => {
|
|||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "nope" },
|
||||
}),
|
||||
).rejects.toThrow(/isolated cron jobs require/);
|
||||
).rejects.toThrow(/isolated.*cron jobs require/);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
|
|
|
|||
|
|
@ -72,6 +72,39 @@ function createLegacyIsolatedAgentTurnJob(
|
|||
}
|
||||
|
||||
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 () => {
|
||||
const { store, cron } = await startCronWithStoredJobs([
|
||||
createLegacyIsolatedAgentTurnJob({
|
||||
|
|
|
|||
|
|
@ -133,6 +133,24 @@ describe("cron store migration", () => {
|
|||
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 () => {
|
||||
const createdAtMs = 1_700_000_000_000;
|
||||
const migrated = await migrateLegacyJob(
|
||||
|
|
|
|||
|
|
@ -132,11 +132,15 @@ function resolveEveryAnchorMs(params: {
|
|||
}
|
||||
|
||||
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") {
|
||||
throw new Error('main cron jobs require payload.kind="systemEvent"');
|
||||
}
|
||||
if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") {
|
||||
throw new Error('isolated cron jobs require payload.kind="agentTurn"');
|
||||
if (isIsolatedLike && job.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") {
|
||||
return;
|
||||
}
|
||||
// Webhook delivery is allowed for any session target
|
||||
if (job.delivery.mode === "webhook") {
|
||||
const target = normalizeHttpWebhookUrl(job.delivery.to);
|
||||
if (!target) {
|
||||
|
|
@ -189,7 +194,11 @@ function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">)
|
|||
job.delivery.to = target;
|
||||
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"');
|
||||
}
|
||||
if (job.delivery.channel === "telegram") {
|
||||
|
|
@ -606,11 +615,11 @@ export function applyJobPatch(
|
|||
if (!patch.delivery && patch.payload?.kind === "agentTurn") {
|
||||
// Back-compat: legacy clients still update delivery via payload fields.
|
||||
const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload);
|
||||
if (
|
||||
legacyDeliveryPatch &&
|
||||
job.sessionTarget === "isolated" &&
|
||||
job.payload.kind === "agentTurn"
|
||||
) {
|
||||
const isIsolatedLike =
|
||||
job.sessionTarget === "isolated" ||
|
||||
job.sessionTarget === "current" ||
|
||||
job.sessionTarget.startsWith("session:");
|
||||
if (legacyDeliveryPatch && isIsolatedLike && job.payload.kind === "agentTurn") {
|
||||
job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,11 +451,25 @@ export function normalizeStoredCronJobs(
|
|||
|
||||
const payloadKind =
|
||||
payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : "";
|
||||
const normalizedSessionTarget =
|
||||
typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : "";
|
||||
if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") {
|
||||
if (raw.sessionTarget !== normalizedSessionTarget) {
|
||||
raw.sessionTarget = normalizedSessionTarget;
|
||||
const rawSessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim() : "";
|
||||
const loweredSessionTarget = rawSessionTarget.toLowerCase();
|
||||
if (loweredSessionTarget === "main" || loweredSessionTarget === "isolated") {
|
||||
if (raw.sessionTarget !== loweredSessionTarget) {
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -469,7 +483,10 @@ export function normalizeStoredCronJobs(
|
|||
const sessionTarget =
|
||||
typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : "";
|
||||
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 normalizedLegacy = normalizeLegacyDeliveryInput({
|
||||
delivery: hasDelivery ? (delivery as Record<string, unknown>) : null,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export type CronSchedule =
|
|||
staggerMs?: number;
|
||||
};
|
||||
|
||||
export type CronSessionTarget = "main" | "isolated";
|
||||
export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`;
|
||||
export type CronWakeMode = "next-heartbeat" | "now";
|
||||
|
||||
export type CronMessageChannel = ChannelId | "last";
|
||||
|
|
|
|||
|
|
@ -21,6 +21,29 @@ describe("cron protocol validators", () => {
|
|||
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", () => {
|
||||
const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams;
|
||||
expect(validateCronAddParams(withoutWakeMode)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -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 CronRunStatusSchema = Type.Union([
|
||||
Type.Literal("ok"),
|
||||
|
|
|
|||
|
|
@ -5,10 +5,19 @@ import type { CliDeps } from "../cli/deps.js";
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const requestHeartbeatNowMock = vi.fn();
|
||||
const loadConfigMock = vi.fn();
|
||||
const fetchWithSsrFGuardMock = vi.fn();
|
||||
const {
|
||||
enqueueSystemEventMock,
|
||||
requestHeartbeatNowMock,
|
||||
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[]) {
|
||||
return enqueueSystemEventMock(...args);
|
||||
|
|
@ -35,7 +44,11 @@ vi.mock("../config/config.js", async () => {
|
|||
});
|
||||
|
||||
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";
|
||||
|
|
@ -58,6 +71,7 @@ describe("buildGatewayCronService", () => {
|
|||
requestHeartbeatNowMock.mockClear();
|
||||
loadConfigMock.mockClear();
|
||||
fetchWithSsrFGuardMock.mockClear();
|
||||
runCronIsolatedAgentTurnMock.mockClear();
|
||||
});
|
||||
|
||||
it("routes main-target jobs to the scoped session for enqueue + wake", async () => {
|
||||
|
|
@ -142,4 +156,44 @@ describe("buildGatewayCronService", () => {
|
|||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -284,6 +284,13 @@ export function buildGatewayCronService(params: {
|
|||
},
|
||||
runIsolatedAgentJob: async ({ job, message, abortSignal }) => {
|
||||
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({
|
||||
cfg: runtimeConfig,
|
||||
deps: params.deps,
|
||||
|
|
@ -291,7 +298,7 @@ export function buildGatewayCronService(params: {
|
|||
message,
|
||||
abortSignal,
|
||||
agentId,
|
||||
sessionKey: `cron:${job.id}`,
|
||||
sessionKey,
|
||||
lane: "cron",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -89,7 +89,14 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||
respond(true, status, undefined);
|
||||
},
|
||||
"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)) {
|
||||
respond(
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export type CronModelSuggestionsState = {
|
|||
export function supportsAnnounceDelivery(
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ export type CronSchedule =
|
|||
| { kind: "every"; everyMs: number; anchorMs?: 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 CronPayload =
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export type CronFormState = {
|
|||
scheduleExact: boolean;
|
||||
staggerAmount: string;
|
||||
staggerUnit: "seconds" | "minutes";
|
||||
sessionTarget: "main" | "isolated";
|
||||
sessionTarget: "main" | "isolated" | "current" | `session:${string}`;
|
||||
wakeMode: "next-heartbeat" | "now";
|
||||
payloadKind: "systemEvent" | "agentTurn";
|
||||
payloadText: string;
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ export function renderCron(props: CronProps) {
|
|||
const statusSummary = summarizeSelection(selectedStatusLabels, t("cron.runs.allStatuses"));
|
||||
const deliverySummary = summarizeSelection(selectedDeliveryLabels, t("cron.runs.allDelivery"));
|
||||
const supportsAnnounce =
|
||||
props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn";
|
||||
props.form.sessionTarget !== "main" && props.form.payloadKind === "agentTurn";
|
||||
const selectedDeliveryMode =
|
||||
props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode;
|
||||
const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode);
|
||||
|
|
|
|||
Loading…
Reference in New Issue