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)
This commit is contained in:
Tyler Yust
2026-02-03 20:35:47 -08:00
committed by Peter Steinberger
parent 6fb8d8850e
commit f8d2534062
9 changed files with 83 additions and 88 deletions

View File

@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable public let sessiontarget: AnyCodable
public let wakemode: AnyCodable public let wakemode: AnyCodable
public let payload: AnyCodable public let payload: AnyCodable
public let isolation: [String: AnyCodable]? public let delivery: [String: AnyCodable]?
public let state: [String: AnyCodable] public let state: [String: AnyCodable]
public init( public init(
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable, sessiontarget: AnyCodable,
wakemode: AnyCodable, wakemode: AnyCodable,
payload: AnyCodable, payload: AnyCodable,
isolation: [String: AnyCodable]?, delivery: [String: AnyCodable]?,
state: [String: AnyCodable] state: [String: AnyCodable]
) { ) {
self.id = id self.id = id
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
self.sessiontarget = sessiontarget self.sessiontarget = sessiontarget
self.wakemode = wakemode self.wakemode = wakemode
self.payload = payload self.payload = payload
self.isolation = isolation self.delivery = delivery
self.state = state self.state = state
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
case sessiontarget = "sessionTarget" case sessiontarget = "sessionTarget"
case wakemode = "wakeMode" case wakemode = "wakeMode"
case payload case payload
case isolation case delivery
case state case state
} }
} }
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
public let sessiontarget: AnyCodable public let sessiontarget: AnyCodable
public let wakemode: AnyCodable public let wakemode: AnyCodable
public let payload: AnyCodable public let payload: AnyCodable
public let isolation: [String: AnyCodable]? public let delivery: [String: AnyCodable]?
public init( public init(
name: String, name: String,
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
sessiontarget: AnyCodable, sessiontarget: AnyCodable,
wakemode: AnyCodable, wakemode: AnyCodable,
payload: AnyCodable, payload: AnyCodable,
isolation: [String: AnyCodable]? delivery: [String: AnyCodable]?
) { ) {
self.name = name self.name = name
self.agentid = agentid self.agentid = agentid
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
self.sessiontarget = sessiontarget self.sessiontarget = sessiontarget
self.wakemode = wakemode self.wakemode = wakemode
self.payload = payload self.payload = payload
self.isolation = isolation self.delivery = delivery
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
case sessiontarget = "sessionTarget" case sessiontarget = "sessionTarget"
case wakemode = "wakeMode" case wakemode = "wakeMode"
case payload case payload
case isolation case delivery
} }
} }

View File

@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable public let sessiontarget: AnyCodable
public let wakemode: AnyCodable public let wakemode: AnyCodable
public let payload: AnyCodable public let payload: AnyCodable
public let isolation: [String: AnyCodable]? public let delivery: [String: AnyCodable]?
public let state: [String: AnyCodable] public let state: [String: AnyCodable]
public init( public init(
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable, sessiontarget: AnyCodable,
wakemode: AnyCodable, wakemode: AnyCodable,
payload: AnyCodable, payload: AnyCodable,
isolation: [String: AnyCodable]?, delivery: [String: AnyCodable]?,
state: [String: AnyCodable] state: [String: AnyCodable]
) { ) {
self.id = id self.id = id
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
self.sessiontarget = sessiontarget self.sessiontarget = sessiontarget
self.wakemode = wakemode self.wakemode = wakemode
self.payload = payload self.payload = payload
self.isolation = isolation self.delivery = delivery
self.state = state self.state = state
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
case sessiontarget = "sessionTarget" case sessiontarget = "sessionTarget"
case wakemode = "wakeMode" case wakemode = "wakeMode"
case payload case payload
case isolation case delivery
case state case state
} }
} }
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
public let sessiontarget: AnyCodable public let sessiontarget: AnyCodable
public let wakemode: AnyCodable public let wakemode: AnyCodable
public let payload: AnyCodable public let payload: AnyCodable
public let isolation: [String: AnyCodable]? public let delivery: [String: AnyCodable]?
public init( public init(
name: String, name: String,
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
sessiontarget: AnyCodable, sessiontarget: AnyCodable,
wakemode: AnyCodable, wakemode: AnyCodable,
payload: AnyCodable, payload: AnyCodable,
isolation: [String: AnyCodable]? delivery: [String: AnyCodable]?
) { ) {
self.name = name self.name = name
self.agentid = agentid self.agentid = agentid
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
self.sessiontarget = sessiontarget self.sessiontarget = sessiontarget
self.wakemode = wakemode self.wakemode = wakemode
self.payload = payload self.payload = payload
self.isolation = isolation self.delivery = delivery
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
case sessiontarget = "sessionTarget" case sessiontarget = "sessionTarget"
case wakemode = "wakeMode" case wakemode = "wakeMode"
case payload case payload
case isolation case delivery
} }
} }

