feat(cron): enhance delivery modes and job configuration

- Updated isolated cron jobs to support new delivery modes: `announce` and `none`, improving output management.
- Refactored job configuration to remove legacy fields and streamline delivery settings.
- Enhanced the `CronJobEditor` UI to reflect changes in delivery options, including a new segmented control for delivery mode selection.
- Updated documentation to clarify the new delivery configurations and their implications for job execution.
- Improved tests to validate the new delivery behavior and ensure backward compatibility with legacy settings.

This update provides users with greater flexibility in managing how isolated jobs deliver their outputs, enhancing overall usability and clarity in job configurations.
main
Tyler Yust 2026-02-03 16:53:46 -08:00 committed by Peter Steinberger
parent ab9f06f4ff
commit 3f82daefd8
56 changed files with 917 additions and 1150 deletions

View File

@ -47,7 +47,11 @@ Docs: https://docs.openclaw.ai
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs.
- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI.
- Cron: suppress messaging tools during announce delivery so summaries post consistently.
- Cron: avoid duplicate deliveries when isolated runs send messages directly.
- Subagents: discourage direct messaging tool use unless a specific external recipient is requested.
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.

View File

@ -20,9 +20,11 @@ extension CronJobEditor {
self.wakeMode = job.wakeMode
switch job.schedule {
case let .at(atMs):
case let .at(at):
self.scheduleKind = .at
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
if let date = CronSchedule.parseAtDate(at) {
self.atDate = date
}
case let .every(everyMs, _):
self.scheduleKind = .every
self.everyText = self.formatDuration(ms: everyMs)
@ -36,19 +38,22 @@ extension CronJobEditor {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
if let delivery = job.delivery {
self.deliveryMode = delivery.mode == .announce ? .announce : .none
let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = delivery.to ?? ""
self.bestEffortDeliver = delivery.bestEffort ?? false
} else if self.sessionTarget == .isolated {
self.deliveryMode = .announce
}
}
func save() {
@ -88,15 +93,25 @@ extension CronJobEditor {
}
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
root["delivery"] = self.buildDelivery()
}
return root.mapValues { AnyCodable($0) }
}
func buildDelivery() -> [String: Any] {
let mode = self.deliveryMode == .announce ? "announce" : "none"
var delivery: [String: Any] = ["mode": mode]
if self.deliveryMode == .announce {
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
delivery["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { delivery["to"] = to }
if self.bestEffortDeliver { delivery["bestEffort"] = true }
}
return delivery
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
@ -115,7 +130,7 @@ extension CronJobEditor {
func buildSchedule() throws -> [String: Any] {
switch self.scheduleKind {
case .at:
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
@ -209,14 +224,6 @@ extension CronJobEditor {
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
if !thinking.isEmpty { payload["thinking"] = thinking }
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
}
return payload
}

View File

@ -13,13 +13,12 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.deliveryMode = .announce
self.channel = "last"
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
self.bestEffortDeliver = true
self.postPrefix = "Cron"
_ = self.buildAgentTurnPayload()
_ = try? self.buildPayload()

View File

@ -16,16 +16,13 @@ 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 deliver results (WhatsApp/Telegram/Discord/etc)."
+ "Isolated jobs run OpenClaw in a dedicated session 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 =
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
+ "and a short summary is posted back to your main chat."
"Isolated jobs always run an agent turn. Announce sends a short summary to a channel."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
static let mainSummaryNote =
"Controls the label used when posting the completion summary back to the main session."
@State var name: String = ""
@State var description: String = ""
@ -46,13 +43,13 @@ struct CronJobEditor: View {
@State var payloadKind: PayloadKind = .systemEvent
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { rawValue } }
@State var deliveryMode: DeliveryChoice = .announce
@State var channel: String = "last"
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron"
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
@ -248,27 +245,6 @@ struct CronJobEditor: View {
}
}
if self.sessionTarget == .isolated {
GroupBox("Main session summary") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Prefix")
TextField("Cron", text: self.$postPrefix)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.mainSummaryNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
@ -340,13 +316,17 @@ struct CronJobEditor: View {
.frame(width: 180, alignment: .leading)
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a channel", isOn: self.$deliver)
.toggleStyle(.switch)
self.gridLabel("Delivery")
Picker("", selection: self.$deliveryMode) {
Text("Announce summary").tag(DeliveryChoice.announce)
Text("None").tag(DeliveryChoice.none)
}
.labelsHidden()
.pickerStyle(.segmented)
}
}
if self.deliver {
if self.deliveryMode == .announce {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Channel")
@ -367,7 +347,7 @@ struct CronJobEditor: View {
}
GridRow {
self.gridLabel("Best-effort")
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver)
.toggleStyle(.switch)
}
}

View File

@ -14,12 +14,26 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
var id: String { self.rawValue }
}
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
case none
case announce
var id: String { self.rawValue }
}
struct CronDelivery: Codable, Equatable {
var mode: CronDeliveryMode
var channel: String?
var to: String?
var bestEffort: Bool?
}
enum CronSchedule: Codable, Equatable {
case at(atMs: Int)
case at(at: String)
case every(everyMs: Int, anchorMs: Int?)
case cron(expr: String, tz: String?)
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz }
var kind: String {
switch self {
@ -34,7 +48,21 @@ enum CronSchedule: Codable, Equatable {
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "at":
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
if let at = try container.decodeIfPresent(String.self, forKey: .at),
!at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
self = .at(at: at)
return
}
if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) {
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
self = .at(at: Self.formatIsoDate(date))
return
}
throw DecodingError.dataCorruptedError(
forKey: .at,
in: container,
debugDescription: "Missing schedule.at")
case "every":
self = try .every(
everyMs: container.decode(Int.self, forKey: .everyMs),
@ -55,8 +83,8 @@ enum CronSchedule: Codable, Equatable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .at(atMs):
try container.encode(atMs, forKey: .atMs)
case let .at(at):
try container.encode(at, forKey: .at)
case let .every(everyMs, anchorMs):
try container.encode(everyMs, forKey: .everyMs)
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
@ -65,6 +93,29 @@ enum CronSchedule: Codable, Equatable {
try container.encodeIfPresent(tz, forKey: .tz)
}
}
static func parseAtDate(_ value: String) -> Date? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if let date = isoFormatterWithFractional.date(from: trimmed) { return date }
return isoFormatter.date(from: trimmed)
}
static func formatIsoDate(_ date: Date) -> String {
isoFormatter.string(from: date)
}
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
private static let isoFormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
}
enum CronPayload: Codable, Equatable {
@ -131,10 +182,6 @@ enum CronPayload: Codable, Equatable {
}
}
struct CronIsolation: Codable, Equatable {
var postToMainPrefix: String?
}
struct CronJobState: Codable, Equatable {
var nextRunAtMs: Int?
var runningAtMs: Int?
@ -157,7 +204,7 @@ struct CronJob: Identifiable, Codable, Equatable {
let sessionTarget: CronSessionTarget
let wakeMode: CronWakeMode
let payload: CronPayload
let isolation: CronIsolation?
let delivery: CronDelivery?
let state: CronJobState
var displayName: String {

View File

@ -17,9 +17,11 @@ extension CronSettings {
func scheduleSummary(_ schedule: CronSchedule) -> String {
switch schedule {
case let .at(atMs):
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
return "at \(date.formatted(date: .abbreviated, time: .standard))"
case let .at(at):
if let date = CronSchedule.parseAtDate(at) {
return "at \(date.formatted(date: .abbreviated, time: .standard))"
}
return "at \(at)"
case let .every(everyMs, _):
return "every \(self.formatDuration(ms: everyMs))"
case let .cron(expr, tz):

View File

@ -128,7 +128,7 @@ extension CronSettings {
.foregroundStyle(.orange)
.textSelection(.enabled)
}
self.payloadSummary(job.payload)
self.payloadSummary(job)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
@ -205,7 +205,8 @@ extension CronSettings {
.padding(.vertical, 4)
}
func payloadSummary(_ payload: CronPayload) -> some View {
func payloadSummary(_ job: CronJob) -> some View {
let payload = job.payload
VStack(alignment: .leading, spacing: 6) {
Text("Payload")
.font(.caption.weight(.semibold))
@ -215,7 +216,7 @@ extension CronSettings {
Text(text)
.font(.callout)
.textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
@ -223,10 +224,19 @@ 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 deliver ?? false {
StatusPill(text: "deliver", tint: .secondary)
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
if job.sessionTarget == .isolated {
let delivery = job.delivery
if let delivery {
if delivery.mode == .announce {
StatusPill(text: "announce", tint: .secondary)
if let channel = delivery.channel, !channel.isEmpty {
StatusPill(text: channel, tint: .secondary)
}
if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
} else {
StatusPill(text: "no delivery", tint: .secondary)
}
}
}
}
}

View File

@ -21,11 +21,11 @@ struct CronSettings_Previews: PreviewProvider {
message: "Summarize inbox",
thinking: "low",
timeoutSeconds: 600,
deliver: true,
channel: "last",
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true),
state: CronJobState(
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
runningAtMs: nil,
@ -75,11 +75,11 @@ extension CronSettings {
message: "Summarize",
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
state: CronJobState(
nextRunAtMs: 1_700_000_200_000,
runningAtMs: nil,
@ -111,7 +111,7 @@ extension CronSettings {
_ = view.detailCard(job)
_ = view.runHistoryCard(job)
_ = view.runRow(run)
_ = view.payloadSummary(job.payload)
_ = view.payloadSummary(job)
_ = view.scheduleSummary(job.schedule)
_ = view.statusTint(job.state.lastStatus)
_ = view.nextRunLabel(Date())

View File

@ -40,11 +40,11 @@ struct CronJobEditorSmokeTests {
message: "Summarize the last day",
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
state: CronJobState(
nextRunAtMs: 1_700_000_100_000,
runningAtMs: nil,

View File

@ -5,12 +5,24 @@ import Testing
@Suite
struct CronModelsTests {
@Test func scheduleAtEncodesAndDecodes() throws {
let schedule = CronSchedule.at(atMs: 123)
let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z")
let data = try JSONEncoder().encode(schedule)
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
#expect(decoded == schedule)
}
@Test func scheduleAtDecodesLegacyAtMs() throws {
let json = """
{"kind":"at","atMs":1700000000000}
"""
let decoded = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
if case let .at(at) = decoded {
#expect(at.hasPrefix("2023-"))
} else {
#expect(Bool(false))
}
}
@Test func scheduleEveryEncodesAndDecodesWithAnchor() throws {
let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000)
let data = try JSONEncoder().encode(schedule)
@ -49,11 +61,11 @@ struct CronModelsTests {
deleteAfterRun: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 1_700_000_000_000),
schedule: .at(at: "2026-02-03T18:00:00Z"),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
delivery: nil,
state: CronJobState())
let data = try JSONEncoder().encode(job)
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
@ -62,7 +74,7 @@ struct CronModelsTests {
@Test func scheduleDecodeRejectsUnknownKind() {
let json = """
{"kind":"wat","atMs":1}
{"kind":"wat","at":"2026-02-03T18:00:00Z"}
"""
#expect(throws: DecodingError.self) {
_ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
@ -88,11 +100,11 @@ struct CronModelsTests {
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
schedule: .at(at: "2026-02-03T18:00:00Z"),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "hi"),
isolation: nil,
delivery: nil,
state: CronJobState())
#expect(base.displayName == "hello")
@ -111,11 +123,11 @@ struct CronModelsTests {
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
schedule: .at(at: "2026-02-03T18:00:00Z"),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "hi"),
isolation: nil,
delivery: nil,
state: CronJobState(
nextRunAtMs: 1_700_000_000_000,
runningAtMs: nil,

View File

@ -23,7 +23,7 @@ struct SettingsViewSmokeTests {
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
delivery: nil,
state: CronJobState(
nextRunAtMs: 1_700_000_200_000,
runningAtMs: nil,
@ -48,11 +48,11 @@ struct SettingsViewSmokeTests {
message: "hello",
thinking: "low",
timeoutSeconds: 30,
deliver: true,
channel: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "sms", to: "+15551234567", bestEffort: true),
state: CronJobState(
nextRunAtMs: nil,
runningAtMs: nil,

View File

@ -23,7 +23,7 @@ cron is the mechanism.
- Jobs persist under `~/.openclaw/cron/` so restarts dont 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, full output or none; legacy main summary still supported).
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
## Quick start (actionable)
@ -97,7 +97,7 @@ A cron job is a stored record with:
- a **schedule** (when it should run),
- a **payload** (what it should do),
- optional **delivery mode** (announce, full output, or none).
- optional **delivery mode** (announce or none).
- optional **agent binding** (`agentId`): run the job under a specific agent; if
missing or unknown, the gateway falls back to the default agent.
@ -109,7 +109,7 @@ One-shot jobs auto-delete after success by default; set `deleteAfterRun: false`
Cron supports three schedule kinds:
- `at`: one-shot timestamp. Prefer ISO 8601 via `schedule.at`; `atMs` (epoch ms) is also accepted.
- `at`: one-shot timestamp via `schedule.at` (ISO 8601).
- `every`: fixed interval (ms).
- `cron`: 5-field cron expression with optional IANA timezone.
@ -137,13 +137,10 @@ Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over).
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary immediately (`delivery.mode = "announce"`), unless legacy isolation settings or legacy payload delivery fields are provided.
- Legacy behavior: jobs with legacy isolation settings, legacy payload delivery fields, or older stored jobs without `delivery` post a summary to the main session (prefix `Cron`, configurable).
- `delivery.mode` (isolated-only) chooses what happens instead of the legacy summary:
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary immediately (`delivery.mode = "announce"`).
- `delivery.mode` (isolated-only) chooses what happens:
- `announce`: subagent-style summary delivered immediately to a chat.
- `deliver`: full agent output delivered immediately to a chat.
- `none`: internal only (no main summary, no delivery).
- `wakeMode: "now"` only triggers an immediate heartbeat when using the legacy main-summary path.
- `none`: internal only (no delivery).
Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
your main chat history.
@ -163,28 +160,15 @@ Common `agentTurn` fields:
Delivery config (isolated jobs only):
- `delivery.mode`: `none` | `announce` | `deliver`.
- `delivery.mode`: `none` | `announce`.
- `delivery.channel`: `last` or a specific channel.
- `delivery.to`: channel-specific target (phone/chat/channel id).
- `delivery.bestEffort`: avoid failing the job if delivery fails (deliver mode).
- `delivery.bestEffort`: avoid failing the job if announce delivery fails.
If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce` unless legacy isolation
settings are present.
Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to`
to target the chat instead.
Legacy delivery fields (still accepted when `delivery` is omitted):
- `payload.deliver`: `true` to send output to a channel target.
- `payload.channel`: `last` or a specific channel.
- `payload.to`: channel-specific target (phone/chat/channel id).
- `payload.bestEffortDeliver`: avoid failing the job if delivery fails.
Isolation options (only for `session=isolated`):
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the system event in main.
- `postToMainMode`: `summary` (default) or `full`.
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
Note: setting isolation post-to-main options opts into the legacy main-summary path (no `delivery` field). If `delivery` is set, the legacy summary is skipped.
If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce`.
### Model and thinking overrides
@ -207,7 +191,7 @@ Resolution priority:
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
- `delivery.mode`: `announce` (subagent-style summary) or `deliver` (full output).
- `delivery.mode`: `announce` (subagent-style summary) or `none`.
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
- `delivery.to`: channel-specific recipient target.
@ -216,14 +200,6 @@ Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main sessions
“last route” (the last place the agent replied).
Legacy behavior (no `delivery` field with legacy isolation settings or older jobs):
- If `payload.to` is set, cron auto-delivers the agents final output even if `payload.deliver` is omitted.
- Use `payload.deliver: true` when you want last-route delivery without an explicit `to`.
- Use `payload.deliver: false` to keep output internal even if a `to` is present.
If `delivery` is set, it overrides legacy payload delivery fields and skips the legacy main-session summary.
Target format reminders:
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
@ -246,7 +222,7 @@ Prefixed targets like `telegram:...` / `telegram:group:...` are also accepted:
Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC).
CLI flags accept human durations like `20m`, but tool calls should use an ISO 8601 string
for `schedule.at` (preferred) or epoch milliseconds for `atMs` and `everyMs`.
for `schedule.at` and milliseconds for `schedule.everyMs`.
### cron.add params
@ -286,12 +262,12 @@ Recurring, isolated job with delivery:
Notes:
- `schedule.kind`: `at` (`at` or `atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
- `atMs` and `everyMs` are epoch milliseconds.
- `everyMs` is milliseconds.
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
`delivery`, `isolation`.
`delivery`.
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
### cron.update params
@ -392,7 +368,7 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize today; send to the nightly topic." \
--deliver \
--announce \
--channel telegram \
--to "-1001234567890:topic:123"
```
@ -408,7 +384,7 @@ openclaw cron add \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```

View File

@ -90,8 +90,8 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect
- **Exact timing**: 5-field cron expressions with timezone support.
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
- **Model overrides**: Use a cheaper or more powerful model per job.
- **Delivery control**: Isolated jobs default to `announce` (summary); choose `deliver` (full output) or `none` as needed. Legacy jobs still post a summary to main.
- **Immediate delivery**: Announce/deliver modes post directly without waiting for heartbeat.
- **Delivery control**: Isolated jobs default to `announce` (summary); choose `none` as needed.
- **Immediate delivery**: Announce mode posts directly without waiting for heartbeat.
- **No agent context needed**: Runs even if main session is idle or compacted.
- **One-shot support**: `--at` for precise future timestamps.
@ -246,7 +246,7 @@ Use `--session isolated` when you want:
- A clean slate without prior context
- Different model or thinking settings
- Announce summaries or deliver full output directly to a channel
- Announce summaries directly to a channel
- History that doesn't clutter main session
```bash

View File

@ -16,9 +16,8 @@ Related:
Tip: run `openclaw cron --help` for the full command surface.
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--deliver` for full output
or `--no-deliver` to keep output internal. To opt into the legacy main-summary path, pass
`--post-prefix` (or other `--post-*` options) without delivery flags.
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep
output internal. `--deliver` remains as a deprecated alias for `--announce`.
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
@ -36,8 +35,8 @@ Disable delivery for an isolated job:
openclaw cron edit <job-id> --no-deliver
```
Deliver full output (instead of announce):
Announce to a specific channel:
```bash
openclaw cron edit <job-id> --deliver --channel slack --to "channel:C1234567890"
openclaw cron edit <job-id> --announce --channel slack --to "channel:C1234567890"
```

View File

@ -81,8 +81,8 @@ you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
Cron jobs panel notes:
- For isolated jobs, delivery defaults to announce summary. You can switch to legacy main summary, deliver full output, or none.
- Channel/target fields appear when announce or deliver is selected.
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
- Channel/target fields appear when announce is selected.
## Chat behavior

View File

@ -1,39 +1,39 @@
---
read_when:
- 调度后台任务或唤醒
- 配置需要与心跳一起运行或配合运行的自动化任务
- 决定计划任务使用心跳还是定时任务
summary: Gateway 网关调度器的定时任务与唤醒机制
- 配置需要与心跳一起或并行运行的自动化
- 在心跳和定时任务之间做选择
summary: Gateway网关调度器的定时任务与唤醒
title: 定时任务
x-i18n:
generated_at: "2026-02-03T07:44:30Z"
generated_at: "2026-02-01T19:37:32Z"
model: claude-opus-4-5
provider: pi
source_hash: d43268b0029f1b13d0825ddcc9c06a354987ea17ce02f3b5428a9c68bf936676
source_path: automation/cron-jobs.md
workflow: 15
workflow: 14
---
# 定时任务Gateway 网关调度器)
# 定时任务Gateway网关调度器
> **定时任务还是心跳?** 请参阅[定时任务与心跳对比](/automation/cron-vs-heartbeat)了解何时使用哪种方式。
定时任务是 Gateway 网关内置的调度器。它持久化任务,在正确的时间唤醒智能体,并可选择将输出发送回聊天。
定时任务是 Gateway网关内置的调度器。它持久化任务、在合适的时间唤醒智能体,并可选择将输出发送回聊天。
如果你需要"每天早上运行这个"或"20 分钟后触发智能体",定时任务就是实现机制。
如果你想要 _"每天早上运行"__"20 分钟后提醒智能体"_,定时任务就是对应的机制。
## 简要概述
- 定时任务运行在 **Gateway 网关内部**(不是在模型内部)。
- 定时任务运行在 **Gateway网关内部**(而非模型内部)。
- 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。
- 两种执行方式:
- **主会话**将系统事件加入队列,然后在下一次心跳时运行。
- **隔离**:在 `cron:<jobId>` 中运行专用的智能体回合,可选择发送输出
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳"。
- **主会话**入队一个系统事件,然后在下一次心跳时运行。
- **隔离**:在 `cron:<jobId>` 中运行专用智能体轮次,可投递摘要(默认 announce或不投递
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳"。
## 快速开始(可操作)
创建一个一次性提醒,验证它是否存在,然后立即运行:
创建一个一次性提醒,验证存在,然后立即运行:
```bash
openclaw cron add \
@ -49,7 +49,7 @@ openclaw cron run <job-id> --force
openclaw cron runs --id <job-id>
```
调度一个带消息发送的循环隔离任务:
调度一个带投递功能的周期性隔离任务:
```bash
openclaw cron add \
@ -58,168 +58,158 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize overnight updates." \
--deliver \
--announce \
--channel slack \
--to "channel:C1234567890"
```
## 工具调用等效项Gateway 网关定时任务工具)
## 工具调用等价形式Gateway网关定时任务工具)
有关规范的 JSON 结构和示例,请参阅[工具调用的 JSON schema](/automation/cron-jobs#json-schema-for-tool-calls)。
有关规范的 JSON 结构和示例,请参阅[工具调用的 JSON 模式](/automation/cron-jobs#json-schema-for-tool-calls)。
## 定时任务的存储位置
定时任务默认持久化存储在 Gateway 网关主机的 `~/.openclaw/cron/jobs.json`。Gateway 网关将文件加载到内存中,并在更改时写回,因此只有在 Gateway 网关停止时手动编辑才是安全的。建议使用 `openclaw cron add/edit` 或定时任务工具调用 API 进行更改。
定时任务默认持久化存储在 Gateway网关主机的 `~/.openclaw/cron/jobs.json`。Gateway网关将文件加载到内存中并在更改时写回因此仅在 Gateway网关停止时手动编辑才是安全的。请优先使用 `openclaw cron add/edit` 或定时任务工具调用 API 进行更改。
## 新手友好概述
将定时任务理解为:**何时**运行 + **做什么**。
1. **选择计划**
1. **选择调度计划**
- 一次性提醒 → `schedule.kind = "at"`CLI`--at`
- 重复任务 → `schedule.kind = "every"``schedule.kind = "cron"`
- 如果你的 ISO 时间戳省略了时区,将被视为 **UTC**
- 如果你的 ISO 时间戳省略了时区,将被视为 **UTC**
2. **选择运行位置**
- `sessionTarget: "main"` → 在下一次心跳时使用主上下文运行。
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用的智能体回合
- `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用智能体轮次
3. **选择负载**
- 主会话 → `payload.kind = "systemEvent"`
- 隔离会话 → `payload.kind = "agentTurn"`
可选:`deleteAfterRun: true` 会在成功执行后从存储中删除一次性任务。
可选:一次性任务(`schedule.kind = "at"`)默认会在成功运行后删除。设置
`deleteAfterRun: false` 可保留它(成功后会禁用)。
## 概念
### 任务
定时任务是一个存储的记录,包含:
定时任务是一条存储记录,包含:
- 一个**计划**(何时运行),
- 一个**调度计划**(何时运行),
- 一个**负载**(做什么),
- 可选的**发送**(输出发送到哪里)。
- 可选的**智能体绑定**`agentId`):在定智能体下运行任务如果缺失或未知Gateway 网关会回退到默认智能体。
- 可选的**投递**(输出发送到哪里)。
- 可选的**智能体绑定**`agentId`):在定智能体下运行任务如果缺失或未知Gateway网关会回退到默认智能体。
任务通过稳定的 `jobId` 标识(供 CLI/Gateway 网关 API 使用)。在智能体工具调用中,`jobId` 是规范名称;为了兼容性也接受旧版的 `id`。任务可以通过 `deleteAfterRun: true` 选择在一次性成功运行后自动删除。
任务通过稳定的 `jobId` 标识(用于 CLI/Gateway网关 API
在智能体工具调用中,`jobId` 是规范字段;旧版 `id` 仍可兼容使用。
一次性任务默认会在成功运行后自动删除;设置 `deleteAfterRun: false` 可保留它。
### 计划
### 调度计划
定时任务支持三种计划类型:
定时任务支持三种调度类型:
- `at`:一次性时间戳(自纪元以来的毫秒数。Gateway 网关接受 ISO 8601 并转换为 UTC
- `at`:一次性时间戳(ISO 8601 字符串)
- `every`:固定间隔(毫秒)。
- `cron`5 字段 cron 表达式,可选 IANA 时区。
- `cron`5 字段 cron 表达式,可选 IANA 时区。
Cron 表达式使用 `croner`。如果省略时区,则使用 Gateway 网关主机的本地时区。
Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主机的本地时区。
### 主会话与隔离执行
### 主会话与隔离执行
#### 主会话任务(系统事件)
任务将系统事件加入队列并可选择唤醒心跳运行器。它们必须使用 `payload.kind = "systemEvent"`
会话任务入队一个系统事件,并可选择唤醒心跳运行器。它们必须使用 `payload.kind = "systemEvent"`
- `wakeMode: "next-heartbeat"`(默认):事件等待下一次计划心跳。
- `wakeMode: "next-heartbeat"`(默认):事件等待下一次计划心跳。
- `wakeMode: "now"`:事件触发立即心跳运行。
当你需要正常的心跳提示 + 主会话上下文时,这是最佳选择。参见[心跳](/gateway/heartbeat)。
#### 隔离任务(专用定时会话)
隔离任务在会话 `cron:<jobId>` 中运行专用的智能体回合
隔离任务在会话 `cron:<jobId>` 中运行专用智能体轮次
关键行为:
- 提示以 `[cron:<jobId> <job name>]` 为前缀以便追踪。
- 每次运行启动一个**新的会话 id**(没有先前的对话延续)。
- 摘要会发布到主会话(前缀 `Cron`,可配置)。
- `wakeMode: "now"` 在发布摘要后触发立即心跳。
- 如果 `payload.deliver: true`,输出会发送到渠道;否则保持内部。
- 提示以 `[cron:<jobId> <任务名称>]` 为前缀,便于追踪。
- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。
- 如果未指定 `delivery`隔离任务会默认以“announce”方式投递摘要。
- `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。
对于嘈杂、频繁或不应该刷屏主聊天历史的"后台杂务",使用隔离任务。
对于嘈杂、频繁或"后台杂务"类任务,使用隔离任务可以避免污染你的主聊天记录
### 负载结构(运行什么
### 负载结构(运行内容
支持两种负载类型:
- `systemEvent`:仅限主会话,通过心跳提示路由。
- `agentTurn`:仅限隔离会话,运行专用的智能体回合
- `agentTurn`:仅限隔离会话,运行专用智能体轮次
见的 `agentTurn` 字段:
`agentTurn` 字段:
- `message`:必需的文本提示。
- `message`:必文本提示。
- `model` / `thinking`:可选覆盖(见下文)。
- `timeoutSeconds`:可选的超时覆盖。
- `deliver``true` 则将输出发送到渠道目标。
- `channel``last` 或特定渠道。
- `to`:特定于渠道的目标(电话/聊天/频道 id
- `bestEffortDeliver`:发送失败时避免任务失败。
- `timeoutSeconds`:可选超时覆盖。
隔离选项(仅适用于 `session=isolated`
### 模型和思维覆盖
- `postToMainPrefix`CLI`--post-prefix`):主会话中系统事件的前缀。
- `postToMainMode``summary`(默认)或 `full`
- `postToMainMaxChars`:当 `postToMainMode=full` 时的最大字符数(默认 8000
### 模型和思考覆盖
隔离任务(`agentTurn`)可以覆盖模型和思考级别:
隔离任务(`agentTurn`)可以覆盖模型和思维级别:
- `model`:提供商/模型字符串(例如 `anthropic/claude-sonnet-4-20250514`)或别名(例如 `opus`
- `thinking`:思级别(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`;仅限 GPT-5.2 + Codex 模型)
- `thinking`:思维级别(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`;仅限 GPT-5.2 + Codex 模型)
注意:你也可以在主会话任务上设置 `model`,但会更改共享的主会话模型。我们建议仅对隔离任务使用模型覆盖,以避免意外的上下文切换。
注意:你也可以在主会话任务上设置 `model`,但这会更改共享的主会话模型。我们建议仅对隔离任务使用模型覆盖,以避免意外的上下文切换。
解析优先级:
优先级解析顺序
1. 任务负载覆盖(最高)
1. 任务负载覆盖(最高优先级
2. 钩子特定默认值(例如 `hooks.gmail.model`
3. 智能体配置默认值
### 发送(渠道 + 目标)
### 投递(渠道 + 目标)
隔离任务可以将输出发送到渠道。任务负载可以指定
隔离任务可以通过顶层 `delivery` 配置投递输出
- `channel``whatsapp` / `telegram` / `discord` / `slack` / `mattermost`(插件)/ `signal` / `imessage` / `last`
- `to`:特定于渠道的接收者目标
- `delivery.mode``announce`(投递摘要)或 `none`
- `delivery.channel``whatsapp` / `telegram` / `discord` / `slack` / `mattermost`(插件)/ `signal` / `imessage` / `last`
- `delivery.to`:渠道特定的接收目标
- `delivery.bestEffort`:投递失败时避免任务失败
如果省略 `channel``to`,定时任务可以回退到主会话的"最后路由"(智能体最后回复的位置)。
当启用 announce 投递时,该轮次会抑制消息工具发送;请使用 `delivery.channel`/`delivery.to` 来指定目标
发送说明:
- 如果设置了 `to`,即使省略了 `deliver`,定时任务也会自动发送智能体的最终输出。
- 当你想要不带显式 `to` 的最后路由发送时,使用 `deliver: true`
- 使用 `deliver: false` 即使存在 `to` 也保持输出在内部。
如果省略 `delivery.channel``delivery.to`,定时任务会回退到主会话的“最后路由”(智能体最后回复的位置)。
目标格式提醒:
- Slack/Discord/Mattermost插件目标应使用显式前缀(例如 `channel:<id>`、`user:<id>`)以避免歧义。
- Telegram 话题应使用 `:topic:`式(见下文)。
- Slack/Discord/Mattermost插件目标应使用明确前缀例如 `channel:<id>`、`user:<id>`)以避免歧义。
- Telegram 主题应使用 `:topic:`式(见下文)。
#### Telegram 发送目标(话题/论坛帖子)
#### Telegram 投递目标(主题/论坛帖子)
Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发送,你可以将话题/帖子编码到 `to` 字段中:
Telegram 通过 `message_thread_id` 支持论坛主题。对于定时任务投递,你可以将主题/帖子编码到 `to` 字段中:
- `-1001234567890`(仅聊天 id
- `-1001234567890:topic:123`(推荐:显式话题标记)
- `-1001234567890`(仅聊天 ID
- `-1001234567890:topic:123`(推荐:明确的主题标记)
- `-1001234567890:123`(简写:数字后缀)
带前缀的目标如 `telegram:...` / `telegram:group:...`接受:
带前缀的目标如 `telegram:...` / `telegram:group:...`接受:
- `telegram:group:-1001234567890:topic:123`
## 工具调用的 JSON schema
## 工具调用的 JSON 模式
直接调用 Gateway 网关 `cron.*` 工具(智能体工具调用或 RPC使用这些结构。CLI 标志接受人类可读的时间格式如 `20m`,但工具调用`atMs``everyMs` 使用纪元毫秒(`at` 时间接受 ISO 时间戳)
直接调用 Gateway网关 `cron.*` 工具(智能体工具调用或 RPC使用这些结构。CLI 标志接受人类可读的时间格式如 `20m`,但工具调用应使用 ISO 8601 字符串作为 `schedule.at`,并使用毫秒作为 `schedule.everyMs`
### cron.add 参数
一次性主会话任务(系统事件):
一次性主会话任务(系统事件):
```json
{
"name": "Reminder",
"schedule": { "kind": "at", "atMs": 1738262400000 },
"schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" },
"sessionTarget": "main",
"wakeMode": "now",
"payload": { "kind": "systemEvent", "text": "Reminder text" },
@ -227,7 +217,7 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
}
```
循环,带发送的隔离任务:
带投递的周期性隔离任务:
```json
{
@ -237,22 +227,24 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
"wakeMode": "next-heartbeat",
"payload": {
"kind": "agentTurn",
"message": "Summarize overnight updates.",
"deliver": true,
"message": "Summarize overnight updates."
},
"delivery": {
"mode": "announce",
"channel": "slack",
"to": "channel:C1234567890",
"bestEffortDeliver": true
},
"isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" }
"bestEffort": true
}
}
```
说明:
- `schedule.kind``at``atMs`)、`every``everyMs`)或 `cron``expr`,可选 `tz`)。
- `atMs``everyMs` 是纪元毫秒。
- `sessionTarget` 必须是 `"main"``"isolated"` 并且必须与 `payload.kind` 匹配。
- 可选字段:`agentId`、`description`、`enabled`、`deleteAfterRun`、`isolation`。
- `schedule.kind``at``at`)、`every``everyMs`)或 `cron``expr`,可选 `tz`)。
- `schedule.at` 接受 ISO 8601可省略时区省略时按 UTC 处理)。
- `everyMs` 为毫秒数。
- `sessionTarget` 必须为 `"main"``"isolated"`,且必须与 `payload.kind` 匹配。
- 可选字段:`agentId`、`description`、`enabled`、`deleteAfterRun`、`delivery`。
- `wakeMode` 省略时默认为 `"next-heartbeat"`
### cron.update 参数
@ -269,8 +261,8 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
说明:
- `jobId` 是规范名称;为了兼容性也接受 `id`
- 在补丁中使用 `agentId: null` 清除智能体绑定。
- `jobId` 是规范字段;`id` 可兼容使用
- 在补丁中使用 `agentId: null` 清除智能体绑定。
### cron.run 和 cron.remove 参数
@ -282,9 +274,9 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
{ "jobId": "job-123" }
```
## 存储历史
## 存储历史
- 任务存储:`~/.openclaw/cron/jobs.json`Gateway 网关管理的 JSON
- 任务存储:`~/.openclaw/cron/jobs.json`Gateway网关管理的 JSON
- 运行历史:`~/.openclaw/cron/runs/<jobId>.jsonl`JSONL自动清理
- 覆盖存储路径:配置中的 `cron.store`
@ -330,7 +322,7 @@ openclaw cron add \
--wake now
```
循环隔离任务(发送到 WhatsApp
周期性隔离任务(投递到 WhatsApp
```bash
openclaw cron add \
@ -339,12 +331,12 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize inbox + calendar for today." \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
循环隔离任务(发送到 Telegram 话题):
周期性隔离任务(投递到 Telegram 主题):
```bash
openclaw cron add \
@ -353,12 +345,12 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize today; send to the nightly topic." \
--deliver \
--announce \
--channel telegram \
--to "-1001234567890:topic:123"
```
带模型和思覆盖的隔离任务:
带模型和思覆盖的隔离任务:
```bash
openclaw cron add \
@ -369,15 +361,15 @@ openclaw cron add \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
智能体选择(多智能体置):
智能体选择(多智能体置):
```bash
# 将任务绑定到智能体"ops"(如果该智能体不存在则回退到默认)
# 将任务绑定到智能体 "ops"(如果该智能体不存在则回退到默认智能体
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
# 切换或清除现有任务的智能体
@ -406,27 +398,27 @@ openclaw cron edit <jobId> \
openclaw cron runs --id <jobId> --limit 50
```
不创建任务的立即系统事件:
不创建任务直接发送系统事件:
```bash
openclaw system event --mode now --text "Next heartbeat: check battery."
```
## Gateway 网关 API 接口
## Gateway网关 API 接口
- `cron.list`、`cron.status`、`cron.add`、`cron.update`、`cron.remove`
- `cron.run`(强制或到期)、`cron.runs`
对于不创建任务的立即系统事件,使用 [`openclaw system event`](/cli/system)。
如需不创建任务直接发送系统事件,请使用 [`openclaw system event`](/cli/system)。
## 故障排除
### "什么都不运行"
### "没有任何任务运行"
- 检查定时任务是否启用:`cron.enabled` 和 `OPENCLAW_SKIP_CRON`
- 检查 Gateway 网关是否持续运行(定时任务在 Gateway 网关进程内运行)。
- 对于 `cron` 计划:确认时区(`--tz`)与主机时区的关系。
- 检查定时任务是否启用:`cron.enabled` 和 `OPENCLAW_SKIP_CRON`
- 检查 Gateway网关是否持续运行(定时任务运行在 Gateway网关进程内部)。
- 对于 `cron` 调度:确认时区(`--tz`)与主机时区的关系。
### Telegram 发送到错误的位置
### Telegram 投递到了错误的位置
- 对于论坛题,使用 `-100…:topic:<id>` 以确保明确无歧义。
- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务发送接受它们并仍然正确解析话题 ID。
- 对于论坛题,使用 `-100…:topic:<id>` 以确保明确无歧义。
- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务投递接受这些前缀并仍能正确解析主题 ID。

View File

@ -97,7 +97,7 @@ x-i18n:
- **精确定时**:支持带时区的 5 字段 cron 表达式。
- **会话隔离**:在 `cron:<jobId>` 中运行,不会污染主会话历史。
- **模型覆盖**:可按任务使用更便宜或更强大的模型。
- **投递控制**可直接投递到渠道;默认仍会向主会话发布摘要(可配置)
- **投递控制**隔离任务默认以 `announce` 投递摘要,可选 `none` 仅内部运行
- **无需智能体上下文**:即使主会话空闲或已压缩,也能运行。
- **一次性支持**`--at` 用于精确的未来时间戳。
@ -111,7 +111,7 @@ openclaw cron add \
--session isolated \
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
--model opus \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
@ -180,7 +180,7 @@ openclaw cron add \
```bash
# 每天早上 7 点的早间简报
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
# 每周一上午 9 点的项目回顾
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
@ -219,13 +219,13 @@ Lobster 是用于**多步骤工具管道**的工作流运行时,适用于需
心跳和定时任务都可以与主会话交互,但方式不同:
| | 心跳 | 定时任务(主会话) | 定时任务(隔离式) |
| ------ | ------------------------ | ---------------------- | ------------------ |
| 会话 | 主会话 | 主会话(通过系统事件) | `cron:<jobId>` |
| 历史 | 共享 | 共享 | 每次运行全新 |
| 上下文 | 完整 | 完整 | 无(从零开始) |
| 模型 | 主会话模型 | 主会话模型 | 可覆盖 |
| 输出 | 非 `HEARTBEAT_OK` 时投递 | 心跳提示 + 事件 | 摘要发布到主会话 |
| | 心跳 | 定时任务(主会话) | 定时任务(隔离式) |
| ------ | ------------------------ | ---------------------- | --------------------- |
| 会话 | 主会话 | 主会话(通过系统事件) | `cron:<jobId>` |
| 历史 | 共享 | 共享 | 每次运行全新 |
| 上下文 | 完整 | 完整 | 无(从零开始) |
| 模型 | 主会话模型 | 主会话模型 | 可覆盖 |
| 输出 | 非 `HEARTBEAT_OK` 时投递 | 心跳提示 + 事件 | announce 摘要(默认) |
### 何时使用主会话定时任务
@ -250,7 +250,7 @@ openclaw cron add \
- 无先前上下文的全新环境
- 不同的模型或思维设置
- 输出直接投递到渠道(摘要默认仍会发布到主会话
- 输出可通过 `announce` 直接投递摘要(或用 `none` 仅内部运行
- 不会把主会话搞得杂乱的历史记录
```bash
@ -261,7 +261,7 @@ openclaw cron add \
--message "Weekly codebase analysis..." \
--model opus \
--thinking high \
--deliver
--announce
```
## 成本考量

View File

@ -23,12 +23,17 @@ x-i18n:
提示:运行 `openclaw cron --help` 查看完整的命令集。
## 常用编辑
说明:隔离式 `cron add` 任务默认使用 `--announce` 投递摘要。使用 `--no-deliver` 仅内部运行。
`--deliver` 仍作为 `--announce` 的弃用别名保留。
说明:一次性(`--at`)任务成功后默认删除。使用 `--keep-after-run` 保留。
## 常见编辑
更新投递设置而不更改消息:
```bash
openclaw cron edit <job-id> --deliver --channel telegram --to "123456789"
openclaw cron edit <job-id> --announce --channel telegram --to "123456789"
```
为隔离的作业禁用投递:

View File

@ -53,6 +53,10 @@ export function createOpenClawTools(options?: {
modelHasVision?: boolean;
/** Explicit agent ID override for cron/hook sessions. */
requesterAgentIdOverride?: string;
/** Require explicit message targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
}): AnyAgentTool[] {
const imageTool = options?.agentDir?.trim()
? createImageTool({
@ -70,6 +74,20 @@ export function createOpenClawTools(options?: {
config: options?.config,
sandboxed: options?.sandboxed,
});
const messageTool = options?.disableMessageTool
? null
: createMessageTool({
agentAccountId: options?.agentAccountId,
agentSessionKey: options?.agentSessionKey,
config: options?.config,
currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.agentChannel,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
sandboxRoot: options?.sandboxRoot,
requireExplicitTarget: options?.requireExplicitMessageTarget,
});
const tools: AnyAgentTool[] = [
createBrowserTool({
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
@ -83,17 +101,7 @@ export function createOpenClawTools(options?: {
createCronTool({
agentSessionKey: options?.agentSessionKey,
}),
createMessageTool({
agentAccountId: options?.agentAccountId,
agentSessionKey: options?.agentSessionKey,
config: options?.config,
currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.agentChannel,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
sandboxRoot: options?.sandboxRoot,
}),
...(messageTool ? [messageTool] : []),
createTtsTool({
agentChannel: options?.agentChannel,
config: options?.config,

View File

@ -238,6 +238,9 @@ export async function runEmbeddedAttempt(
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
logToolSchemasForGoogle({ tools, provider: params.provider });

View File

@ -47,6 +47,10 @@ export type RunEmbeddedPiAgentParams = {
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
/** Require explicit message tool targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
sessionFile: string;
workspaceDir: string;
agentDir?: string;

View File

@ -157,6 +157,10 @@ export function createOpenClawCodingTools(options?: {
hasRepliedRef?: { value: boolean };
/** If true, the model has native vision capability */
modelHasVision?: boolean;
/** Require explicit message targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
@ -348,6 +352,8 @@ export function createOpenClawCodingTools(options?: {
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
modelHasVision: options?.modelHasVision,
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
disableMessageTool: options?.disableMessageTool,
requesterAgentIdOverride: agentId,
}),
];

View File

@ -323,10 +323,10 @@ export function buildSubagentSystemPrompt(params: {
"",
"## What You DON'T Do",
"- NO user conversations (that's main agent's job)",
"- NO external messages (email, tweets, etc.) unless explicitly tasked",
"- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel",
"- NO cron jobs or persistent state",
"- NO pretending to be the main agent",
"- NO using the `message` tool directly",
"- Only use the `message` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the main agent deliver it",
"",
"## Session Context",
params.label ? `- Label: ${params.label}` : undefined,

View File

@ -82,7 +82,7 @@ describe("cron tool", () => {
expect(call.method).toBe("cron.add");
expect(call.params).toEqual({
name: "wake-up",
schedule: { kind: "at", atMs: 123 },
schedule: { kind: "at", at: new Date(123).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
@ -95,7 +95,7 @@ describe("cron tool", () => {
action: "add",
job: {
name: "wake-up",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
agentId: null,
},
});
@ -126,7 +126,7 @@ describe("cron tool", () => {
contextMessages: 3,
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
@ -163,7 +163,7 @@ describe("cron tool", () => {
contextMessages: 20,
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
@ -194,7 +194,7 @@ describe("cron tool", () => {
action: "add",
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
payload: { text: "Reminder: the thing." },
},
});
@ -218,7 +218,7 @@ describe("cron tool", () => {
action: "add",
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},

View File

@ -174,15 +174,14 @@ JOB SCHEMA (for add action):
"name": "string (optional)",
"schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute
"delivery": { ... }, // Optional: announce/deliver output (isolated only)
"delivery": { ... }, // Optional: announce summary (isolated only)
"sessionTarget": "main" | "isolated", // Required
"enabled": true | false // Optional, default true
}
SCHEDULE TYPES (schedule.kind):
- "at": One-shot at absolute time
{ "kind": "at", "at": "<ISO-8601 timestamp>" } // preferred
{ "kind": "at", "atMs": <unix-ms-timestamp> } // also accepted
{ "kind": "at", "at": "<ISO-8601 timestamp>" }
- "every": Recurring interval
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
- "cron": Cron expression
@ -197,11 +196,9 @@ PAYLOAD TYPES (payload.kind):
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
DELIVERY (isolated-only, top-level):
{ "mode": "none|announce|deliver", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
{ "mode": "none|announce", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
LEGACY DELIVERY (payload, only when delivery is omitted):
{ "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
- If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run.
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"

View File

@ -24,6 +24,18 @@ import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
const EXPLICIT_TARGET_ACTIONS = new Set<ChannelMessageActionName>([
"send",
"sendWithEffect",
"sendAttachment",
"reply",
"thread-reply",
"broadcast",
]);
function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean {
return EXPLICIT_TARGET_ACTIONS.has(action);
}
function buildRoutingSchema() {
return {
channel: Type.Optional(Type.String()),
@ -285,6 +297,7 @@ type MessageToolOptions = {
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
sandboxRoot?: string;
requireExplicitTarget?: boolean;
};
function buildMessageToolSchema(cfg: OpenClawConfig) {
@ -394,6 +407,20 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const action = readStringParam(params, "action", {
required: true,
}) as ChannelMessageActionName;
const requireExplicitTarget = options?.requireExplicitTarget === true;
if (requireExplicitTarget && actionNeedsExplicitTarget(action)) {
const explicitTarget =
(typeof params.target === "string" && params.target.trim().length > 0) ||
(typeof params.to === "string" && params.to.trim().length > 0) ||
(typeof params.channelId === "string" && params.channelId.trim().length > 0) ||
(Array.isArray(params.targets) &&
params.targets.some((value) => typeof value === "string" && value.trim().length > 0));
if (!explicitTarget) {
throw new Error(
"Explicit message target required for this run. Provide target/targets (and channel when needed).",
);
}
}
// Validate file paths against sandbox root to prevent host file access.
const sandboxRoot = options?.sandboxRoot;

View File

@ -310,7 +310,7 @@ describe("cron cli", () => {
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.delivery?.mode).toBe("deliver");
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.channel).toBe("telegram");
expect(patch?.patch?.delivery?.to).toBe("19098680");
expect(patch?.patch?.payload?.message).toBeUndefined();
@ -408,7 +408,7 @@ describe("cron cli", () => {
// Should include everything
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.delivery?.mode).toBe("deliver");
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.channel).toBe("telegram");
expect(patch?.patch?.delivery?.to).toBe("19098680");
});
@ -428,11 +428,15 @@ describe("cron cli", () => {
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
patch?: {
payload?: { message?: string };
delivery?: { bestEffort?: boolean; mode?: string };
};
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.bestEffort).toBe(true);
});
it("includes no-best-effort delivery when provided with message", async () => {
@ -450,10 +454,14 @@ describe("cron cli", () => {
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
patch?: {
payload?: { message?: string };
delivery?: { bestEffort?: boolean; mode?: string };
};
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.bestEffort).toBe(false);
});
});

View File

@ -8,7 +8,7 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import {
getCronChannelOptions,
parseAtMs,
parseAt,
parseDurationMs,
printCronList,
warnIfCronSchedulerDisabled,
@ -82,24 +82,14 @@ export function registerCronAddCommand(cron: Command) {
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--announce", "Announce summary to a chat (subagent-style)", false)
.option(
"--deliver",
"Deliver full output to a chat (required when using last-route delivery without --to)",
)
.option("--no-deliver", "Disable delivery and skip main-session summary")
.option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.")
.option("--no-deliver", "Disable announce delivery and skip main-session summary")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
.option(
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
.option("--post-prefix <prefix>", "Prefix for main-session post", "Cron")
.option(
"--post-mode <mode>",
"What to post back to main for isolated jobs (summary|full)",
"summary",
)
.option("--post-max-chars <n>", "Max chars when --post-mode=full (default 8000)", "8000")
.option("--json", "Output JSON", false)
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
try {
@ -112,11 +102,11 @@ export function registerCronAddCommand(cron: Command) {
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
}
if (at) {
const atMs = parseAtMs(at);
if (!atMs) {
const atIso = parseAt(at);
if (!atIso) {
throw new Error("Invalid --at; use ISO time or duration like 20m");
}
return { kind: "at" as const, atMs };
return { kind: "at" as const, at: atIso };
}
if (every) {
const everyMs = parseDurationMs(every);
@ -143,12 +133,11 @@ export function registerCronAddCommand(cron: Command) {
? sanitizeAgentId(opts.agent.trim())
: undefined;
const hasAnnounce = Boolean(opts.announce);
const hasDeliver = opts.deliver === true;
const hasAnnounce = Boolean(opts.announce) || opts.deliver === true;
const hasNoDeliver = opts.deliver === false;
const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(Boolean).length;
const deliveryFlagCount = [hasAnnounce, hasNoDeliver].filter(Boolean).length;
if (deliveryFlagCount > 1) {
throw new Error("Choose at most one of --announce, --deliver, or --no-deliver");
throw new Error("Choose at most one of --announce or --no-deliver");
}
const payload = (() => {
@ -203,56 +192,16 @@ export function registerCronAddCommand(cron: Command) {
(opts.announce || typeof opts.deliver === "boolean") &&
(sessionTarget !== "isolated" || payload.kind !== "agentTurn")
) {
throw new Error("--announce/--deliver/--no-deliver require --session isolated.");
throw new Error("--announce/--no-deliver require --session isolated.");
}
const hasLegacyPostConfig =
optionSource("postPrefix") === "cli" ||
optionSource("postMode") === "cli" ||
optionSource("postMaxChars") === "cli";
if (
hasLegacyPostConfig &&
(sessionTarget !== "isolated" || payload.kind !== "agentTurn")
) {
throw new Error(
"--post-prefix/--post-mode/--post-max-chars require --session isolated.",
);
}
if (hasLegacyPostConfig && (hasAnnounce || hasDeliver || hasNoDeliver)) {
throw new Error("Choose legacy main-summary options or a delivery mode (not both).");
}
const isolation =
sessionTarget === "isolated" && hasLegacyPostConfig
? {
postToMainPrefix:
typeof opts.postPrefix === "string" && opts.postPrefix.trim()
? opts.postPrefix.trim()
: "Cron",
postToMainMode:
opts.postMode === "full" || opts.postMode === "summary"
? opts.postMode
: undefined,
postToMainMaxChars:
opts.postMode === "full" &&
typeof opts.postMaxChars === "string" &&
/^\d+$/.test(opts.postMaxChars)
? Number.parseInt(opts.postMaxChars, 10)
: undefined,
}
: undefined;
const deliveryMode =
sessionTarget === "isolated" && payload.kind === "agentTurn" && !hasLegacyPostConfig
sessionTarget === "isolated" && payload.kind === "agentTurn"
? hasAnnounce
? "announce"
: hasDeliver
? "deliver"
: hasNoDeliver
? "none"
: "announce"
: hasNoDeliver
? "none"
: "announce"
: undefined;
const nameRaw = typeof opts.name === "string" ? opts.name : "";
@ -284,11 +233,9 @@ export function registerCronAddCommand(cron: Command) {
? opts.channel.trim()
: undefined,
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
bestEffort:
deliveryMode === "deliver" && opts.bestEffortDeliver ? true : undefined,
bestEffort: opts.bestEffortDeliver ? true : undefined,
}
: undefined,
isolation,
};
const res = await callGatewayFromCli("cron.add", opts, params);

View File

@ -5,7 +5,7 @@ import { defaultRuntime } from "../../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import {
getCronChannelOptions,
parseAtMs,
parseAt,
parseDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
@ -47,11 +47,8 @@ export function registerCronEditCommand(cron: Command) {
.option("--model <model>", "Model override for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--announce", "Announce summary to a chat (subagent-style)")
.option(
"--deliver",
"Deliver full output to a chat (required when using last-route delivery without --to)",
)
.option("--no-deliver", "Disable delivery")
.option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.")
.option("--no-deliver", "Disable announce delivery")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option(
"--to <dest>",
@ -59,7 +56,6 @@ export function registerCronEditCommand(cron: Command) {
)
.option("--best-effort-deliver", "Do not fail job if delivery fails")
.option("--no-best-effort-deliver", "Fail job when delivery fails")
.option("--post-prefix <prefix>", "Prefix for summary system event")
.action(async (id, opts) => {
try {
if (opts.session === "main" && opts.message) {
@ -72,11 +68,8 @@ export function registerCronEditCommand(cron: Command) {
"Isolated jobs cannot use --system-event; use --message or --session main.",
);
}
if (opts.session === "main" && typeof opts.postPrefix === "string") {
throw new Error("--post-prefix only applies to isolated jobs.");
}
if (opts.announce && typeof opts.deliver === "boolean") {
throw new Error("Choose --announce, --deliver, or --no-deliver (not multiple).");
throw new Error("Choose --announce or --no-deliver (not multiple).");
}
const patch: Record<string, unknown> = {};
@ -125,11 +118,11 @@ export function registerCronEditCommand(cron: Command) {
throw new Error("Choose at most one schedule change");
}
if (opts.at) {
const atMs = parseAtMs(String(opts.at));
if (!atMs) {
const atIso = parseAt(String(opts.at));
if (!atIso) {
throw new Error("Invalid --at");
}
patch.schedule = { kind: "at", atMs };
patch.schedule = { kind: "at", at: atIso };
} else if (opts.every) {
const everyMs = parseDurationMs(String(opts.every));
if (!everyMs) {
@ -164,7 +157,8 @@ export function registerCronEditCommand(cron: Command) {
Boolean(thinking) ||
hasTimeoutSeconds ||
hasDeliveryModeFlag ||
(!hasDeliveryModeFlag && (hasDeliveryTarget || hasBestEffort));
hasDeliveryTarget ||
hasBestEffort;
if (hasSystemEventPatch && hasAgentTurnPatch) {
throw new Error("Choose at most one payload change");
}
@ -179,36 +173,16 @@ export function registerCronEditCommand(cron: Command) {
assignIf(payload, "model", model, Boolean(model));
assignIf(payload, "thinking", thinking, Boolean(thinking));
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
if (!hasDeliveryModeFlag) {
const channel =
typeof opts.channel === "string" && opts.channel.trim()
? opts.channel.trim()
: undefined;
const to = typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined;
assignIf(payload, "channel", channel, Boolean(channel));
assignIf(payload, "to", to, Boolean(to));
assignIf(
payload,
"bestEffortDeliver",
opts.bestEffortDeliver,
typeof opts.bestEffortDeliver === "boolean",
);
}
patch.payload = payload;
}
if (typeof opts.postPrefix === "string") {
patch.isolation = {
postToMainPrefix: opts.postPrefix.trim() ? opts.postPrefix : "Cron",
};
}
if (hasDeliveryModeFlag) {
const deliveryMode = opts.announce
? "announce"
: opts.deliver === true
? "deliver"
: "none";
if (hasDeliveryModeFlag || hasDeliveryTarget || hasBestEffort) {
const deliveryMode =
opts.announce || opts.deliver === true
? "announce"
: opts.deliver === false
? "none"
: "announce";
patch.delivery = {
mode: deliveryMode,
channel:

View File

@ -60,18 +60,18 @@ export function parseDurationMs(input: string): number | null {
return Math.floor(n * factor);
}
export function parseAtMs(input: string): number | null {
export function parseAt(input: string): string | null {
const raw = input.trim();
if (!raw) {
return null;
}
const absolute = parseAbsoluteTimeMs(raw);
if (absolute) {
return absolute;
return new Date(absolute).toISOString();
}
const dur = parseDurationMs(raw);
if (dur) {
return Date.now() + dur;
return new Date(Date.now() + dur).toISOString();
}
return null;
}
@ -97,13 +97,14 @@ const truncate = (value: string, width: number) => {
return `${value.slice(0, width - 3)}...`;
};
const formatIsoMinute = (ms: number) => {
const d = new Date(ms);
const formatIsoMinute = (iso: string) => {
const parsed = parseAbsoluteTimeMs(iso);
const d = new Date(parsed ?? NaN);
if (Number.isNaN(d.getTime())) {
return "-";
}
const iso = d.toISOString();
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
const isoStr = d.toISOString();
return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`;
};
const formatDuration = (ms: number) => {
@ -143,7 +144,7 @@ const formatRelative = (ms: number | null | undefined, nowMs: number) => {
const formatSchedule = (schedule: CronSchedule) => {
if (schedule.kind === "at") {
return `at ${formatIsoMinute(schedule.atMs)}`;
return `at ${formatIsoMinute(schedule.at)}`;
}
if (schedule.kind === "every") {
return `every ${formatDuration(schedule.everyMs)}`;

View File

@ -4,10 +4,8 @@ export type CronDeliveryPlan = {
mode: CronDeliveryMode;
channel: CronMessageChannel;
to?: string;
bestEffort: boolean;
source: "delivery" | "payload";
requested: boolean;
legacyMode?: "explicit" | "auto" | "off";
};
function normalizeChannel(value: unknown): CronMessageChannel | undefined {
@ -35,19 +33,20 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
const hasDelivery = delivery && typeof delivery === "object";
const rawMode = hasDelivery ? (delivery as { mode?: unknown }).mode : undefined;
const mode =
rawMode === "none" || rawMode === "announce" || rawMode === "deliver" ? rawMode : undefined;
rawMode === "announce"
? "announce"
: rawMode === "none"
? "none"
: rawMode === "deliver"
? "announce"
: undefined;
const payloadChannel = normalizeChannel(payload?.channel);
const payloadTo = normalizeTo(payload?.to);
const payloadBestEffort = payload?.bestEffortDeliver === true;
const deliveryChannel = normalizeChannel(
(delivery as { channel?: unknown } | undefined)?.channel,
);
const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to);
const deliveryBestEffortRaw = (delivery as { bestEffort?: unknown } | undefined)?.bestEffort;
const deliveryBestEffort =
typeof deliveryBestEffortRaw === "boolean" ? deliveryBestEffortRaw : undefined;
const channel = (deliveryChannel ?? payloadChannel ?? "last") as CronMessageChannel;
const to = deliveryTo ?? payloadTo;
@ -57,9 +56,8 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
mode: resolvedMode,
channel,
to,
bestEffort: deliveryBestEffort ?? false,
source: "delivery",
requested: resolvedMode !== "none",
requested: resolvedMode === "announce",
};
}
@ -69,12 +67,10 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
const requested = legacyMode === "explicit" || (legacyMode === "auto" && hasExplicitTarget);
return {
mode: requested ? "deliver" : "none",
mode: requested ? "announce" : "none",
channel,
to,
bestEffort: payloadBestEffort,
source: "payload",
requested,
legacyMode,
};
}

View File

@ -14,9 +14,13 @@ vi.mock("../agents/pi-embedded.js", () => ({
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
}));
vi.mock("../agents/subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(),
}));
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
@ -67,6 +71,7 @@ function makeJob(payload: CronJob["payload"]): CronJob {
const now = Date.now();
return {
id: "job-1",
name: "job-1",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
@ -75,7 +80,6 @@ function makeJob(payload: CronJob["payload"]): CronJob {
wakeMode: "now",
payload,
state: {},
isolation: { postToMainPrefix: "Cron" },
};
}
@ -83,6 +87,7 @@ describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValue([]);
vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true);
});
it("delivers when response has HEARTBEAT_OK but includes media", async () => {
@ -110,24 +115,20 @@ describe("runCronIsolatedAgentTurn", () => {
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "123",
}),
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"HEARTBEAT_OK",
expect.objectContaining({ mediaUrl: "https://example.com/img.png" }),
);
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
});
@ -164,20 +165,20 @@ describe("runCronIsolatedAgentTurn", () => {
const res = await runCronIsolatedAgentTurn({
cfg,
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "123",
}),
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalled();
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -23,9 +23,13 @@ vi.mock("../agents/pi-embedded.js", () => ({
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
}));
vi.mock("../agents/subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(),
}));
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
@ -76,6 +80,7 @@ function makeJob(payload: CronJob["payload"]): CronJob {
const now = Date.now();
return {
id: "job-1",
name: "job-1",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
@ -84,7 +89,6 @@ function makeJob(payload: CronJob["payload"]): CronJob {
wakeMode: "now",
payload,
state: {},
isolation: { postToMainPrefix: "Cron" },
};
}
@ -92,6 +96,7 @@ describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValue([]);
vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true);
const runtime = createPluginRuntime();
setDiscordRuntime(runtime);
setTelegramRuntime(runtime);
@ -105,7 +110,7 @@ describe("runCronIsolatedAgentTurn", () => {
);
});
it("skips delivery without a WhatsApp recipient when bestEffortDeliver=true", async () => {
it("announces when delivery is requested", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
@ -116,7 +121,7 @@ describe("runCronIsolatedAgentTurn", () => {
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello" }],
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
@ -124,148 +129,32 @@ describe("runCronIsolatedAgentTurn", () => {
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "whatsapp",
bestEffortDeliver: true,
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("skipped");
expect(String(res.summary ?? "")).toMatch(/delivery skipped/i);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
expect(res.status).toBe("ok");
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
const call = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0];
expect(call?.label).toBe("Cron: job-1");
});
});
it("delivers telegram via channel send", async () => {
it("skips announce when messaging tool already sent to target", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "123",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hello from cron",
expect.objectContaining({ verbose: false }),
);
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
}
});
});
it("auto-delivers when explicit target is set without deliver flag", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
channel: "telegram",
to: "123",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hello from cron",
expect.objectContaining({ verbose: false }),
);
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
}
});
});
it("skips auto-delivery when messaging tool already sent to the target", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
@ -280,181 +169,31 @@ describe("runCronIsolatedAgentTurn", () => {
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
});
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
channel: "telegram",
to: "123",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
}
});
});
it("delivers telegram topic targets via channel send", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "-1001234567890",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "telegram:group:-1001234567890:topic:321",
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"telegram:group:-1001234567890:topic:321",
"hello from cron",
expect.objectContaining({ verbose: false }),
);
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
});
it("delivers telegram shorthand topic suffixes via channel send", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "-1001234567890",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "-1001234567890:321",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"-1001234567890:321",
"hello from cron",
expect.objectContaining({ verbose: false }),
);
});
});
it("delivers via discord when configured", async () => {
it("skips announce for heartbeat-only output", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn().mockResolvedValue({
messageId: "d1",
channelId: "chan",
}),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "discord",
to: "channel:1122",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
"channel:1122",
"hello from cron",
expect.objectContaining({ verbose: false }),
);
});
});
it("skips delivery when response is exactly HEARTBEAT_OK", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
@ -467,112 +206,22 @@ describe("runCronIsolatedAgentTurn", () => {
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "123",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
// Job still succeeds, but no delivery happens.
expect(res.status).toBe("ok");
expect(res.summary).toBe("HEARTBEAT_OK");
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
});
});
it("skips delivery when response has HEARTBEAT_OK with short padding", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn().mockResolvedValue({
messageId: "w1",
chatId: "+1234",
}),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
// Short junk around HEARTBEAT_OK (<=30 chars) should still skip delivery.
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { whatsapp: { allowFrom: ["+1234"] } },
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "whatsapp",
to: "+1234",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
});
it("delivers when response has HEARTBEAT_OK but also substantial content", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
// Long content after HEARTBEAT_OK should still be delivered.
const longContent = `Important alert: ${"a".repeat(500)}`;
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: `HEARTBEAT_OK ${longContent}` }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "123",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalled();
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
});
});

View File

@ -81,7 +81,6 @@ function makeJob(payload: CronJob["payload"]): CronJob {
wakeMode: "now",
payload,
state: {},
isolation: { postToMainPrefix: "Cron" },
};
}
@ -542,46 +541,6 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("fails delivery without a WhatsApp recipient when bestEffortDeliver=false", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "whatsapp",
bestEffortDeliver: false,
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("error");
expect(res.summary).toBe("hello");
expect(String(res.error ?? "")).toMatch(/requires a recipient/i);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
});
it("starts a fresh session id for each cron run", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);

View File

@ -44,14 +44,13 @@ import {
normalizeVerboseLevel,
supportsXHighThinking,
} from "../../auto-reply/thinking.js";
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
import { type CliDeps } from "../../cli/outbound-send-deps.js";
import {
resolveAgentMainSessionKey,
resolveSessionTranscriptPath,
updateSessionStore,
} from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { logWarn } from "../../logger.js";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
@ -242,9 +241,6 @@ export async function runCronIsolatedAgentTurn(params: {
const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null;
const deliveryPlan = resolveCronDeliveryPlan(params.job);
const deliveryRequested = deliveryPlan.requested;
const bestEffortDeliver = deliveryPlan.bestEffort;
const legacyDeliveryMode =
deliveryPlan.source === "payload" ? deliveryPlan.legacyMode : undefined;
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, {
channel: deliveryPlan.channel ?? "last",
@ -294,6 +290,10 @@ export async function runCronIsolatedAgentTurn(params: {
// Internal/trusted source - use original format
commandBody = `${base}\n${timeLine}`.trim();
}
if (deliveryRequested) {
commandBody =
`${commandBody}\n\nDo not send messages via messaging tools. Return your summary as plain text; delivery is handled automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim();
}
const existingSnapshot = cronSession.sessionEntry.skillsSnapshot;
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
@ -380,6 +380,8 @@ export async function runCronIsolatedAgentTurn(params: {
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId: cronSession.sessionEntry.sessionId,
requireExplicitMessageTarget: true,
disableMessageTool: deliveryRequested,
});
},
});
@ -432,7 +434,6 @@ export async function runCronIsolatedAgentTurn(params: {
const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars);
const skipMessagingToolDelivery =
deliveryRequested &&
legacyDeliveryMode === "auto" &&
runResult.didSendViaMessagingTool === true &&
(runResult.messagingToolSentTargets ?? []).some((target) =>
matchesMessagingToolDeliveryTarget(target, {
@ -443,71 +444,35 @@ export async function runCronIsolatedAgentTurn(params: {
);
if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) {
if (deliveryPlan.mode === "announce") {
const requesterSessionKey = resolveAgentMainSessionKey({
cfg: cfgWithAgentDefaults,
agentId,
});
const useExplicitOrigin = deliveryPlan.channel !== "last" || Boolean(deliveryPlan.to?.trim());
const requesterOrigin = useExplicitOrigin
? {
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId,
}
: undefined;
const outcome: SubagentRunOutcome = { status: "ok" };
const taskLabel = params.job.name?.trim() || "cron job";
await runSubagentAnnounceFlow({
childSessionKey: agentSessionKey,
childRunId: cronSession.sessionEntry.sessionId,
requesterSessionKey,
requesterOrigin,
requesterDisplayKey: requesterSessionKey,
task: taskLabel,
timeoutMs: 30_000,
cleanup: "keep",
roundOneReply: outputText ?? summary,
waitForCompletion: false,
label: `Cron: ${taskLabel}`,
outcome,
});
} else {
if (!resolvedDelivery.to) {
const reason =
resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
if (!bestEffortDeliver) {
return {
status: "error",
summary,
outputText,
error: reason,
};
}
return {
status: "skipped",
summary: `Delivery skipped (${reason}).`,
outputText,
};
}
try {
await deliverOutboundPayloads({
cfg: cfgWithAgentDefaults,
const requesterSessionKey = resolveAgentMainSessionKey({
cfg: cfgWithAgentDefaults,
agentId,
});
const useExplicitOrigin = deliveryPlan.channel !== "last" || Boolean(deliveryPlan.to?.trim());
const requesterOrigin = useExplicitOrigin
? {
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
payloads,
bestEffort: bestEffortDeliver,
deps: createOutboundSendDeps(params.deps),
});
} catch (err) {
if (!bestEffortDeliver) {
return { status: "error", summary, outputText, error: String(err) };
threadId: resolvedDelivery.threadId,
}
return { status: "ok", summary, outputText };
}
}
: undefined;
const outcome: SubagentRunOutcome = { status: "ok" };
const taskLabel = params.job.name?.trim() || "cron job";
await runSubagentAnnounceFlow({
childSessionKey: agentSessionKey,
childRunId: cronSession.sessionEntry.sessionId,
requesterSessionKey,
requesterOrigin,
requesterDisplayKey: requesterSessionKey,
task: taskLabel,
timeoutMs: 30_000,
cleanup: "keep",
roundOneReply: outputText ?? summary,
waitForCompletion: false,
label: `Cron: ${taskLabel}`,
outcome,
});
}
return { status: "ok", summary, outputText };

View File

@ -75,7 +75,7 @@ describe("normalizeCronJobCreate", () => {
expect(payload.channel).toBe("telegram");
});
it("coerces ISO schedule.at to atMs (UTC)", () => {
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
const normalized = normalizeCronJobCreate({
name: "iso at",
enabled: true,
@ -90,10 +90,10 @@ describe("normalizeCronJobCreate", () => {
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("at");
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString());
});
it("coerces ISO schedule.atMs string to atMs (UTC)", () => {
it("coerces schedule.atMs string to schedule.at (UTC)", () => {
const normalized = normalizeCronJobCreate({
name: "iso atMs",
enabled: true,
@ -108,7 +108,7 @@ describe("normalizeCronJobCreate", () => {
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("at");
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString());
});
it("defaults deleteAfterRun for one-shot schedules", () => {
@ -166,7 +166,7 @@ describe("normalizeCronJobCreate", () => {
expect(delivery.mode).toBe("announce");
});
it("does not override explicit legacy delivery fields", () => {
it("migrates legacy delivery fields to delivery", () => {
const normalized = normalizeCronJobCreate({
name: "legacy deliver",
enabled: true,
@ -175,14 +175,38 @@ describe("normalizeCronJobCreate", () => {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "telegram",
to: "7200373102",
bestEffortDeliver: true,
},
}) as unknown as Record<string, unknown>;
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe("telegram");
expect(delivery.to).toBe("7200373102");
expect(delivery.bestEffort).toBe(true);
});
it("maps legacy deliver=false to delivery none", () => {
const normalized = normalizeCronJobCreate({
name: "legacy off",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
payload: {
kind: "agentTurn",
message: "hi",
deliver: false,
channel: "telegram",
to: "7200373102",
},
}) as unknown as Record<string, unknown>;
expect(normalized.delivery).toBeUndefined();
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("none");
});
it("does not override legacy isolation settings", () => {
it("migrates legacy isolation settings to announce delivery", () => {
const normalized = normalizeCronJobCreate({
name: "legacy isolation",
enabled: true,
@ -194,6 +218,8 @@ describe("normalizeCronJobCreate", () => {
isolation: { postToMainPrefix: "Cron" },
}) as unknown as Record<string, unknown>;
expect(normalized.delivery).toBeUndefined();
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect((normalized as { isolation?: unknown }).isolation).toBeUndefined();
});
});

View File

@ -22,12 +22,15 @@ function coerceSchedule(schedule: UnknownRecord) {
const kind = typeof schedule.kind === "string" ? schedule.kind : undefined;
const atMsRaw = schedule.atMs;
const atRaw = schedule.at;
const atString = typeof atRaw === "string" ? atRaw.trim() : "";
const parsedAtMs =
typeof atMsRaw === "string"
? parseAbsoluteTimeMs(atMsRaw)
: typeof atRaw === "string"
? parseAbsoluteTimeMs(atRaw)
: null;
typeof atMsRaw === "number"
? atMsRaw
: typeof atMsRaw === "string"
? parseAbsoluteTimeMs(atMsRaw)
: atString
? parseAbsoluteTimeMs(atString)
: null;
if (!kind) {
if (
@ -43,12 +46,13 @@ function coerceSchedule(schedule: UnknownRecord) {
}
}
if (typeof schedule.atMs !== "number" && parsedAtMs !== null) {
next.atMs = parsedAtMs;
if (atString) {
next.at = parsedAtMs ? new Date(parsedAtMs).toISOString() : atString;
} else if (parsedAtMs !== null) {
next.at = new Date(parsedAtMs).toISOString();
}
if ("at" in next) {
delete next.at;
if ("atMs" in next) {
delete next.atMs;
}
return next;
@ -64,7 +68,8 @@ function coercePayload(payload: UnknownRecord) {
function coerceDelivery(delivery: UnknownRecord) {
const next: UnknownRecord = { ...delivery };
if (typeof delivery.mode === "string") {
next.mode = delivery.mode.trim().toLowerCase();
const mode = delivery.mode.trim().toLowerCase();
next.mode = mode === "deliver" ? "announce" : mode;
}
if (typeof delivery.channel === "string") {
const trimmed = delivery.channel.trim().toLowerCase();
@ -98,6 +103,40 @@ function hasLegacyDeliveryHints(payload: UnknownRecord) {
return false;
}
function buildDeliveryFromLegacyPayload(payload: UnknownRecord): UnknownRecord {
const deliver = payload.deliver;
const mode = deliver === false ? "none" : "announce";
const channelRaw =
typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
const next: UnknownRecord = { mode };
if (channelRaw) {
next.channel = channelRaw;
}
if (toRaw) {
next.to = toRaw;
}
if (typeof payload.bestEffortDeliver === "boolean") {
next.bestEffort = payload.bestEffortDeliver;
}
return next;
}
function stripLegacyDeliveryFields(payload: UnknownRecord) {
if ("deliver" in payload) {
delete payload.deliver;
}
if ("channel" in payload) {
delete payload.channel;
}
if ("to" in payload) {
delete payload.to;
}
if ("bestEffortDeliver" in payload) {
delete payload.bestEffortDeliver;
}
}
function unwrapJob(raw: UnknownRecord) {
if (isRecord(raw.data)) {
return raw.data;
@ -159,6 +198,10 @@ export function normalizeCronJobInput(
next.delivery = coerceDelivery(base.delivery);
}
if (isRecord(base.isolation)) {
delete next.isolation;
}
if (options.applyDefaults) {
if (!next.wakeMode) {
next.wakeMode = "next-heartbeat";
@ -180,20 +223,20 @@ export function normalizeCronJobInput(
) {
next.deleteAfterRun = true;
}
const hasDelivery = "delivery" in next && next.delivery !== undefined;
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 : "";
const hasLegacyIsolation = isRecord(next.isolation);
const isIsolatedAgentTurn =
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn");
const hasDelivery = "delivery" in next && next.delivery !== undefined;
const hasLegacyDelivery = payload ? hasLegacyDeliveryHints(payload) : false;
if (
!hasDelivery &&
!hasLegacyIsolation &&
!hasLegacyDelivery &&
sessionTarget === "isolated" &&
payloadKind === "agentTurn"
) {
next.delivery = { mode: "announce" };
if (!hasDelivery && isIsolatedAgentTurn && payloadKind === "agentTurn") {
if (payload && hasLegacyDelivery) {
next.delivery = buildDeliveryFromLegacyPayload(payload);
stripLegacyDeliveryFields(payload);
} else {
next.delivery = { mode: "announce" };
}
}
}

View File

@ -1,9 +1,14 @@
import { Cron } from "croner";
import type { CronSchedule } from "./types.js";
import { parseAbsoluteTimeMs } from "./parse.js";
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
return schedule.atMs > nowMs ? schedule.atMs : undefined;
const atMs = parseAbsoluteTimeMs(schedule.at);
if (atMs === null) {
return undefined;
}
return atMs > nowMs ? atMs : undefined;
}
if (schedule.kind === "every") {

View File

@ -55,7 +55,7 @@ describe("CronService", () => {
await cronA.add({
name: "shared store job",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },

View File

@ -56,7 +56,7 @@ describe("CronService", () => {
name: "one-shot hello",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
@ -99,7 +99,7 @@ describe("CronService", () => {
const job = await cron.add({
name: "one-shot delete",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
@ -153,7 +153,7 @@ describe("CronService", () => {
const job = await cron.add({
name: "wakeMode now waits",
enabled: true,
schedule: { kind: "at", atMs: 1 },
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
@ -208,7 +208,7 @@ describe("CronService", () => {
await cron.add({
enabled: true,
name: "weekly",
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "do it", deliver: false },
@ -352,7 +352,7 @@ describe("CronService", () => {
await cron.add({
name: "isolated error test",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "do it", deliver: false },
@ -427,7 +427,7 @@ describe("CronService", () => {
enabled: true,
createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "agentTurn", message: "bad" },

View File

@ -54,7 +54,7 @@ describe("CronService", () => {
await cron.add({
name: "empty systemEvent test",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: " " },
@ -93,7 +93,7 @@ describe("CronService", () => {
await cron.add({
name: "disabled cron job",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
@ -133,7 +133,7 @@ describe("CronService", () => {
await cron.add({
name: "status next wake",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },

View File

@ -0,0 +1,101 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadCronStore } from "../store.js";
import { CronService } from "./service.js";
const noopLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-migrate-"));
return {
dir,
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
describe("cron store migration", () => {
beforeEach(() => {
noopLogger.debug.mockClear();
noopLogger.info.mockClear();
noopLogger.warn.mockClear();
noopLogger.error.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("migrates isolated jobs to announce delivery and drops isolation", async () => {
const store = await makeStorePath();
const atMs = 1_700_000_000_000;
const legacyJob = {
id: "job-1",
agentId: undefined,
name: "Legacy job",
description: null,
enabled: true,
deleteAfterRun: false,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000,
schedule: { kind: "at", atMs },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "telegram",
to: "7200373102",
bestEffortDeliver: true,
},
isolation: { postToMainPrefix: "Cron" },
state: {},
};
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [legacyJob] }, null, 2));
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
cron.stop();
const loaded = await loadCronStore(store.storePath);
const migrated = loaded.jobs[0] as Record<string, unknown>;
expect(migrated.delivery).toEqual({
mode: "announce",
channel: "telegram",
to: "7200373102",
bestEffort: true,
});
expect("isolation" in migrated).toBe(false);
const payload = migrated.payload as Record<string, unknown>;
expect(payload.deliver).toBeUndefined();
expect(payload.channel).toBeUndefined();
expect(payload.to).toBeUndefined();
expect(payload.bestEffortDeliver).toBeUndefined();
const schedule = migrated.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("at");
expect(schedule.at).toBe(new Date(atMs).toISOString());
await store.cleanup();
});
});

View File

@ -9,6 +9,7 @@ import type {
CronPayloadPatch,
} from "../types.js";
import type { CronServiceState } from "./state.js";
import { parseAbsoluteTimeMs } from "../parse.js";
import { computeNextRunAtMs } from "../schedule.js";
import {
normalizeOptionalAgentId,
@ -51,7 +52,8 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
return undefined;
}
return job.schedule.atMs;
const atMs = parseAbsoluteTimeMs(job.schedule.at);
return atMs !== null ? atMs : undefined;
}
return computeNextRunAtMs(job.schedule, nowMs);
}
@ -117,7 +119,6 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
wakeMode: input.wakeMode,
payload: input.payload,
delivery: input.delivery,
isolation: input.isolation,
state: {
...input.state,
},
@ -156,9 +157,6 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if (patch.delivery) {
job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
}
if (patch.isolation) {
job.isolation = patch.isolation;
}
if (patch.state) {
job.state = { ...job.state, ...patch.state };
}
@ -251,7 +249,7 @@ function mergeCronDelivery(
};
if (typeof patch.mode === "string") {
next.mode = patch.mode;
next.mode = patch.mode === "deliver" ? "announce" : patch.mode;
}
if ("channel" in patch) {
const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";

View File

@ -1,11 +1,59 @@
import fs from "node:fs";
import type { CronJob } from "../types.js";
import type { CronServiceState } from "./state.js";
import { parseAbsoluteTimeMs } from "../parse.js";
import { migrateLegacyCronPayload } from "../payload-migration.js";
import { loadCronStore, saveCronStore } from "../store.js";
import { recomputeNextRuns } from "./jobs.js";
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
function hasLegacyDeliveryHints(payload: Record<string, unknown>) {
if (typeof payload.deliver === "boolean") {
return true;
}
if (typeof payload.bestEffortDeliver === "boolean") {
return true;
}
if (typeof payload.to === "string" && payload.to.trim()) {
return true;
}
return false;
}
function buildDeliveryFromLegacyPayload(payload: Record<string, unknown>) {
const deliver = payload.deliver;
const mode = deliver === false ? "none" : "announce";
const channelRaw =
typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
const next: Record<string, unknown> = { mode };
if (channelRaw) {
next.channel = channelRaw;
}
if (toRaw) {
next.to = toRaw;
}
if (typeof payload.bestEffortDeliver === "boolean") {
next.bestEffort = payload.bestEffortDeliver;
}
return next;
}
function stripLegacyDeliveryFields(payload: Record<string, unknown>) {
if ("deliver" in payload) {
delete payload.deliver;
}
if ("channel" in payload) {
delete payload.channel;
}
if ("to" in payload) {
delete payload.to;
}
if ("bestEffortDeliver" in payload) {
delete payload.bestEffortDeliver;
}
}
async function getFileMtimeMs(path: string): Promise<number | null> {
try {
const stats = await fs.promises.stat(path);
@ -59,6 +107,78 @@ export async function ensureLoaded(state: CronServiceState) {
mutated = true;
}
}
const schedule = raw.schedule;
if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) {
const sched = schedule as Record<string, unknown>;
const kind = typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : "";
if (!kind && ("at" in sched || "atMs" in sched)) {
sched.kind = "at";
mutated = true;
}
const atRaw = typeof sched.at === "string" ? sched.at.trim() : "";
const atMsRaw = sched.atMs;
const parsedAtMs =
typeof atMsRaw === "number"
? atMsRaw
: typeof atMsRaw === "string"
? parseAbsoluteTimeMs(atMsRaw)
: atRaw
? parseAbsoluteTimeMs(atRaw)
: null;
if (parsedAtMs !== null) {
sched.at = new Date(parsedAtMs).toISOString();
if ("atMs" in sched) {
delete sched.atMs;
}
mutated = true;
}
}
const delivery = raw.delivery;
if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) {
const modeRaw = (delivery as { mode?: unknown }).mode;
if (typeof modeRaw === "string") {
const lowered = modeRaw.trim().toLowerCase();
if (lowered === "deliver") {
(delivery as { mode?: unknown }).mode = "announce";
mutated = true;
}
}
}
const isolation = raw.isolation;
if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) {
delete raw.isolation;
mutated = true;
}
const payloadRecord =
payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
const payloadKind =
payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : "";
const sessionTarget =
typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : "";
const isIsolatedAgentTurn =
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn");
const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery);
const hasLegacyDelivery = payloadRecord ? hasLegacyDeliveryHints(payloadRecord) : false;
if (isIsolatedAgentTurn && payloadKind === "agentTurn") {
if (!hasDelivery) {
raw.delivery =
payloadRecord && hasLegacyDelivery
? buildDeliveryFromLegacyPayload(payloadRecord)
: { mode: "announce" };
mutated = true;
}
if (payloadRecord && hasLegacyDelivery) {
stripLegacyDeliveryFields(payloadRecord);
mutated = true;
}
}
}
state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
state.storeLoadedAtMs = state.deps.nowMs();

View File

@ -80,12 +80,7 @@ export async function executeJob(
let deleted = false;
const finish = async (
status: "ok" | "error" | "skipped",
err?: string,
summary?: string,
outputText?: string,
) => {
const finish = async (status: "ok" | "error" | "skipped", err?: string, summary?: string) => {
const endedAt = state.deps.nowMs();
job.state.runningAtMs = undefined;
job.state.lastRunAtMs = startedAt;
@ -124,30 +119,6 @@ export async function executeJob(
deleted = true;
emit(state, { jobId: job.id, action: "removed" });
}
if (job.sessionTarget === "isolated" && !job.delivery) {
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
const mode = job.isolation?.postToMainMode ?? "summary";
let body = (summary ?? err ?? status).trim();
if (mode === "full") {
// Prefer full agent output if available; fall back to summary.
const maxCharsRaw = job.isolation?.postToMainMaxChars;
const maxChars = Number.isFinite(maxCharsRaw) ? Math.max(0, maxCharsRaw as number) : 8000;
const fullText = (outputText ?? "").trim();
if (fullText) {
body = fullText.length > maxChars ? `${fullText.slice(0, maxChars)}` : fullText;
}
}
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
agentId: job.agentId,
});
if (job.wakeMode === "now") {
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
}
}
};
try {
@ -214,11 +185,11 @@ export async function executeJob(
message: job.payload.message,
});
if (res.status === "ok") {
await finish("ok", undefined, res.summary, res.outputText);
await finish("ok", undefined, res.summary);
} else if (res.status === "skipped") {
await finish("skipped", undefined, res.summary, res.outputText);
await finish("skipped", undefined, res.summary);
} else {
await finish("error", res.error ?? "cron job failed", res.summary, res.outputText);
await finish("error", res.error ?? "cron job failed", res.summary);
}
} catch (err) {
await finish("error", String(err));

View File

@ -1,7 +1,7 @@
import type { ChannelId } from "../channels/plugins/types.js";
export type CronSchedule =
| { kind: "at"; atMs: number }
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
@ -10,7 +10,7 @@ export type CronWakeMode = "next-heartbeat" | "now";
export type CronMessageChannel = ChannelId | "last";
export type CronDeliveryMode = "none" | "announce" | "deliver";
export type CronDeliveryMode = "none" | "announce";
export type CronDelivery = {
mode: CronDeliveryMode;
@ -52,18 +52,6 @@ export type CronPayloadPatch =
bestEffortDeliver?: boolean;
};
export type CronIsolation = {
postToMainPrefix?: string;
/**
* What to post back into the main session after an isolated run.
* - summary: small status/summary line (default)
* - full: the agent's final text output (optionally truncated)
*/
postToMainMode?: "summary" | "full";
/** Max chars when postToMainMode="full". Default: 8000. */
postToMainMaxChars?: number;
};
export type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
@ -87,7 +75,6 @@ export type CronJob = {
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
isolation?: CronIsolation;
state: CronJobState;
};

View File

@ -1,4 +1,5 @@
import type { CronSchedule } from "./types.js";
import { parseAbsoluteTimeMs } from "./parse.js";
const ONE_MINUTE_MS = 60 * 1000;
const TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1000;
@ -15,7 +16,7 @@ export type TimestampValidationSuccess = {
export type TimestampValidationResult = TimestampValidationSuccess | TimestampValidationError;
/**
* Validates atMs timestamps in cron schedules.
* Validates at timestamps in cron schedules.
* Rejects timestamps that are:
* - More than 1 minute in the past
* - More than 10 years in the future
@ -28,12 +29,13 @@ export function validateScheduleTimestamp(
return { ok: true };
}
const atMs = schedule.atMs;
const atRaw = typeof schedule.at === "string" ? schedule.at.trim() : "";
const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null;
if (typeof atMs !== "number" || !Number.isFinite(atMs)) {
if (atMs === null || !Number.isFinite(atMs)) {
return {
ok: false,
message: `Invalid atMs: must be a finite number (got ${String(atMs)})`,
message: `Invalid schedule.at: expected ISO-8601 timestamp (got ${String(schedule.at)})`,
};
}
@ -46,7 +48,7 @@ export function validateScheduleTimestamp(
const minutesAgo = Math.floor(-diffMs / ONE_MINUTE_MS);
return {
ok: false,
message: `atMs is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`,
message: `schedule.at is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`,
};
}
@ -56,7 +58,7 @@ export function validateScheduleTimestamp(
const yearsAhead = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000));
return {
ok: false,
message: `atMs is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`,
message: `schedule.at is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`,
};
}

View File

@ -5,7 +5,7 @@ export const CronScheduleSchema = Type.Union([
Type.Object(
{
kind: Type.Literal("at"),
atMs: Type.Integer({ minimum: 0 }),
at: NonEmptyString,
},
{ additionalProperties: false },
),
@ -77,7 +77,7 @@ export const CronPayloadPatchSchema = Type.Union([
export const CronDeliverySchema = Type.Object(
{
mode: Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]),
mode: Type.Union([Type.Literal("none"), Type.Literal("announce")]),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffort: Type.Optional(Type.Boolean()),
@ -87,9 +87,7 @@ export const CronDeliverySchema = Type.Object(
export const CronDeliveryPatchSchema = Type.Object(
{
mode: Type.Optional(
Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]),
),
mode: Type.Optional(Type.Union([Type.Literal("none"), Type.Literal("announce")])),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffort: Type.Optional(Type.Boolean()),
@ -97,15 +95,6 @@ export const CronDeliveryPatchSchema = Type.Object(
{ additionalProperties: false },
);
export const CronIsolationSchema = Type.Object(
{
postToMainPrefix: Type.Optional(Type.String()),
postToMainMode: Type.Optional(Type.Union([Type.Literal("summary"), Type.Literal("full")])),
postToMainMaxChars: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const CronJobStateSchema = Type.Object(
{
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
@ -135,7 +124,6 @@ export const CronJobSchema = Type.Object(
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema,
delivery: Type.Optional(CronDeliverySchema),
isolation: Type.Optional(CronIsolationSchema),
state: CronJobStateSchema,
},
{ additionalProperties: false },
@ -162,7 +150,6 @@ export const CronAddParamsSchema = Type.Object(
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema,
delivery: Type.Optional(CronDeliverySchema),
isolation: Type.Optional(CronIsolationSchema),
},
{ additionalProperties: false },
);
@ -179,7 +166,6 @@ export const CronJobPatchSchema = Type.Object(
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
payload: Type.Optional(CronPayloadPatchSchema),
delivery: Type.Optional(CronDeliveryPatchSchema),
isolation: Type.Optional(CronIsolationSchema),
state: Type.Optional(Type.Partial(CronJobStateSchema)),
},
{ additionalProperties: false },

View File

@ -89,7 +89,7 @@ describe("gateway server cron", () => {
const routeRes = await rpcReq(ws, "cron.add", {
name: "route test",
enabled: true,
schedule: { kind: "at", atMs: routeAtMs },
schedule: { kind: "at", at: new Date(routeAtMs).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "cron route check" },
@ -108,7 +108,7 @@ describe("gateway server cron", () => {
const wrappedRes = await rpcReq(ws, "cron.add", {
data: {
name: "wrapped",
schedule: { atMs: wrappedAtMs },
schedule: { at: new Date(wrappedAtMs).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
},
});
@ -137,7 +137,7 @@ describe("gateway server cron", () => {
const updateRes = await rpcReq(ws, "cron.update", {
id: patchJobId,
patch: {
schedule: { atMs },
schedule: { at: new Date(atMs).toISOString() },
payload: { kind: "systemEvent", text: "updated" },
},
});
@ -224,7 +224,7 @@ describe("gateway server cron", () => {
const jobIdUpdateRes = await rpcReq(ws, "cron.update", {
jobId,
patch: {
schedule: { atMs: Date.now() + 2_000 },
schedule: { at: new Date(Date.now() + 2_000).toISOString() },
payload: { kind: "systemEvent", text: "updated" },
},
});
@ -282,7 +282,7 @@ describe("gateway server cron", () => {
const addRes = await rpcReq(ws, "cron.add", {
name: "log test",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
@ -331,7 +331,7 @@ describe("gateway server cron", () => {
const autoRes = await rpcReq(ws, "cron.add", {
name: "auto run test",
enabled: true,
schedule: { kind: "at", atMs: Date.now() - 10 },
schedule: { kind: "at", at: new Date(Date.now() - 10).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "auto" },

View File

@ -52,7 +52,7 @@ export function createGatewayHooksRequestHandler(params: {
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "at", atMs: now },
schedule: { kind: "at", at: new Date(now).toISOString() },
sessionTarget: "isolated",
wakeMode: value.wakeMode,
payload: {

View File

@ -29,5 +29,4 @@ export const DEFAULT_CRON_FORM: CronFormState = {
deliveryChannel: "last",
deliveryTo: "",
timeoutSeconds: "",
postToMainPrefix: "Cron",
};

View File

@ -55,7 +55,7 @@ export function buildCronSchedule(form: CronFormState) {
if (!Number.isFinite(ms)) {
throw new Error("Invalid run time.");
}
return { kind: "at" as const, atMs: ms };
return { kind: "at" as const, at: new Date(ms).toISOString() };
}
if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0);
@ -109,19 +109,13 @@ export async function addCronJob(state: CronState) {
const delivery =
state.cronForm.sessionTarget === "isolated" &&
state.cronForm.payloadKind === "agentTurn" &&
state.cronForm.deliveryMode !== "legacy"
state.cronForm.deliveryMode
? {
mode:
state.cronForm.deliveryMode === "announce"
? "announce"
: state.cronForm.deliveryMode === "deliver"
? "deliver"
: "none",
mode: state.cronForm.deliveryMode === "announce" ? "announce" : "none",
channel: state.cronForm.deliveryChannel.trim() || "last",
to: state.cronForm.deliveryTo.trim() || undefined,
}
: undefined;
const legacyPrefix = state.cronForm.postToMainPrefix.trim() || "Cron";
const agentId = state.cronForm.agentId.trim();
const job = {
name: state.cronForm.name.trim(),
@ -133,10 +127,6 @@ export async function addCronJob(state: CronState) {
wakeMode: state.cronForm.wakeMode,
payload,
delivery,
isolation:
state.cronForm.sessionTarget === "isolated" && state.cronForm.deliveryMode === "legacy"
? { postToMainPrefix: legacyPrefix }
: undefined,
};
if (!job.name) {
throw new Error("Name required.");

View File

@ -53,7 +53,8 @@ export function formatCronState(job: CronJob) {
export function formatCronSchedule(job: CronJob) {
const s = job.schedule;
if (s.kind === "at") {
return `At ${formatMs(s.atMs)}`;
const atMs = Date.parse(s.at);
return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
}
if (s.kind === "every") {
return `Every ${formatDurationMs(s.everyMs)}`;
@ -75,9 +76,5 @@ export function formatCronPayload(job: CronJob) {
: "";
return `${base} · ${delivery.mode}${target}`;
}
if (!delivery && (p.deliver || p.to)) {
const target = p.channel || p.to ? ` (${p.channel ?? "last"}${p.to ? ` -> ${p.to}` : ""})` : "";
return `${base} · deliver${target}`;
}
return base;
}

View File

@ -425,7 +425,7 @@ export type SessionsPatchResult = {
};
export type CronSchedule =
| { kind: "at"; atMs: number }
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
@ -439,31 +439,15 @@ export type CronPayload =
message: string;
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
channel?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
bestEffortDeliver?: boolean;
};
export type CronDelivery = {
mode: "none" | "announce" | "deliver";
mode: "none" | "announce";
channel?: string;
to?: string;
bestEffort?: boolean;
};
export type CronIsolation = {
postToMainPrefix?: string;
};
export type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
@ -487,7 +471,6 @@ export type CronJob = {
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
isolation?: CronIsolation;
state?: CronJobState;
};

View File

@ -29,9 +29,8 @@ export type CronFormState = {
wakeMode: "next-heartbeat" | "now";
payloadKind: "systemEvent" | "agentTurn";
payloadText: string;
deliveryMode: "legacy" | "none" | "announce" | "deliver";
deliveryMode: "none" | "announce";
deliveryChannel: string;
deliveryTo: string;
timeoutSeconds: string;
postToMainPrefix: string;
};

View File

@ -211,9 +211,7 @@ export function renderCron(props: CronProps) {
.value as CronFormState["deliveryMode"],
})}
>
<option value="legacy">Main summary (legacy)</option>
<option value="announce">Announce summary (default)</option>
<option value="deliver">Deliver full output</option>
<option value="none">None (internal)</option>
</select>
</label>
@ -228,7 +226,7 @@ export function renderCron(props: CronProps) {
/>
</label>
${
props.form.deliveryMode === "announce" || props.form.deliveryMode === "deliver"
props.form.deliveryMode === "announce"
? html`
<label class="field">
<span>Channel</span>
@ -261,22 +259,6 @@ export function renderCron(props: CronProps) {
`
: nothing
}
${
props.form.sessionTarget === "isolated" && props.form.deliveryMode === "legacy"
? html`
<label class="field">
<span>Post to main prefix</span>
<input
.value=${props.form.postToMainPrefix}
@input=${(e: Event) =>
props.onFormChange({
postToMainPrefix: (e.target as HTMLInputElement).value,
})}
/>
</label>
`
: nothing
}
</div>
`
: nothing