Sitelet https://github.com/init4samwise/openclaw/commit/f8d253406296a548e53a63651834b8b9cb1fb405
Skip to content

Commit f8d2534

Browse files
tyler6204steipete
authored andcommitted
fix(cron): fix test failures and regenerate protocol files
- Add forceReload option to ensureLoaded to avoid stat I/O in normal paths while still detecting cross-service writes in the timer path - Post isolated job summary back to main session (restores the old isolation.postToMainPrefix behavior via delivery model) - Update legacy migration tests to check delivery.channel instead of payload.channel (normalization now moves delivery fields to top-level) - Remove legacy deliver/channel/to/bestEffortDeliver from payload schema - Update protocol conformance test for delivery modes - Regenerate GatewayModels.swift (isolation -> delivery)
1 parent 6fb8d88 commit f8d2534

9 files changed

Lines changed: 83 additions & 88 deletions

File tree

‎apps/macos/Sources/OpenClawProtocol/GatewayModels.swift‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
18721872
public let sessiontarget: AnyCodable
18731873
public let wakemode: AnyCodable
18741874
public let payload: AnyCodable
1875-
public let isolation: [String: AnyCodable]?
1875+
public let delivery: [String: AnyCodable]?
18761876
public let state: [String: AnyCodable]
18771877

18781878
public init(
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
18881888
sessiontarget: AnyCodable,
18891889
wakemode: AnyCodable,
18901890
payload: AnyCodable,
1891-
isolation: [String: AnyCodable]?,
1891+
delivery: [String: AnyCodable]?,
18921892
state: [String: AnyCodable]
18931893
) {
18941894
self.id = id
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
19031903
self.sessiontarget = sessiontarget
19041904
self.wakemode = wakemode
19051905
self.payload = payload
1906-
self.isolation = isolation
1906+
self.delivery = delivery
19071907
self.state = state
19081908
}
19091909
private enum CodingKeys: String, CodingKey {
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
19191919
case sessiontarget = "sessionTarget"
19201920
case wakemode = "wakeMode"
19211921
case payload
1922-
case isolation
1922+
case delivery
19231923
case state
19241924
}
19251925
}
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
19501950
public let sessiontarget: AnyCodable
19511951
public let wakemode: AnyCodable
19521952
public let payload: AnyCodable
1953-
public let isolation: [String: AnyCodable]?
1953+
public let delivery: [String: AnyCodable]?
19541954

19551955
public init(
19561956
name: String,
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
19621962
sessiontarget: AnyCodable,
19631963
wakemode: AnyCodable,
19641964
payload: AnyCodable,
1965-
isolation: [String: AnyCodable]?
1965+
delivery: [String: AnyCodable]?
19661966
) {
19671967
self.name = name
19681968
self.agentid = agentid
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
19731973
self.sessiontarget = sessiontarget
19741974
self.wakemode = wakemode
19751975
self.payload = payload
1976-
self.isolation = isolation
1976+
self.delivery = delivery
19771977
}
19781978
private enum CodingKeys: String, CodingKey {
19791979
case name
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
19851985
case sessiontarget = "sessionTarget"
19861986
case wakemode = "wakeMode"
19871987
case payload
1988-
case isolation
1988+
case delivery
19891989
}
19901990
}
19911991

‎apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
18721872
public let sessiontarget: AnyCodable
18731873
public let wakemode: AnyCodable
18741874
public let payload: AnyCodable
1875-
public let isolation: [String: AnyCodable]?
1875+
public let delivery: [String: AnyCodable]?
18761876
public let state: [String: AnyCodable]
18771877