View File

@@ -66,11 +66,11 @@ export function parseAt(input: string): string | null {
return null; return null;
} }
const absolute = parseAbsoluteTimeMs(raw); const absolute = parseAbsoluteTimeMs(raw);
if (absolute) { if (absolute !== null) {
return new Date(absolute).toISOString(); return new Date(absolute).toISOString();
} }
const dur = parseDurationMs(raw); const dur = parseDurationMs(raw);
if (dur) { if (dur !== null) {
return new Date(Date.now() + dur).toISOString(); return new Date(Date.now() + dur).toISOString();
} }
return null; return null;

View File

@@ -2,39 +2,29 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
import { CronPayloadSchema } from "../gateway/protocol/schema.js"; import { CronDeliverySchema } from "../gateway/protocol/schema.js";
type SchemaLike = { type SchemaLike = {
anyOf?: Array<{ properties?: Record<string, unknown> }>; anyOf?: Array<{ properties?: Record<string, unknown>; const?: unknown }>;
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
const?: unknown; const?: unknown;
}; };
type ProviderSchema = { function extractDeliveryModes(schema: SchemaLike): string[] {
anyOf?: Array<{ const?: unknown }>; const modeSchema = schema.properties?.mode as SchemaLike | undefined;
}; return (modeSchema?.anyOf ?? [])
function extractCronChannels(schema: SchemaLike): string[] {
const union = schema.anyOf ?? [];
const payloadWithChannel = union.find((entry) =>
Boolean(entry?.properties && "channel" in entry.properties),
);
const channelSchema = payloadWithChannel?.properties
? (payloadWithChannel.properties.channel as ProviderSchema)
: undefined;
const channels = (channelSchema?.anyOf ?? [])
.map((entry) => entry?.const) .map((entry) => entry?.const)
.filter((value): value is string => typeof value === "string"); .filter((value): value is string => typeof value === "string");
return channels;
} }
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
const SWIFT_FILE_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`]; const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`];
const SWIFT_STATUS_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`];
async function resolveSwiftFiles(cwd: string): Promise<string[]> { async function resolveSwiftFiles(cwd: string, candidates: string[]): Promise<string[]> {
const matches: string[] = []; const matches: string[] = [];
for (const relPath of SWIFT_FILE_CANDIDATES) { for (const relPath of candidates) {
try { try {
await fs.access(path.join(cwd, relPath)); await fs.access(path.join(cwd, relPath));
matches.push(relPath); matches.push(relPath);
@@ -43,30 +33,32 @@ async function resolveSwiftFiles(cwd: string): Promise<string[]> {
} }
} }
if (matches.length === 0) { if (matches.length === 0) {
throw new Error(`Missing Swift cron definition. Tried: ${SWIFT_FILE_CANDIDATES.join(", ")}`); throw new Error(`Missing Swift cron definition. Tried: ${candidates.join(", ")}`);
} }
return matches; return matches;
} }
describe("cron protocol conformance", () => { describe("cron protocol conformance", () => {
it("ui + swift include all cron providers from gateway schema", async () => { it("ui + swift include all cron delivery modes from gateway schema", async () => {
const channels = extractCronChannels(CronPayloadSchema as SchemaLike); const modes = extractDeliveryModes(CronDeliverySchema as SchemaLike);
expect(channels.length).toBeGreaterThan(0); expect(modes.length).toBeGreaterThan(0);
const cwd = process.cwd(); const cwd = process.cwd();
for (const relPath of UI_FILES) { for (const relPath of UI_FILES) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) { for (const mode of modes) {
expect(content.includes(`"${channel}"`), `${relPath} missing ${channel}`).toBe(true); expect(content.includes(`"${mode}"`), `${relPath} missing delivery mode ${mode}`).toBe(
true,
);
} }
} }
const swiftFiles = await resolveSwiftFiles(cwd); const swiftModelFiles = await resolveSwiftFiles(cwd, SWIFT_MODEL_CANDIDATES);
for (const relPath of swiftFiles) { for (const relPath of swiftModelFiles) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) { for (const mode of modes) {
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`); const pattern = new RegExp(`\\bcase\\s+${mode}\\b`);
expect(pattern.test(content), `${relPath} missing case ${channel}`).toBe(true); expect(pattern.test(content), `${relPath} missing case ${mode}`).toBe(true);
} }
} }
}); });
@@ -78,7 +70,7 @@ describe("cron protocol conformance", () => {
expect(uiTypes.includes("jobs:")).toBe(true); expect(uiTypes.includes("jobs:")).toBe(true);
expect(uiTypes.includes("jobCount")).toBe(false); expect(uiTypes.includes("jobCount")).toBe(false);
const [swiftRelPath] = await resolveSwiftFiles(cwd); const [swiftRelPath] = await resolveSwiftFiles(cwd, SWIFT_STATUS_CANDIDATES);
const swiftPath = path.join(cwd, swiftRelPath); const swiftPath = path.join(cwd, swiftRelPath);
const swift = await fs.readFile(swiftPath, "utf-8"); const swift = await fs.readFile(swiftPath, "utf-8");
expect(swift.includes("struct CronSchedulerStatus")).toBe(true); expect(swift.includes("struct CronSchedulerStatus")).toBe(true);

View File

@@ -270,9 +270,12 @@ describe("CronService", () => {
await cron.start(); await cron.start();
const jobs = await cron.list({ includeDisabled: true }); const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id); const job = jobs.find((j) => j.id === rawJob.id);
// Legacy delivery fields are migrated to the top-level delivery object
const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
const payload = job?.payload as unknown as Record<string, unknown>; const payload = job?.payload as unknown as Record<string, unknown>;
expect(payload.channel).toBe("telegram");
expect("provider" in payload).toBe(false); expect("provider" in payload).toBe(false);
expect("channel" in payload).toBe(false);
cron.stop(); cron.stop();
await store.cleanup(); await store.cleanup();
@@ -321,8 +324,9 @@ describe("CronService", () => {
await cron.start(); await cron.start();
const jobs = await cron.list({ includeDisabled: true }); const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id); const job = jobs.find((j) => j.id === rawJob.id);
const payload = job?.payload as unknown as Record<string, unknown>; // Legacy delivery fields are migrated to the top-level delivery object
expect(payload.channel).toBe("telegram"); const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
cron.stop(); cron.stop();
await store.cleanup(); await store.cleanup();

View File

@@ -126,23 +126,24 @@ async function getFileMtimeMs(path: string): Promise<number | null> {
} }
} }
export async function ensureLoaded(state: CronServiceState) { export async function ensureLoaded(state: CronServiceState, opts?: { forceReload?: boolean }) {
const fileMtimeMs = await getFileMtimeMs(state.deps.storePath); // Fast path: store is already in memory. The timer path passes
// forceReload=true so that cross-service writes to the same store file
// Check if we need to reload: // are always picked up. Other callers (add, list, run, …) trust the
// - No store loaded yet // in-memory copy to avoid a stat syscall on every operation.
// - File modification time has changed if (state.store && !opts?.forceReload) {
// - File was modified after we last loaded (external edit)
const needsReload =
!state.store ||
(fileMtimeMs !== null &&
state.storeFileMtimeMs !== null &&
fileMtimeMs > state.storeFileMtimeMs);
if (!needsReload) {
return; return;
} }
if (opts?.forceReload && state.store) {
// Only pay for the stat when we're explicitly checking for external edits.
const mtime = await getFileMtimeMs(state.deps.storePath);
if (mtime !== null && state.storeFileMtimeMs !== null && mtime === state.storeFileMtimeMs) {
return; // File unchanged since our last load/persist.
}
}
const fileMtimeMs = await getFileMtimeMs(state.deps.storePath);
const loaded = await loadCronStore(state.deps.storePath); const loaded = await loadCronStore(state.deps.storePath);
const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>; const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>;
let mutated = false; let mutated = false;

View File

@@ -37,7 +37,7 @@ export async function onTimer(state: CronServiceState) {
state.running = true; state.running = true;
try { try {
await locked(state, async () => { await locked(state, async () => {
await ensureLoaded(state); await ensureLoaded(state, { forceReload: true });
await runDueJobs(state); await runDueJobs(state);
await persist(state); await persist(state);
armTimer(state); armTimer(state);
@@ -184,6 +184,18 @@ export async function executeJob(
job, job,
message: job.payload.message, message: job.payload.message,
}); });
// Post a short summary back to the main session so the user sees
// the cron result without opening the isolated session.
const summaryText = res.summary?.trim();
if (summaryText) {
const prefix = "Cron";
const label =
res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`;
state.deps.enqueueSystemEvent(label, { agentId: job.agentId });
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
}
if (res.status === "ok") { if (res.status === "ok") {
await finish("ok", undefined, res.summary); await finish("ok", undefined, res.summary);
} else if (res.status === "skipped") { } else if (res.status === "skipped") {

View File

@@ -42,10 +42,6 @@ export const CronPayloadSchema = Type.Union([
model: Type.Optional(Type.String()), model: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
deliver: Type.Optional(Type.Boolean()),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffortDeliver: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
@@ -66,10 +62,6 @@ export const CronPayloadPatchSchema = Type.Union([
model: Type.Optional(Type.String()), model: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
deliver: Type.Optional(Type.Boolean()),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffortDeliver: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),

View File

@@ -164,28 +164,22 @@ describe("gateway server cron", () => {
const mergeUpdateRes = await rpcReq(ws, "cron.update", { const mergeUpdateRes = await rpcReq(ws, "cron.update", {
id: mergeJobId, id: mergeJobId,
patch: { patch: {
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" }, delivery: { mode: "announce", channel: "telegram", to: "19098680" },
}, },
}); });
expect(mergeUpdateRes.ok).toBe(true); expect(mergeUpdateRes.ok).toBe(true);
const merged = mergeUpdateRes.payload as const merged = mergeUpdateRes.payload as
| { | {
payload?: { payload?: { kind?: unknown; message?: unknown; model?: unknown };
kind?: unknown; delivery?: { mode?: unknown; channel?: unknown; to?: unknown };
message?: unknown;
model?: unknown;
deliver?: unknown;
channel?: unknown;
to?: unknown;
};
} }
| undefined; | undefined;
expect(merged?.payload?.kind).toBe("agentTurn"); expect(merged?.payload?.kind).toBe("agentTurn");
expect(merged?.payload?.message).toBe("hello"); expect(merged?.payload?.message).toBe("hello");
expect(merged?.payload?.model).toBe("opus"); expect(merged?.payload?.model).toBe("opus");
expect(merged?.payload?.deliver).toBe(true); expect(merged?.delivery?.mode).toBe("announce");
expect(merged?.payload?.channel).toBe("telegram"); expect(merged?.delivery?.channel).toBe("telegram");
expect(merged?.payload?.to).toBe("19098680"); expect(merged?.delivery?.to).toBe("19098680");
const rejectRes = await rpcReq(ws, "cron.add", { const rejectRes = await rpcReq(ws, "cron.add", {
name: "patch reject", name: "patch reject",
@@ -203,7 +197,7 @@ describe("gateway server cron", () => {
const rejectUpdateRes = await rpcReq(ws, "cron.update", { const rejectUpdateRes = await rpcReq(ws, "cron.update", {
id: rejectJobId, id: rejectJobId,
patch: { patch: {
payload: { kind: "agentTurn", deliver: true }, payload: { kind: "agentTurn", message: "nope" },
}, },
}); });
expect(rejectUpdateRes.ok).toBe(false); expect(rejectUpdateRes.ok).toBe(false);