From e7d9648fba27254ac2d641be557466c369b747ad Mon Sep 17 00:00:00 2001 From: kkhomej33-netizen Date: Sat, 14 Mar 2026 13:48:46 +0800 Subject: [PATCH] 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:` 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 Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenClaw/CronJobEditor+Helpers.swift | 32 ++++-- .../Sources/OpenClaw/CronJobEditor.swift | 13 ++- apps/macos/Sources/OpenClaw/CronModels.swift | 86 ++++++++++++++- .../Sources/OpenClaw/CronSettings+Rows.swift | 14 +-- docs/automation/cron-jobs.md | 49 ++++++++- docs/automation/cron-vs-heartbeat.md | 14 +-- docs/concepts/session.md | 2 +- docs/zh-CN/automation/cron-jobs.md | 17 ++- extensions/diffs/src/render.test.ts | 3 +- extensions/diffs/src/render.ts | 104 +++++++++++++----- extensions/diffs/src/tool.test.ts | 6 +- src/agents/tools/cron-tool.ts | 24 +++- src/cli/cron-cli/register.cron-add.ts | 23 ++-- src/cli/cron-cli/shared.ts | 6 +- src/cron/normalize.test.ts | 36 ++++++ src/cron/normalize.ts | 53 ++++++++- src/cron/service.jobs.test.ts | 23 ++++ ...runs-one-shot-main-job-disables-it.test.ts | 2 +- src/cron/service.store-migration.test.ts | 33 ++++++ src/cron/service.store.migration.test.ts | 18 +++ src/cron/service/jobs.ts | 25 +++-- src/cron/store-migration.ts | 29 ++++- src/cron/types.ts | 2 +- src/gateway/protocol/cron-validators.test.ts | 23 ++++ src/gateway/protocol/schema/cron.ts | 7 +- src/gateway/server-cron.test.ts | 64 ++++++++++- src/gateway/server-cron.ts | 9 +- src/gateway/server-methods/cron.ts | 9 +- ui/src/ui/controllers/cron.ts | 2 +- ui/src/ui/types.ts | 2 +- ui/src/ui/ui-types.ts | 2 +- ui/src/ui/views/cron.ts | 2 +- 33 files changed, 617 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11cb42a82d6..7463733f3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` 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 diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift index 26b64ea7c65..41b98111b4e 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift @@ -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) } diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/apps/macos/Sources/OpenClaw/CronJobEditor.swift index a7d88a4f2fb..292f3a63284 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index e0ce46c13da..40079453974 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift index 69655bdc302..85e45928853 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift @@ -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 { diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index effa8f3ab81..cb27380416b 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -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:`, with delivery (announce by default or none). + - **Isolated**: run a dedicated agent turn in `cron:` 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 = ""`. - 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:`. + - `sessionTarget: "current"` → bind to the current session (resolved at creation time to `session:`). + - `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:`. +Isolated jobs run a dedicated agent turn in session `cron:` or a custom session. Key behaviors: - Prompt is prefixed with `[cron: ]` 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:"`. +- `"current"` is resolved to `"session:"` 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. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 9676d960d23..09f9187c368 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -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:` | -| 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:` 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 diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 2a58c15cb4d..2f00325b730 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -200,7 +200,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Legacy `group:` keys are still recognized for migration. - Inbound contexts may still use `group:`; the channel is inferred from `Provider` and normalized to the canonical `agent:::group:` form. - Other sources: - - Cron jobs: `cron:` + - Cron jobs: `cron:` (isolated) or custom `session:` (persistent) - Webhooks: `hook:` (unless explicitly set by the hook) - Node runs: `node-` diff --git a/docs/zh-CN/automation/cron-jobs.md b/docs/zh-CN/automation/cron-jobs.md index 185779a2636..cfdb0c178e1 100644 --- a/docs/zh-CN/automation/cron-jobs.md +++ b/docs/zh-CN/automation/cron-jobs.md @@ -28,7 +28,9 @@ x-i18n: - 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。 - 两种执行方式: - **主会话**:入队一个系统事件,然后在下一次心跳时运行。 - - **隔离式**:在 `cron:` 中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **隔离式**:在 `cron:` 或自定义会话中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **当前会话**:绑定到创建定时任务时的会话 (`sessionTarget: "current"`)。 + - **自定义会话**:在持久化的命名会话中运行 (`sessionTarget: "session:custom-id"`)。 - 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。 ## 快速开始(可操作) @@ -83,6 +85,14 @@ openclaw cron add \ 2. **选择运行位置** - `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。 - `sessionTarget: "isolated"` → 在 `cron:` 中运行专用智能体轮次。 + - `sessionTarget: "current"` → 绑定到当前会话(创建时解析为 `session:`)。 + - `sessionTarget: "session:custom-id"` → 在持久化的命名会话中运行,跨运行保持上下文。 + + 默认行为(保持不变): + - `systemEvent` 负载默认使用 `main` + - `agentTurn` 负载默认使用 `isolated` + + 要使用当前会话绑定,需显式设置 `sessionTarget: "current"`。 3. **选择负载** - 主会话 → `payload.kind = "systemEvent"` @@ -129,12 +139,13 @@ Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主 #### 隔离任务(专用定时会话) -隔离任务在会话 `cron:` 中运行专用智能体轮次。 +隔离任务在会话 `cron:` 或自定义会话中运行专用智能体轮次。 关键行为: - 提示以 `[cron: <任务名称>]` 为前缀,便于追踪。 -- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。 +- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话),除非使用自定义会话。 +- 自定义会话(`session:xxx`)可跨运行保持上下文,适用于如每日站会等需要基于前次摘要的工作流。 - 如果未指定 `delivery`,隔离任务会默认以“announce”方式投递摘要。 - `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。 diff --git a/extensions/diffs/src/render.test.ts b/extensions/diffs/src/render.test.ts index f46a2c9abe9..006b239a39f 100644 --- a/extensions/diffs/src/render.test.ts +++ b/extensions/diffs/src/render.test.ts @@ -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;"); diff --git a/extensions/diffs/src/render.ts b/extensions/diffs/src/render.ts index ce01091eea6..364252c0b3b 100644 --- a/extensions/diffs/src/render.ts +++ b/extensions/diffs/src/render.ts @@ -241,14 +241,6 @@ function renderDiffCard(payload: DiffViewerPayload): string { `; } -function renderStaticDiffCard(prerenderedHTML: string): string { - return `
- - - -
`; -} - function buildHtmlDocument(params: { title: string; bodyHtml: string; @@ -257,7 +249,7 @@ function buildHtmlDocument(params: { runtimeMode: "viewer" | "image"; }): string { return ` - + @@ -349,7 +341,7 @@ function buildHtmlDocument(params: { ${params.bodyHtml} - ${params.runtimeMode === "viewer" ? `` : ""} + `; } @@ -360,16 +352,12 @@ type RenderedSection = { }; function buildRenderedSection(params: { - viewerPrerenderedHtml: string; - imagePrerenderedHtml: string; - payload: Omit; + 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>; +type PreloadedMultiFileDiffResult = Awaited>; + +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 { + 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 { + try { + return await preloadMultiFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + oldFile: params.oldFile, + newFile: params.newFile, + prerenderedHTML: "", + }; + } +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 056b10c0643..2f845727274 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -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({ diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 14df6901024..2976dee3924 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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:", // 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:": 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": "" } @@ -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); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index bd7d0ff1af5..e916c459863 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -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:"); } 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 diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index d3601b6ce40..3574a63ab27 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -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); diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 6f34c85ebed..969faa6bb6f 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -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; + + 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; + + 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; + + expect(normalized.sessionTarget).toBe("session:MySessionID"); + }); }); describe("normalizeCronJobPatch", () => { diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 5a6c66ff356..b1afdfaaa12 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -11,6 +11,8 @@ type UnknownRecord = Record; 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, ): CronJobCreate | null { return normalizeCronJobInput(raw, { applyDefaults: true, diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 053ea8764de..c514f7528ba 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -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", diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 555750bd738..75ffb262d4d 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -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(); diff --git a/src/cron/service.store-migration.test.ts b/src/cron/service.store-migration.test.ts index 52c9f571b08..216154fa503 100644 --- a/src/cron/service.store-migration.test.ts +++ b/src/cron/service.store-migration.test.ts @@ -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({ diff --git a/src/cron/service.store.migration.test.ts b/src/cron/service.store.migration.test.ts index 8daa0b39e9a..973efca67a6 100644 --- a/src/cron/service.store.migration.test.ts +++ b/src/cron/service.store.migration.test.ts @@ -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( diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 5579e5430f0..542ba81053d 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -132,11 +132,15 @@ function resolveEveryAnchorMs(params: { } export function assertSupportedJobSpec(job: Pick) { + 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) 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) 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); } } diff --git a/src/cron/store-migration.ts b/src/cron/store-migration.ts index 1e9dcb1b136..0a460174bd2 100644 --- a/src/cron/store-migration.ts +++ b/src/cron/store-migration.ts @@ -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) : null, diff --git a/src/cron/types.ts b/src/cron/types.ts index 2a93bc30311..02078d15424 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -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"; diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts index 33df9d478e9..1de9db206b9 100644 --- a/src/gateway/protocol/cron-validators.test.ts +++ b/src/gateway/protocol/cron-validators.test.ts @@ -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); diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 3cba5a65781..f61d3e42711 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -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"), diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 2608560e20f..d7a6b375d10 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -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(); + } + }); }); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 1f1cd1f5359..8a288866721 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -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", }); }, diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 830d12c9509..7eccb895534 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -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, diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index c81d69c57ea..c6073a8e626 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -84,7 +84,7 @@ export type CronModelSuggestionsState = { export function supportsAnnounceDelivery( form: Pick, ) { - return form.sessionTarget === "isolated" && form.payloadKind === "agentTurn"; + return form.sessionTarget !== "main" && form.payloadKind === "agentTurn"; } export function normalizeCronFormState(form: CronFormState): CronFormState { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 17ff4293afa..d9764a024e6 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -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 = diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index c01e2cf0f7d..2cd1709d841 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -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; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 836b72dbbcc..1509637b46f 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -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);