18781878
public init(
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
18881888
sessiontarget: AnyCodable,
18891889
wakemode: AnyCodable,
18901890
payload: AnyCodable,
1891-
isolation: [String: AnyCodable]?,
1891+
delivery: [String: AnyCodable]?,
18921892
state: [String: AnyCodable]
18931893
) {
18941894
self.id = id
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
19031903
self.sessiontarget = sessiontarget
19041904
self.wakemode = wakemode
19051905
self.payload = payload
1906-
self.isolation = isolation
1906+
self.delivery = delivery
19071907
self.state = state
19081908
}
19091909
private enum CodingKeys: String, CodingKey {
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
19191919
case sessiontarget = "sessionTarget"
19201920
case wakemode = "wakeMode"
19211921
case payload
1922-
case isolation
1922+
case delivery
19231923
case state
19241924
}
19251925
}
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
19501950
public let sessiontarget: AnyCodable
19511951
public let wakemode: AnyCodable
19521952
public let payload: AnyCodable
1953-
public let isolation: [String: AnyCodable]?
1953+
public let delivery: [String: AnyCodable]?
19541954

19551955
public init(
19561956
name: String,
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
19621962
sessiontarget: AnyCodable,
19631963
wakemode: AnyCodable,
19641964
payload: AnyCodable,
1965-
isolation: [String: AnyCodable]?
1965+
delivery: [String: AnyCodable]?
19661966
) {
19671967
self.name = name
19681968
self.agentid = agentid
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
19731973
self.sessiontarget = sessiontarget
19741974
self.wakemode = wakemode
19751975
self.payload = payload
1976-
self.isolation = isolation
1976+
self.delivery = delivery
19771977
}
19781978
private enum CodingKeys: String, CodingKey {
19791979
case name
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
19851985
case sessiontarget = "sessionTarget"
19861986
case wakemode = "wakeMode"
19871987
case payload
1988-
case isolation
1988+
case delivery
19891989
}
19901990
}
19911991

