diff --git a/CHANGELOG.md b/CHANGELOG.md index 4daa92945..c2bc56b9f 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 720c8ba21..ee5f827cb 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 0d4c46523..83b5923e6 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 6300afb5a..a5207ca10 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 7c7e77e92..031094caf 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 86f313ae5..c638e4c87 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 98ebc23e6..9dc0d8aa2 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 ffa31eb13..4b51a4a9e 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 9d833cbe7..ed8315b7c 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 f9b5561e8..f90ac25a9 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 136091dbb..f9de602e2 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 87b696476..9741ea8d0 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 197a0a3fd..423565d4f 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 09ea72edb..c28da2638 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 458b14f4d..d5def046b 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 5c3b6471a..185779a26 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 73f3bdcd1..e0492e61f 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 85c5a09fb..732de177f 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 9bad8943a..b38645f14 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 a46c779eb..abb624fbe 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 d98a425f4..e6927a28f 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 277c30eb6..cec1c8cbd 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 5145d8b70..b83a543bf 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 efab4535f..d61a0505a 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 cedbdc57b..f4bf7b236 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 61c5b9a3e..9a8d3ab63 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 abe196eec..164b951b5 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 d85fee814..81720418d 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 099c97e3f..74f94e0cc 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 884610dcf..5e1204712 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 5a40e1ac1..6039749a0 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 7745fe828..b74b52d88 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 078893563..256878b8e 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 3340225d9..ab547bdf7 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 3ccef96e6..7373dd543 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 6b8a9e10c..bec4dfa07 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 5533edc0a..bed25c312 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 814ba751c..1be95acaa 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 dac1ad634..c8867e3e1 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 5fe376a93..ee172819f 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 aa20ba36a..d25edfb8a 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 000000000..a0384c9d3 --- /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 e53846212..4c5596a4c 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 cc27ec246..40ea830bb 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 3afcaa2fe..9ccaa5061 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 ed70fe1d1..736d5529e 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 bb9751c4c..3003fb3d2 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 e4a0082b8..e86e5d24c 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 f7d898299..f1d3994fb 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 8b4c107b5..139e9ef9c 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 eae000ae0..6521d0748 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 8b51c9a6d..190311bca 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 9704d29d7..7c99380a8 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 8548e3141..27a1132bf 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 258fe165e..7ce3c7399 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 979ab8820..a957cf1a2 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`