From 3f82daefd8010cda5b7ead7c1717a9078acaec29 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 3 Feb 2026 16:53:46 -0800 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + .../OpenClaw/CronJobEditor+Helpers.swift | 51 ++- .../OpenClaw/CronJobEditor+Testing.swift | 3 +- .../Sources/OpenClaw/CronJobEditor.swift | 46 +- apps/macos/Sources/OpenClaw/CronModels.swift | 67 ++- .../OpenClaw/CronSettings+Helpers.swift | 8 +- .../Sources/OpenClaw/CronSettings+Rows.swift | 24 +- .../OpenClaw/CronSettings+Testing.swift | 20 +- .../CronJobEditorSmokeTests.swift | 10 +- .../OpenClawIPCTests/CronModelsTests.swift | 28 +- .../SettingsViewSmokeTests.swift | 12 +- docs/automation/cron-jobs.md | 60 +-- docs/automation/cron-vs-heartbeat.md | 6 +- docs/cli/cron.md | 9 +- docs/web/control-ui.md | 4 +- docs/zh-CN/automation/cron-jobs.md | 226 +++++----- docs/zh-CN/automation/cron-vs-heartbeat.md | 24 +- docs/zh-CN/cli/cron.md | 9 +- src/agents/openclaw-tools.ts | 30 +- src/agents/pi-embedded-runner/run/attempt.ts | 3 + src/agents/pi-embedded-runner/run/params.ts | 4 + src/agents/pi-tools.ts | 6 + src/agents/subagent-announce.ts | 4 +- src/agents/tools/cron-tool.test.ts | 12 +- src/agents/tools/cron-tool.ts | 11 +- src/agents/tools/message-tool.ts | 27 ++ src/cli/cron-cli.test.ts | 20 +- src/cli/cron-cli/register.cron-add.ts | 83 +--- src/cli/cron-cli/register.cron-edit.ts | 58 +-- src/cli/cron-cli/shared.ts | 17 +- src/cron/delivery.ts | 22 +- ...onse-has-heartbeat-ok-but-includes.test.ts | 43 +- ...p-recipient-besteffortdeliver-true.test.ts | 421 ++---------------- ....uses-last-non-empty-agent-text-as.test.ts | 41 -- src/cron/isolated-agent/run.ts | 99 ++-- src/cron/normalize.test.ts | 42 +- src/cron/normalize.ts | 85 +++- src/cron/schedule.ts | 7 +- .../service.prevents-duplicate-timers.test.ts | 2 +- ...runs-one-shot-main-job-disables-it.test.ts | 12 +- ...s-main-jobs-empty-systemevent-text.test.ts | 6 +- src/cron/service.store.migration.test.ts | 101 +++++ src/cron/service/jobs.ts | 10 +- src/cron/service/store.ts | 120 +++++ src/cron/service/timer.ts | 37 +- src/cron/types.ts | 17 +- src/cron/validate-timestamp.ts | 14 +- src/gateway/protocol/schema/cron.ts | 20 +- src/gateway/server.cron.e2e.test.ts | 12 +- src/gateway/server/hooks.ts | 2 +- ui/src/ui/app-defaults.ts | 1 - ui/src/ui/controllers/cron.ts | 16 +- ui/src/ui/presenter.ts | 7 +- ui/src/ui/types.ts | 21 +- ui/src/ui/ui-types.ts | 3 +- ui/src/ui/views/cron.ts | 20 +- 56 files changed, 917 insertions(+), 1150 deletions(-) create mode 100644 src/cron/service.store.migration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4daa929457..c2bc56b9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift index 720c8ba21c..ee5f827cb8 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift @@ -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 } diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift index 0d4c465236..83b5923e6f 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift @@ -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() diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/apps/macos/Sources/OpenClaw/CronJobEditor.swift index 6300afb5aa..a5207ca101 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor.swift @@ -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) } } diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index 7c7e77e928..031094cafd 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -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 { diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift b/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift index 86f313ae59..c638e4c87b 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift @@ -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): diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift index 98ebc23e6b..9dc0d8aa25 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift @@ -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) + } + } } } } diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift b/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift index ffa31eb13f..4b51a4a9e9 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift @@ -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()) diff --git a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift index 9d833cbe7d..ed8315b7c2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift @@ -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, diff --git a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift index f9b5561e81..f90ac25a9d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift @@ -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, diff --git a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift index 136091dbbe..f9de602e25 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift @@ -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, diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 87b6964763..9741ea8d0b 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -23,7 +23,7 @@ cron is the mechanism. - Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. - Two execution styles: - **Main session**: enqueue a system event, then run on the next heartbeat. - - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default, full output or none; legacy main summary still supported). + - **Isolated**: run a dedicated agent turn in `cron:`, 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: ]` 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 session’s “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 agent’s 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:`, `user:`) 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" ``` diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 197a0a3fdb..423565d4f3 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -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:` 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 diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 09ea72edb3..c28da2638c 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -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 --no-deliver ``` -Deliver full output (instead of announce): +Announce to a specific channel: ```bash -openclaw cron edit --deliver --channel slack --to "channel:C1234567890" +openclaw cron edit --announce --channel slack --to "channel:C1234567890" ``` diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 458b14f4d4..d5def046bf 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -81,8 +81,8 @@ you revoke it with `openclaw devices revoke --device --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 diff --git a/docs/zh-CN/automation/cron-jobs.md b/docs/zh-CN/automation/cron-jobs.md index 5c3b6471ad..185779a263 100644 --- a/docs/zh-CN/automation/cron-jobs.md +++ b/docs/zh-CN/automation/cron-jobs.md @@ -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:` 中运行专用的智能体回合,可选择发送输出。 -- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳"。 + - **主会话**:入队一个系统事件,然后在下一次心跳时运行。 + - **隔离式**:在 `cron:` 中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 +- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。 ## 快速开始(可操作) -创建一个一次性提醒,验证它是否存在,然后立即运行: +创建一个一次性提醒,验证其存在,然后立即运行: ```bash openclaw cron add \ @@ -49,7 +49,7 @@ openclaw cron run --force openclaw cron runs --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:` 中运行专用的智能体回合。 + - `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。 + - `sessionTarget: "isolated"` → 在 `cron:` 中运行专用智能体轮次。 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:` 中运行专用的智能体回合。 +隔离任务在会话 `cron:` 中运行专用智能体轮次。 关键行为: -- 提示以 `[cron: ]` 为前缀以便追踪。 -- 每次运行启动一个**新的会话 id**(没有先前的对话延续)。 -- 摘要会发布到主会话(前缀 `Cron`,可配置)。 -- `wakeMode: "now"` 在发布摘要后触发立即心跳。 -- 如果 `payload.deliver: true`,输出会发送到渠道;否则保持内部。 +- 提示以 `[cron: <任务名称>]` 为前缀,便于追踪。 +- 每次运行都会启动一个**全新的会话 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:`、`user:`)以避免歧义。 -- Telegram 话题应使用 `:topic:` 形式(见下文)。 +- Slack/Discord/Mattermost(插件)目标应使用明确前缀(例如 `channel:`、`user:`)以避免歧义。 +- 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/.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 \ openclaw cron runs --id --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:` 以确保明确无歧义。 -- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务发送接受它们并仍然正确解析话题 ID。 +- 对于论坛主题,使用 `-100…:topic:` 以确保明确无歧义。 +- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务投递接受这些前缀并仍能正确解析主题 ID。 diff --git a/docs/zh-CN/automation/cron-vs-heartbeat.md b/docs/zh-CN/automation/cron-vs-heartbeat.md index 73f3bdcd18..e0492e61f3 100644 --- a/docs/zh-CN/automation/cron-vs-heartbeat.md +++ b/docs/zh-CN/automation/cron-vs-heartbeat.md @@ -97,7 +97,7 @@ x-i18n: - **精确定时**:支持带时区的 5 字段 cron 表达式。 - **会话隔离**:在 `cron:` 中运行,不会污染主会话历史。 - **模型覆盖**:可按任务使用更便宜或更强大的模型。 -- **投递控制**:可直接投递到渠道;默认仍会向主会话发布摘要(可配置)。 +- **投递控制**:隔离任务默认以 `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:` | -| 历史 | 共享 | 共享 | 每次运行全新 | -| 上下文 | 完整 | 完整 | 无(从零开始) | -| 模型 | 主会话模型 | 主会话模型 | 可覆盖 | -| 输出 | 非 `HEARTBEAT_OK` 时投递 | 心跳提示 + 事件 | 摘要发布到主会话 | +| | 心跳 | 定时任务(主会话) | 定时任务(隔离式) | +| ------ | ------------------------ | ---------------------- | --------------------- | +| 会话 | 主会话 | 主会话(通过系统事件) | `cron:` | +| 历史 | 共享 | 共享 | 每次运行全新 | +| 上下文 | 完整 | 完整 | 无(从零开始) | +| 模型 | 主会话模型 | 主会话模型 | 可覆盖 | +| 输出 | 非 `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 ``` ## 成本考量 diff --git a/docs/zh-CN/cli/cron.md b/docs/zh-CN/cli/cron.md index 85c5a09fb6..732de177f4 100644 --- a/docs/zh-CN/cli/cron.md +++ b/docs/zh-CN/cli/cron.md @@ -23,12 +23,17 @@ x-i18n: 提示:运行 `openclaw cron --help` 查看完整的命令集。 -## 常用编辑 +说明:隔离式 `cron add` 任务默认使用 `--announce` 投递摘要。使用 `--no-deliver` 仅内部运行。 +`--deliver` 仍作为 `--announce` 的弃用别名保留。 + +说明:一次性(`--at`)任务成功后默认删除。使用 `--keep-after-run` 保留。 + +## 常见编辑 更新投递设置而不更改消息: ```bash -openclaw cron edit --deliver --channel telegram --to "123456789" +openclaw cron edit --announce --channel telegram --to "123456789" ``` 为隔离的作业禁用投递: diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 9bad8943a8..b38645f148 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a46c779ebb..abb624fbee 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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 }); diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index d98a425f40..e6927a28ff 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -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; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 277c30eb6c..cec1c8cbd6 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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, }), ]; diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 5145d8b703..b83a543bf2 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -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, diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index efab4535f0..d61a0505a2 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -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." }, }, diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index cedbdc57bb..f4bf7b2360 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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": "" } // preferred - { "kind": "at", "atMs": } // also accepted + { "kind": "at", "at": "" } - "every": Recurring interval { "kind": "every", "everyMs": , "anchorMs": } - "cron": Cron expression @@ -197,11 +196,9 @@ PAYLOAD TYPES (payload.kind): { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": } DELIVERY (isolated-only, top-level): - { "mode": "none|announce|deliver", "channel": "", "to": "", "bestEffort": } + { "mode": "none|announce", "channel": "", "to": "", "bestEffort": } - Default for isolated agentTurn jobs (when delivery omitted): "announce" - -LEGACY DELIVERY (payload, only when delivery is omitted): - { "deliver": , "channel": "", "to": "", "bestEffortDeliver": } + - 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" diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 61c5b9a3ed..9a8d3ab63a 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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([ + "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; diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index abe196eeca..164b951b53 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -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); }); }); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index d85fee814b..81720418d2 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -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 override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "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 ", `Delivery channel (${getCronChannelOptions()})`, "last") .option( "--to ", "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 for main-session post", "Cron") - .option( - "--post-mode ", - "What to post back to main for isolated jobs (summary|full)", - "summary", - ) - .option("--post-max-chars ", "Max chars when --post-mode=full (default 8000)", "8000") .option("--json", "Output JSON", false) .action(async (opts: GatewayRpcOpts & Record, 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); diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 099c97e3f1..74f94e0cc2 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -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 override for agent jobs") .option("--timeout-seconds ", "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 ", `Delivery channel (${getCronChannelOptions()})`) .option( "--to ", @@ -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 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 = {}; @@ -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: diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 884610dcf2..5e12047126 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -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)}`; diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts index 5a40e1ac11..6039749a0e 100644 --- a/src/cron/delivery.ts +++ b/src/cron/delivery.ts @@ -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, }; } diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 7745fe828a..b74b52d888 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -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(fn: (home: string) => Promise): Promise { @@ -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); }); }); }); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 078893563a..256878b8e9 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -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(fn: (home: string) => Promise): Promise { @@ -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(); }); }); }); diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 3340225d93..ab547bdf72 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -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); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 3ccef96e6a..7373dd543f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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 }; diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 6b8a9e10c5..bec4dfa075 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -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; 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; 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; + + const delivery = normalized.delivery as Record; + 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; - expect(normalized.delivery).toBeUndefined(); + const delivery = normalized.delivery as Record; + 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; - expect(normalized.delivery).toBeUndefined(); + const delivery = normalized.delivery as Record; + expect(delivery.mode).toBe("announce"); + expect((normalized as { isolation?: unknown }).isolation).toBeUndefined(); }); }); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 5533edc0a4..bed25c3129 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -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" }; + } } } diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 814ba751c2..1be95acaaa 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -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") { diff --git a/src/cron/service.prevents-duplicate-timers.test.ts b/src/cron/service.prevents-duplicate-timers.test.ts index dac1ad634b..c8867e3e16 100644 --- a/src/cron/service.prevents-duplicate-timers.test.ts +++ b/src/cron/service.prevents-duplicate-timers.test.ts @@ -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" }, diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 5fe376a938..ee172819fa 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -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" }, diff --git a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts index aa20ba36ad..d25edfb8a7 100644 --- a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts +++ b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts @@ -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" }, diff --git a/src/cron/service.store.migration.test.ts b/src/cron/service.store.migration.test.ts new file mode 100644 index 0000000000..a0384c9d31 --- /dev/null +++ b/src/cron/service.store.migration.test.ts @@ -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; + expect(migrated.delivery).toEqual({ + mode: "announce", + channel: "telegram", + to: "7200373102", + bestEffort: true, + }); + expect("isolation" in migrated).toBe(false); + + const payload = migrated.payload as Record; + expect(payload.deliver).toBeUndefined(); + expect(payload.channel).toBeUndefined(); + expect(payload.to).toBeUndefined(); + expect(payload.bestEffortDeliver).toBeUndefined(); + + const schedule = migrated.schedule as Record; + expect(schedule.kind).toBe("at"); + expect(schedule.at).toBe(new Date(atMs).toISOString()); + + await store.cleanup(); + }); +}); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index e538462124..4c5596a4ce 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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() : ""; diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index cc27ec246d..40ea830bb5 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -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) { + 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) { + 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 = { 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) { + 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 { 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; + 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) + : 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(); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 3afcaa2fe8..9ccaa50611 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -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)); diff --git a/src/cron/types.ts b/src/cron/types.ts index ed70fe1d11..736d5529e0 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -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; }; diff --git a/src/cron/validate-timestamp.ts b/src/cron/validate-timestamp.ts index bb9751c4cd..3003fb3d26 100644 --- a/src/cron/validate-timestamp.ts +++ b/src/cron/validate-timestamp.ts @@ -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`, }; } diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index e4a0082b86..e86e5d24ca 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -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 }, diff --git a/src/gateway/server.cron.e2e.test.ts b/src/gateway/server.cron.e2e.test.ts index f7d8982997..f1d3994fb6 100644 --- a/src/gateway/server.cron.e2e.test.ts +++ b/src/gateway/server.cron.e2e.test.ts @@ -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" }, diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 8b4c107b5d..139e9ef9cf 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -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: { diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index eae000ae05..6521d07487 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -29,5 +29,4 @@ export const DEFAULT_CRON_FORM: CronFormState = { deliveryChannel: "last", deliveryTo: "", timeoutSeconds: "", - postToMainPrefix: "Cron", }; diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 8b51c9a6d9..190311bca6 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -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."); diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index 9704d29d72..7c99380a86 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -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; } diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 8548e3141f..27a1132bf2 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -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; }; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 258fe165e1..7ce3c73998 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -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; }; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 979ab88205..a957cf1a20 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -211,9 +211,7 @@ export function renderCron(props: CronProps) { .value as CronFormState["deliveryMode"], })} > - - @@ -228,7 +226,7 @@ export function renderCron(props: CronProps) { /> ${ - props.form.deliveryMode === "announce" || props.form.deliveryMode === "deliver" + props.form.deliveryMode === "announce" ? html`