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

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

View File

@@ -37,7 +37,7 @@ export async function onTimer(state: CronServiceState) {
state.running = true;
try {
await locked(state, async () => {
await ensureLoaded(state);
await ensureLoaded(state, { forceReload: true });
await runDueJobs(state);
await persist(state);
armTimer(state);
@@ -184,6 +184,18 @@ export async function executeJob(
job,
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") {
await finish("ok", undefined, res.summary);
} else if (res.status === "skipped") {