diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 03f6fa9cafd..c5adec8f94c 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -90,7 +90,7 @@ describe("canvas host", () => { } }); - it("serves canvas content from the mounted base path", async () => { + it("serves canvas content from the mounted base path and reuses handlers without double close", async () => { const dir = await createCaseDir(); await fs.writeFile(path.join(dir, "index.html"), "v1", "utf8"); @@ -131,28 +131,15 @@ describe("canvas host", () => { const miss = await fetch(`http://127.0.0.1:${port}/`); expect(miss.status).toBe(404); } finally { - await handler.close(); await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())), ); } - }); - - it("reuses a handler without closing it twice", async () => { - const dir = await createCaseDir(); - await fs.writeFile(path.join(dir, "index.html"), "v1", "utf8"); - - const handler = await createCanvasHostHandler({ - runtime: quietRuntime, - rootDir: dir, - basePath: CANVAS_HOST_PATH, - allowInTests: true, - }); const originalClose = handler.close; const closeSpy = vi.fn(async () => originalClose()); handler.close = closeSpy; - const server = await startCanvasHost({ + const hosted = await startCanvasHost({ runtime: quietRuntime, handler, ownsHandler: false, @@ -162,9 +149,9 @@ describe("canvas host", () => { }); try { - expect(server.port).toBeGreaterThan(0); + expect(hosted.port).toBeGreaterThan(0); } finally { - await server.close(); + await hosted.close(); expect(closeSpy).not.toHaveBeenCalled(); await originalClose(); } diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 2889d18f267..2c369b2f923 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CronJob } from "./types.js"; import { CronService } from "./service.js"; import { createCronServiceState, type CronEvent } from "./service/state.js"; @@ -16,8 +16,12 @@ const noopLogger = { trace: vi.fn(), }; +let fixtureRoot = ""; +let fixtureCount = 0; + async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cron-issues-")); + const dir = path.join(fixtureRoot, `case-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); const storePath = path.join(dir, "jobs.json"); return { storePath, @@ -50,23 +54,32 @@ function createDueIsolatedJob(params: { } describe("Cron issue regressions", () => { + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cron-issues-")); + }); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z")); }); + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); - it("recalculates nextRunAtMs when schedule changes", async () => { + it("covers schedule updates, force runs, isolated wake scheduling, and payload patching", async () => { const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); const cron = new CronService({ cronEnabled: true, storePath: store.storePath, log: noopLogger, - enqueueSystemEvent: vi.fn(), + enqueueSystemEvent, requestHeartbeatNow: vi.fn(), runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }), }); @@ -86,51 +99,18 @@ describe("Cron issue regressions", () => { expect(updated.state.nextRunAtMs).toBe(Date.parse("2026-02-06T12:00:00.000Z")); - cron.stop(); - await store.cleanup(); - }); - - it("runs immediately with force mode even when not due", async () => { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }), - }); - await cron.start(); - - const created = await cron.add({ + const forceNow = await cron.add({ name: "force-now", schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() }, sessionTarget: "main", payload: { kind: "systemEvent", text: "force" }, }); - const result = await cron.run(created.id, "force"); + const result = await cron.run(forceNow.id, "force"); expect(result).toEqual({ ok: true, ran: true }); expect(enqueueSystemEvent).toHaveBeenCalledWith("force", { agentId: undefined }); - cron.stop(); - await store.cleanup(); - }); - - it("schedules isolated jobs with next wake time", async () => { - const store = await makeStorePath(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent: vi.fn(), - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }), - }); - await cron.start(); - const job = await cron.add({ name: "isolated", schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() }, @@ -142,37 +122,21 @@ describe("Cron issue regressions", () => { expect(typeof job.state.nextRunAtMs).toBe("number"); expect(typeof status.nextWakeAtMs).toBe("number"); - cron.stop(); - await store.cleanup(); - }); - - it("persists allowUnsafeExternalContent on agentTurn payload patches", async () => { - const store = await makeStorePath(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent: vi.fn(), - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }), - }); - await cron.start(); - - const created = await cron.add({ + const unsafeToggle = await cron.add({ name: "unsafe toggle", schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() }, sessionTarget: "isolated", payload: { kind: "agentTurn", message: "hi" }, }); - const updated = await cron.update(created.id, { + const patched = await cron.update(unsafeToggle.id, { payload: { kind: "agentTurn", allowUnsafeExternalContent: true }, }); - expect(updated.payload.kind).toBe("agentTurn"); - if (updated.payload.kind === "agentTurn") { - expect(updated.payload.allowUnsafeExternalContent).toBe(true); - expect(updated.payload.message).toBe("hi"); + expect(patched.payload.kind).toBe("agentTurn"); + if (patched.payload.kind === "agentTurn") { + expect(patched.payload.allowUnsafeExternalContent).toBe(true); + expect(patched.payload.message).toBe("hi"); } cron.stop(); @@ -304,14 +268,10 @@ describe("Cron issue regressions", () => { await store.cleanup(); }); - it("#13845: one-shot job with lastStatus=skipped does not re-fire on restart", async () => { + it("#13845: one-shot jobs with terminal statuses do not re-fire on restart", async () => { const store = await makeStorePath(); const pastAt = Date.parse("2026-02-06T09:00:00.000Z"); - // Simulate a one-shot job that was previously skipped (e.g. main session busy). - // On the old code, runMissedJobs only checked lastStatus === "ok", so a - // skipped job would pass through and fire again on every restart. - const skippedJob: CronJob = { - id: "oneshot-skipped", + const baseJob = { name: "reminder", enabled: true, deleteAfterRun: true, @@ -321,79 +281,46 @@ describe("Cron issue regressions", () => { sessionTarget: "main", wakeMode: "now", payload: { kind: "systemEvent", text: "⏰ Reminder" }, - state: { - nextRunAtMs: pastAt, - lastStatus: "skipped", - lastRunAtMs: pastAt, - }, - }; - await fs.writeFile( - store.storePath, - JSON.stringify({ version: 1, jobs: [skippedJob] }, null, 2), - "utf-8", - ); + } as const; + for (const [id, state] of [ + [ + "oneshot-skipped", + { + nextRunAtMs: pastAt, + lastStatus: "skipped" as const, + lastRunAtMs: pastAt, + }, + ], + [ + "oneshot-errored", + { + nextRunAtMs: pastAt, + lastStatus: "error" as const, + lastRunAtMs: pastAt, + lastError: "heartbeat failed", + }, + ], + ]) { + const job: CronJob = { id, ...baseJob, state }; + await fs.writeFile( + store.storePath, + JSON.stringify({ version: 1, jobs: [job] }, null, 2), + "utf-8", + ); + const enqueueSystemEvent = vi.fn(); + const cron = new CronService({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }), + }); - const enqueueSystemEvent = vi.fn(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }), - }); - - // start() calls runMissedJobs internally - await cron.start(); - - // The skipped one-shot job must NOT be re-enqueued - expect(enqueueSystemEvent).not.toHaveBeenCalled(); - - cron.stop(); - await store.cleanup(); - }); - - it("#13845: one-shot job with lastStatus=error does not re-fire on restart", async () => { - const store = await makeStorePath(); - const pastAt = Date.parse("2026-02-06T09:00:00.000Z"); - const errorJob: CronJob = { - id: "oneshot-errored", - name: "reminder", - enabled: true, - deleteAfterRun: true, - createdAtMs: pastAt - 60_000, - updatedAtMs: pastAt, - schedule: { kind: "at", at: new Date(pastAt).toISOString() }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: "⏰ Reminder" }, - state: { - nextRunAtMs: pastAt, - lastStatus: "error", - lastRunAtMs: pastAt, - lastError: "heartbeat failed", - }, - }; - await fs.writeFile( - store.storePath, - JSON.stringify({ version: 1, jobs: [errorJob] }, null, 2), - "utf-8", - ); - - const enqueueSystemEvent = vi.fn(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow: vi.fn(), - runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }), - }); - - await cron.start(); - expect(enqueueSystemEvent).not.toHaveBeenCalled(); - - cron.stop(); + await cron.start(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + cron.stop(); + } await store.cleanup(); });