‎src/cli/cron-cli/shared.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ export function parseAt(input: string): string | null {
6666
return null;
6767
}
6868
const absolute = parseAbsoluteTimeMs(raw);
69-
if (absolute) {
69+
if (absolute !== null) {
7070
return new Date(absolute).toISOString();
7171
}
7272
const dur = parseDurationMs(raw);
73-
if (dur) {
73+
if (dur !== null) {
7474
return new Date(Date.now() + dur).toISOString();
7575
}
7676
return null;

‎src/cron/cron-protocol-conformance.test.ts‎

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,29 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import { describe, expect, it } from "vitest";
44
import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
5-
import { CronPayloadSchema } from "../gateway/protocol/schema.js";
5+
import { CronDeliverySchema } from "../gateway/protocol/schema.js";
66

77
type SchemaLike = {
8-
anyOf?: Array<{ properties?: Record<string, unknown> }>;
8+
anyOf?: Array<{ properties?: Record<string, unknown>; const?: unknown }>;
99
properties?: Record<string, unknown>;
1010
const?: unknown;
1111
};
1212

13-
type ProviderSchema = {
14-
anyOf?: Array<{ const?: unknown }>;
15-
};
16-
17-
function extractCronChannels(schema: SchemaLike): string[] {
18-
const union = schema.anyOf ?? [];
19-
const payloadWithChannel = union.find((entry) =>
20-
Boolean(entry?.properties && "channel" in entry.properties),
21-
);
22-
const channelSchema = payloadWithChannel?.properties
23-
? (payloadWithChannel.properties.channel as ProviderSchema)
24-
: undefined;
25-
const channels = (channelSchema?.anyOf ?? [])
13+
function extractDeliveryModes(schema: SchemaLike): string[] {
14+
const modeSchema = schema.properties?.mode as SchemaLike | undefined;
15+
return (modeSchema?.anyOf ?? [])
2616
.map((entry) => entry?.const)
2717
.filter((value): value is string => typeof value === "string");
28-
return channels;
2918
}
3019

3120
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
3221

33-
const SWIFT_FILE_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`];
22+
const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`];
23+
const SWIFT_STATUS_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`];
3424

35-
async function resolveSwiftFiles(cwd: string): Promise<string[]> {
25+
async function resolveSwiftFiles(cwd: string, candidates: string[]): Promise<string[]> {
3626
const matches: string[] = [];
37-
for (const relPath of SWIFT_FILE_CANDIDATES) {
27+
for (const relPath of candidates) {
3828
try {
3929
await fs.access(path.join(cwd, relPath));
4030
matches.push(relPath);
@@ -43,30 +33,32 @@ async function resolveSwiftFiles(cwd: string): Promise<string[]> {
4333
}
4434
}
4535
if (matches.length === 0) {
46-
throw new Error(`Missing Swift cron definition. Tried: ${SWIFT_FILE_CANDIDATES.join(", ")}`);
36+
throw new Error(`Missing Swift cron definition. Tried: ${candidates.join(", ")}`);
4737
}
4838
return matches;
4939
}
5040

5141
describe("cron protocol conformance", () => {
52-
it("ui + swift include all cron providers from gateway schema", async () => {
53-
const channels = extractCronChannels(CronPayloadSchema as SchemaLike);
54-
expect(channels.length).toBeGreaterThan(0);
42+
it("ui + swift include all cron delivery modes from gateway schema", async () => {
43+
const modes = extractDeliveryModes(CronDeliverySchema as SchemaLike);
44+
expect(modes.length).toBeGreaterThan(0);
5545

5646
const cwd = process.cwd();
5747
for (const relPath of UI_FILES) {
5848
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
59-
for (const channel of channels) {
60-
expect(content.includes(`"${channel}"`), `${relPath} missing ${channel}`).toBe(true);
49+
for (const mode of modes) {
50+
expect(content.includes(`"${mode}"`), `${relPath} missing delivery mode ${mode}`).toBe(
51+
true,
52+
);
6153
}
6254
}
6355

64-
const swiftFiles = await resolveSwiftFiles(cwd);
65-
for (const relPath of swiftFiles) {
56+
const swiftModelFiles = await resolveSwiftFiles(cwd, SWIFT_MODEL_CANDIDATES);
57+
for (const relPath of swiftModelFiles) {
6658
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
67-
for (const channel of channels) {
68-
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
69-
expect(pattern.test(content), `${relPath} missing case ${channel}`).toBe(true);
59+
for (const mode of modes) {
60+
const pattern = new RegExp(`\\bcase\\s+${mode}\\b`);
61+
expect(pattern.test(content), `${relPath} missing case ${mode}`).toBe(true);
7062
}
7163
}
7264
});
@@ -78,7 +70,7 @@ describe("cron protocol conformance", () => {
7870
expect(uiTypes.includes("jobs:")).toBe(true);
7971
expect(uiTypes.includes("jobCount")).toBe(false);
8072

81-
const [swiftRelPath] = await resolveSwiftFiles(cwd);
73+
const [swiftRelPath] = await resolveSwiftFiles(cwd, SWIFT_STATUS_CANDIDATES);
8274
const swiftPath = path.join(cwd, swiftRelPath);
8375
const swift = await fs.readFile(swiftPath, "utf-8");
8476
expect(swift.includes("struct CronSchedulerStatus")).toBe(true);

‎src/cron/service.runs-one-shot-main-job-disables-it.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,12 @@ describe("CronService", () => {
270270
await cron.start();
271271
const jobs = await cron.list({ includeDisabled: true });
272272
const job = jobs.find((j) => j.id === rawJob.id);
273+
// Legacy delivery fields are migrated to the top-level delivery object
274+
const delivery = job?.delivery as unknown as Record<string, unknown>;
275+
expect(delivery?.channel).toBe("telegram");
273276
const payload = job?.payload as unknown as Record<string, unknown>;
274-
expect(payload.channel).toBe("telegram");
275277
expect("provider" in payload).toBe(false);
278+
expect("channel" in payload).toBe(false);
276279

277280
cron.stop();
278281
await store.cleanup();
@@ -321,8 +324,9 @@ describe("CronService", () => {
321324
await cron.start();
322325
const jobs = await cron.list({ includeDisabled: true });
323326
const job = jobs.find((j) => j.id === rawJob.id);
324-
const payload = job?.payload as unknown as Record<string, unknown>;
325-
expect(payload.channel).toBe("telegram");
327+
// Legacy delivery fields are migrated to the top-level delivery object
328+
const delivery = job?.delivery as unknown as Record<string, unknown>;
329+
expect(delivery?.channel).toBe("telegram");
326330

327331
cron.stop();
328332
await store.cleanup();

‎src/cron/service/store.ts‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,23 +126,24 @@ async function getFileMtimeMs(path: string): Promise<number | null> {
126126
}
127127
}
128128

129-
export async function ensureLoaded(state: CronServiceState) {
130-
const fileMtimeMs = await getFileMtimeMs(state.deps.storePath);
131-
132-
// Check if we need to reload:
133-
// - No store loaded yet
134-
// - File modification time has changed
135-
// - File was modified after we last loaded (external edit)
136-
const needsReload =
137-
!state.store ||
138-
(fileMtimeMs !== null &&
139-
state.storeFileMtimeMs !== null &&
140-
fileMtimeMs > state.storeFileMtimeMs);
141-
142-
if (!needsReload) {
129+
export async function ensureLoaded(state: CronServiceState, opts?: { forceReload?: boolean }) {
130+
// Fast path: store is already in memory. The timer path passes
131+
// forceReload=true so that cross-service writes to the same store file
132+
// are always picked up. Other callers (add, list, run, …) trust the
133+
// in-memory copy to avoid a stat syscall on every operation.
134+
if (state.store && !opts?.forceReload) {
143135
return;
144136
}
145137

138+
if (opts?.forceReload && state.store) {
139+
// Only pay for the stat when we're explicitly checking for external edits.
140+
const mtime = await getFileMtimeMs(state.deps.storePath);
141+
if (mtime !== null && state.storeFileMtimeMs !== null && mtime === state.storeFileMtimeMs) {
142+
return; // File unchanged since our last load/persist.
143+
}
144+
}
145+
146+
const fileMtimeMs = await getFileMtimeMs(state.deps.storePath);
146147
const loaded = await loadCronStore(state.deps.storePath);
147148
const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>;
148149
let mutated = false;

‎src/cron/service/timer.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export async function onTimer(state: CronServiceState) {
3737
state.running = true;
3838
try {
3939
await locked(state, async () => {
40-
await ensureLoaded(state);
40+
await ensureLoaded(state, { forceReload: true });
4141
await runDueJobs(state);
4242
await persist(state);
4343
armTimer(state);
@@ -184,6 +184,18 @@ export async function executeJob(
184184
job,
185185
message: job.payload.message,
186186
});
187+
188+
// Post a short summary back to the main session so the user sees
189+
// the cron result without opening the isolated session.
190+
const summaryText = res.summary?.trim();
191+
if (summaryText) {
192+
const prefix = "Cron";
193+
const label =
194+
res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`;
195+
state.deps.enqueueSystemEvent(label, { agentId: job.agentId });
196+
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
197+
}
198+
187199
if (res.status === "ok") {
188200
await finish("ok", undefined, res.summary);
189201
} else if (res.status === "skipped") {

‎src/gateway/protocol/schema/cron.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ export const CronPayloadSchema = Type.Union([
4242
model: Type.Optional(Type.String()),
4343
thinking: Type.Optional(Type.String()),
4444
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
45-
deliver: Type.Optional(Type.Boolean()),
46-
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
47-
to: Type.Optional(Type.String()),
48-
bestEffortDeliver: Type.Optional(Type.Boolean()),
4945
},
5046
{ additionalProperties: false },
5147
),
@@ -66,10 +62,6 @@ export const CronPayloadPatchSchema = Type.Union([
6662
model: Type.Optional(Type.String()),
6763
thinking: Type.Optional(Type.String()),
6864
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
69-
deliver: Type.Optional(Type.Boolean()),
70-
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
71-
to: Type.Optional(Type.String()),
72-
bestEffortDeliver: Type.Optional(Type.Boolean()),
7365
},
7466
{ additionalProperties: false },
7567
),

0 commit comments

Comments
 (0)