Skip to content

Commit 3f82dae

Browse files
tyler6204steipete
authored andcommitted
feat(cron): enhance delivery modes and job configuration
- Updated isolated cron jobs to support new delivery modes: `announce` and `none`, improving output management. - Refactored job configuration to remove legacy fields and streamline delivery settings. - Enhanced the `CronJobEditor` UI to reflect changes in delivery options, including a new segmented control for delivery mode selection. - Updated documentation to clarify the new delivery configurations and their implications for job execution. - Improved tests to validate the new delivery behavior and ensure backward compatibility with legacy settings. This update provides users with greater flexibility in managing how isolated jobs deliver their outputs, enhancing overall usability and clarity in job configurations.
1 parent ab9f06f commit 3f82dae

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+916
-1149
lines changed

CHANGELOG.md

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

apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ extension CronJobEditor {
2020
self.wakeMode = job.wakeMode
2121

2222
switch job.schedule {
23-
case let .at(atMs):
23+
case let .at(at):
2424
self.scheduleKind = .at
25-
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
25+
if let date = CronSchedule.parseAtDate(at) {
26+
self.atDate = date
27+
}
2628
case let .every(everyMs, _):
2729
self.scheduleKind = .every
2830
self.everyText = self.formatDuration(ms: everyMs)
@@ -36,19 +38,22 @@ extension CronJobEditor {
3638
case let .systemEvent(text):
3739
self.payloadKind = .systemEvent
3840
self.systemEventText = text
39-
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
41+
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
4042
self.payloadKind = .agentTurn
4143
self.agentMessage = message
4244
self.thinking = thinking ?? ""
4345
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
44-
self.deliver = deliver ?? false
45-
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
46-
self.channel = trimmed.isEmpty ? "last" : trimmed
47-
self.to = to ?? ""
48-
self.bestEffortDeliver = bestEffortDeliver ?? false
4946
}
5047

51-
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
48+
if let delivery = job.delivery {
49+
self.deliveryMode = delivery.mode == .announce ? .announce : .none
50+
let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
51+
self.channel = trimmed.isEmpty ? "last" : trimmed
52+
self.to = delivery.to ?? ""
53+
self.bestEffortDeliver = delivery.bestEffort ?? false
54+
} else if self.sessionTarget == .isolated {
55+
self.deliveryMode = .announce
56+
}
5257
}
5358

5459
func save() {
@@ -88,15 +93,25 @@ extension CronJobEditor {
8893
}
8994

9095
if self.sessionTarget == .isolated {
91-
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
92-
root["isolation"] = [
93-
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
94-
]
96+
root["delivery"] = self.buildDelivery()
9597
}
9698

9799
return root.mapValues { AnyCodable($0) }
98100
}
99101

102+
func buildDelivery() -> [String: Any] {
103+
let mode = self.deliveryMode == .announce ? "announce" : "none"
104+
var delivery: [String: Any] = ["mode": mode]
105+
if self.deliveryMode == .announce {
106+
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
107+
delivery["channel"] = trimmed.isEmpty ? "last" : trimmed
108+
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
109+
if !to.isEmpty { delivery["to"] = to }
110+
if self.bestEffortDeliver { delivery["bestEffort"] = true }
111+
}
112+
return delivery
113+
}
114+
100115
func trimmed(_ value: String) -> String {
101116
value.trimmingCharacters(in: .whitespacesAndNewlines)
102117
}
@@ -115,7 +130,7 @@ extension CronJobEditor {
115130
func buildSchedule() throws -> [String: Any] {
116131
switch self.scheduleKind {
117132
case .at:
118-
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
133+
return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)]
119134
case .every:
120135
guard let ms = Self.parseDurationMs(self.everyText) else {
121136
throw NSError(
@@ -209,14 +224,6 @@ extension CronJobEditor {
209224
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
210225
if !thinking.isEmpty { payload["thinking"] = thinking }
211226
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
212-
payload["deliver"] = self.deliver
213-
if self.deliver {
214-
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
215-
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
216-
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
217-
if !to.isEmpty { payload["to"] = to }
218-
payload["bestEffortDeliver"] = self.bestEffortDeliver
219-
}
220227
return payload
221228
}
222229

apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,12 @@ extension CronJobEditor {
1313

1414
self.payloadKind = .agentTurn
1515
self.agentMessage = "Run diagnostic"
16-
self.deliver = true
16+
self.deliveryMode = .announce
1717
self.channel = "last"
1818
self.to = "+15551230000"
1919
self.thinking = "low"
2020
self.timeoutSeconds = "90"
2121
self.bestEffortDeliver = true
22-
self.postPrefix = "Cron"
2322

2423
_ = self.buildAgentTurnPayload()
2524
_ = try? self.buildPayload()

apps/macos/Sources/OpenClaw/CronJobEditor.swift

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,13 @@ struct CronJobEditor: View {
1616
+ "Use an isolated session for agent turns so your main chat stays clean."
1717
static let sessionTargetNote =
1818
"Main jobs post a system event into the current main session. "
19-
+ "Isolated jobs run OpenClaw in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
19+
+ "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel."
2020
static let scheduleKindNote =
2121
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
2222
static let isolatedPayloadNote =
23-
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
24-
+ "and a short summary is posted back to your main chat."
23+
"Isolated jobs always run an agent turn. Announce sends a short summary to a channel."
2524
static let mainPayloadNote =
2625
"System events are injected into the current main session. Agent turns require an isolated session target."
27-
static let mainSummaryNote =
28-
"Controls the label used when posting the completion summary back to the main session."
2926

3027
@State var name: String = ""
3128
@State var description: String = ""
@@ -46,13 +43,13 @@ struct CronJobEditor: View {
4643
@State var payloadKind: PayloadKind = .systemEvent
4744
@State var systemEventText: String = ""
4845
@State var agentMessage: String = ""
49-
@State var deliver: Bool = false
46+
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { rawValue } }
47+
@State var deliveryMode: DeliveryChoice = .announce
5048
@State var channel: String = "last"
5149
@State var to: String = ""
5250
@State var thinking: String = ""
5351
@State var timeoutSeconds: String = ""
5452
@State var bestEffortDeliver: Bool = false
55-
@State var postPrefix: String = "Cron"
5653

5754
var channelOptions: [String] {
5855
let ordered = self.channelsStore.orderedChannelIds()
@@ -248,27 +245,6 @@ struct CronJobEditor: View {
248245
}
249246
}
250247

251-
if self.sessionTarget == .isolated {
252-
GroupBox("Main session summary") {
253-
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
254-
GridRow {
255-
self.gridLabel("Prefix")
256-
TextField("Cron", text: self.$postPrefix)
257-
.textFieldStyle(.roundedBorder)
258-
.frame(maxWidth: .infinity)
259-
}
260-
GridRow {
261-
Color.clear
262-
.frame(width: self.labelColumnWidth, height: 1)
263-
Text(
264-
Self.mainSummaryNote)
265-
.font(.footnote)
266-
.foregroundStyle(.secondary)
267-
.frame(maxWidth: .infinity, alignment: .leading)
268-
}
269-
}
270-
}
271-
}
272248
}
273249
.frame(maxWidth: .infinity, alignment: .leading)
274250
.padding(.vertical, 2)
@@ -340,13 +316,17 @@ struct CronJobEditor: View {
340316
.frame(width: 180, alignment: .leading)
341317
}
342318
GridRow {
343-
self.gridLabel("Deliver")
344-
Toggle("Deliver result to a channel", isOn: self.$deliver)
345-
.toggleStyle(.switch)
319+
self.gridLabel("Delivery")
320+
Picker("", selection: self.$deliveryMode) {
321+
Text("Announce summary").tag(DeliveryChoice.announce)
322+
Text("None").tag(DeliveryChoice.none)
323+
}
324+
.labelsHidden()
325+
.pickerStyle(.segmented)
346326
}
347327
}
348328

349-
if self.deliver {
329+
if self.deliveryMode == .announce {
350330
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
351331
GridRow {
352332
self.gridLabel("Channel")
@@ -367,7 +347,7 @@ struct CronJobEditor: View {
367347
}
368348
GridRow {
369349
self.gridLabel("Best-effort")
370-
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
350+
Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver)
371351
.toggleStyle(.switch)
372352
}
373353
}

apps/macos/Sources/OpenClaw/CronModels.swift

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,26 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
1414
var id: String { self.rawValue }
1515
}
1616

17+
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
18+
case none
19+
case announce
20+
21+
var id: String { self.rawValue }
22+
}
23+
24+
struct CronDelivery: Codable, Equatable {
25+
var mode: CronDeliveryMode
26+
var channel: String?
27+
var to: String?
28+
var bestEffort: Bool?
29+
}
30+
1731
enum CronSchedule: Codable, Equatable {
18-
case at(atMs: Int)
32+
case at(at: String)
1933
case every(everyMs: Int, anchorMs: Int?)
2034
case cron(expr: String, tz: String?)
2135

22-
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
36+
enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz }
2337

2438
var kind: String {
2539
switch self {
@@ -34,7 +48,21 @@ enum CronSchedule: Codable, Equatable {
3448
let kind = try container.decode(String.self, forKey: .kind)
3549
switch kind {
3650
case "at":
37-
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
51+
if let at = try container.decodeIfPresent(String.self, forKey: .at),
52+
!at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
53+
{
54+
self = .at(at: at)
55+
return
56+
}
57+
if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) {
58+
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
59+
self = .at(at: Self.formatIsoDate(date))
60+
return
61+
}
62+
throw DecodingError.dataCorruptedError(
63+
forKey: .at,
64+
in: container,
65+
debugDescription: "Missing schedule.at")
3866
case "every":
3967
self = try .every(
4068
everyMs: container.decode(Int.self, forKey: .everyMs),
@@ -55,8 +83,8 @@ enum CronSchedule: Codable, Equatable {
5583
var container = encoder.container(keyedBy: CodingKeys.self)
5684
try container.encode(self.kind, forKey: .kind)
5785
switch self {
58-
case let .at(atMs):
59-
try container.encode(atMs, forKey: .atMs)
86+
case let .at(at):
87+
try container.encode(at, forKey: .at)
6088
case let .every(everyMs, anchorMs):
6189
try container.encode(everyMs, forKey: .everyMs)
6290
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
@@ -65,6 +93,29 @@ enum CronSchedule: Codable, Equatable {
6593
try container.encodeIfPresent(tz, forKey: .tz)
6694
}
6795
}
96+
97+
static func parseAtDate(_ value: String) -> Date? {
98+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
99+
if trimmed.isEmpty { return nil }
100+
if let date = isoFormatterWithFractional.date(from: trimmed) { return date }
101+
return isoFormatter.date(from: trimmed)
102+
}
103+
104+
static func formatIsoDate(_ date: Date) -> String {
105+
isoFormatter.string(from: date)
106+
}
107+
108+
private static let isoFormatter: ISO8601DateFormatter = {
109+
let formatter = ISO8601DateFormatter()
110+
formatter.formatOptions = [.withInternetDateTime]
111+
return formatter
112+
}()
113+
114+
private static let isoFormatterWithFractional: ISO8601DateFormatter = {
115+
let formatter = ISO8601DateFormatter()
116+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
117+
return formatter
118+
}()
68119
}
69120

70121
enum CronPayload: Codable, Equatable {
@@ -131,10 +182,6 @@ enum CronPayload: Codable, Equatable {
131182
}
132183
}
133184

134-
struct CronIsolation: Codable, Equatable {
135-
var postToMainPrefix: String?
136-
}
137-
138185
struct CronJobState: Codable, Equatable {
139186
var nextRunAtMs: Int?
140187
var runningAtMs: Int?
@@ -157,7 +204,7 @@ struct CronJob: Identifiable, Codable, Equatable {
157204
let sessionTarget: CronSessionTarget
158205
let wakeMode: CronWakeMode
159206
let payload: CronPayload
160-
let isolation: CronIsolation?
207+
let delivery: CronDelivery?
161208
let state: CronJobState
162209

163210
var displayName: String {

apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ extension CronSettings {
1717

1818
func scheduleSummary(_ schedule: CronSchedule) -> String {
1919
switch schedule {
20-
case let .at(atMs):
21-
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
22-
return "at \(date.formatted(date: .abbreviated, time: .standard))"
20+
case let .at(at):
21+
if let date = CronSchedule.parseAtDate(at) {
22+
return "at \(date.formatted(date: .abbreviated, time: .standard))"
23+
}
24+
return "at \(at)"
2325
case let .every(everyMs, _):
2426
return "every \(self.formatDuration(ms: everyMs))"
2527
case let .cron(expr, tz):

apps/macos/Sources/OpenClaw/CronSettings+Rows.swift

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ extension CronSettings {
128128
.foregroundStyle(.orange)
129129
.textSelection(.enabled)
130130
}
131-
self.payloadSummary(job.payload)
131+
self.payloadSummary(job)
132132
}
133133
.frame(maxWidth: .infinity, alignment: .leading)
134134
.padding(10)
@@ -205,7 +205,8 @@ extension CronSettings {
205205
.padding(.vertical, 4)
206206
}
207207

208-
func payloadSummary(_ payload: CronPayload) -> some View {
208+
func payloadSummary(_ job: CronJob) -> some View {
209+
let payload = job.payload
209210
VStack(alignment: .leading, spacing: 6) {
210211
Text("Payload")
211212
.font(.caption.weight(.semibold))
@@ -215,18 +216,27 @@ extension CronSettings {
215216
Text(text)
216217
.font(.callout)
217218
.textSelection(.enabled)
218-
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
219+
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
219220
VStack(alignment: .leading, spacing: 4) {
220221
Text(message)
221222
.font(.callout)
222223
.textSelection(.enabled)
223224
HStack(spacing: 8) {
224225
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
225226
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
226-
if deliver ?? false {
227-
StatusPill(text: "deliver", tint: .secondary)
228-
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
229-
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
227+
if job.sessionTarget == .isolated {
228+
let delivery = job.delivery
229+
if let delivery {
230+
if delivery.mode == .announce {
231+
StatusPill(text: "announce", tint: .secondary)
232+
if let channel = delivery.channel, !channel.isEmpty {
233+
StatusPill(text: channel, tint: .secondary)
234+
}
235+
if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
236+
} else {
237+
StatusPill(text: "no delivery", tint: .secondary)
238+
}
239+
}
230240
}
231241
}
232242
}

0 commit comments

Comments
 (0)