summaryrefslogtreecommitdiffhomepage
path: root/packages/heartbeat/src/heartbeat.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/heartbeat/src/heartbeat.test.ts')
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts781
1 files changed, 781 insertions, 0 deletions
diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts
new file mode 100644
index 0000000..a6f7ebb
--- /dev/null
+++ b/packages/heartbeat/src/heartbeat.test.ts
@@ -0,0 +1,781 @@
+import type { StorageNamespace } from "@dispatch/kernel";
+import type {
+ EnqueueInput,
+ EnqueueResult,
+ SessionOrchestrator,
+ StartTurnResult,
+ TurnEventListener,
+} from "@dispatch/session-orchestrator";
+import { describe, expect, it } from "vitest";
+import { createHeartbeatService } from "./heartbeat.js";
+
+function createMemoryStorage(): StorageNamespace {
+ const data = new Map<string, string>();
+ return {
+ get: async (key) => data.get(key) ?? null,
+ set: async (key, value) => {
+ data.set(key, value);
+ },
+ delete: async (key) => {
+ data.delete(key);
+ },
+ has: async (key) => data.has(key),
+ keys: async (prefix) => {
+ const all = [...data.keys()];
+ if (prefix === undefined) return all;
+ return all.filter((k) => k.startsWith(prefix));
+ },
+ };
+}
+
+/** A controllable fake clock with an `advance` to move virtual time. */
+function createFakeTimers() {
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map<number, { readonly fn: () => void; readonly firesAt: number }>();
+ return {
+ timers: {
+ now: () => now,
+ setTimeout: (fn: () => void, ms: number) => {
+ const id = nextId++;
+ timers.set(id, { fn, firesAt: now + ms });
+ return id as unknown as ReturnType<typeof setTimeout>;
+ },
+ clearTimeout: (handle: ReturnType<typeof setTimeout> | undefined) => {
+ if (handle !== undefined) timers.delete(handle as unknown as number);
+ },
+ },
+ advance(ms: number): void {
+ now += ms;
+ const due = [...timers.entries()]
+ .filter(([, t]) => t.firesAt <= now)
+ .sort((a, b) => a[0] - b[0]);
+ for (const [id, t] of due) {
+ timers.delete(id);
+ t.fn();
+ }
+ },
+ };
+}
+
+interface PendingTurn {
+ readonly conversationId: string;
+ readonly text: string;
+ readonly systemPrompt?: string;
+ readonly modelName?: string;
+ readonly reasoningEffort?: unknown;
+ readonly workspaceId?: string;
+ readonly cwd?: string;
+ resolve: () => void;
+}
+
+/**
+ * A fake orchestrator that records handleMessage calls and lets the test
+ * control when each turn seals. This is the injected edge — the service depends
+ * on the SessionOrchestrator interface, not its implementation.
+ */
+function createFakeOrchestrator(): SessionOrchestrator & {
+ readonly pending: readonly PendingTurn[];
+ readonly stopped: readonly string[];
+} {
+ const pending: PendingTurn[] = [];
+ const stopped: string[] = [];
+ const turns = new Map<string, { resolve: () => void; reject: (e: unknown) => void }>();
+
+ const fake: SessionOrchestrator = {
+ startTurn(): StartTurnResult {
+ return { started: false, reason: "already-active" };
+ },
+ enqueue(_input: EnqueueInput): EnqueueResult {
+ return { startedTurn: false, queue: [] };
+ },
+ subscribe(_conversationId: string, _listener: TurnEventListener): () => void {
+ return () => {};
+ },
+ isActive(_conversationId: string): boolean {
+ return false;
+ },
+ closeConversation(_conversationId: string): { abortedTurn: boolean } {
+ return { abortedTurn: false };
+ },
+ stopTurn(conversationId: string): { abortedTurn: boolean } {
+ stopped.push(conversationId);
+ const t = turns.get(conversationId);
+ if (t !== undefined) {
+ turns.delete(conversationId);
+ t.resolve();
+ }
+ return { abortedTurn: true };
+ },
+ handleMessage(input): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ const entry: PendingTurn = {
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}),
+ ...(input.modelName !== undefined ? { modelName: input.modelName } : {}),
+ ...(input.reasoningEffort !== undefined
+ ? { reasoningEffort: input.reasoningEffort }
+ : {}),
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
+ resolve,
+ };
+ pending.push(entry);
+ turns.set(input.conversationId, { resolve, reject });
+ });
+ },
+ };
+ return Object.assign(fake, {
+ get pending(): readonly PendingTurn[] {
+ return pending;
+ },
+ get stopped(): readonly string[] {
+ return stopped;
+ },
+ });
+}
+
+// Drain microtasks so async .finally handlers (run completion) run. `fire` has
+// nested awaits (configStore.get → storage.get, runStore.create → storage.set)
+// before it reaches handleMessage, so a single queueMicrotask isn't enough —
+// setTimeout(0) schedules a macrotask, letting ALL pending microtasks drain.
+const flush = async (): Promise<void> => {
+ await new Promise((r) => setTimeout(r, 0));
+};
+
+function createService(opts: {
+ readonly orch: ReturnType<typeof createFakeOrchestrator>;
+ readonly storage?: StorageNamespace;
+ readonly resolvePrompt?: (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ) => Promise<string>;
+ readonly getGlobalSystemPrompt?: () => Promise<string>;
+ readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ readonly hasActiveAgents?: (workspaceId: string) => Promise<boolean>;
+}) {
+ const fake = createFakeTimers();
+ let id = 0;
+ const storage = opts.storage ?? createMemoryStorage();
+ const svc = createHeartbeatService({
+ storage,
+ orchestrator: opts.orch,
+ timers: fake.timers,
+ generateId: () => `id-${++id}`,
+ ...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}),
+ ...(opts.getGlobalSystemPrompt !== undefined
+ ? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt }
+ : {}),
+ ...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}),
+ ...(opts.hasActiveAgents !== undefined ? { hasActiveAgents: opts.hasActiveAgents } : {}),
+ });
+ return { svc, advance: fake.advance, storage };
+}
+
+describe("createHeartbeatService", () => {
+ it("returns the default config for an unknown workspace", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ const cfg = await svc.getConfig("ws-1");
+ expect(cfg.enabled).toBe(false);
+ expect(cfg.intervalMinutes).toBe(30);
+ // inactiveOnly defaults ON (the heartbeat is quiet by default while the
+ // workspace is busy).
+ expect(cfg.inactiveOnly).toBe(true);
+ });
+
+ describe("inactiveOnly (skip fire while the workspace has active agents)", () => {
+ it("skips the fire (records no run) when inactiveOnly is true and the workspace has active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(true),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // interval elapses → fire
+ await flush(); // let the async fire() reach the active-agent check + return
+
+ expect(orch.pending).toHaveLength(0); // no turn started
+ expect(await svc.listRuns("ws-1")).toHaveLength(0); // no run recorded
+ });
+
+ it("still fires when inactiveOnly is true but the workspace has NO active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(false),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("fires unconditionally when inactiveOnly is false, even with active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ // Active agents reported, but the setting is OFF → must not block.
+ hasActiveAgents: () => Promise.resolve(true),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ inactiveOnly: false,
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("re-arms and fires on the next interval after a skipped fire (the scheduler keeps ticking)", async () => {
+ const orch = createFakeOrchestrator();
+ let busy = true; // workspace busy on the first fire, free on the next
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(busy),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // first fire — skipped (busy)
+ await flush();
+ expect(orch.pending).toHaveLength(0);
+
+ busy = false; // workspace goes idle
+ advance(60_000); // next interval → fire again
+ await flush();
+ expect(orch.pending).toHaveLength(1); // now it fires
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("does not consult hasActiveAgents when inactiveOnly is false (degrades off cleanly)", async () => {
+ const orch = createFakeOrchestrator();
+ let consulted = false;
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => {
+ consulted = true;
+ return Promise.resolve(true);
+ },
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ inactiveOnly: false,
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(consulted).toBe(false); // never asked — setting is off
+ expect(orch.pending).toHaveLength(1);
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("checks active agents per configured workspace (only the configured workspace is consulted)", async () => {
+ const orch = createFakeOrchestrator();
+ const busyWorkspaces = new Set<string>(["ws-busy"]);
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: (wsId) => Promise.resolve(busyWorkspaces.has(wsId)),
+ });
+ // ws-free is idle, ws-busy has active agents.
+ await svc.updateConfig("ws-free", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ await svc.updateConfig("ws-busy", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // both fire
+ await flush();
+ // ws-free fired (idle), ws-busy skipped (busy).
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-free"))[0]?.status).toBe("running");
+ expect(await svc.listRuns("ws-busy")).toHaveLength(0);
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+ });
+
+ it("arming an enabled config does not fire immediately (waits for the interval)", () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ void svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" });
+ // Just arming → no turn yet.
+ expect(orch.pending).toHaveLength(0);
+ // Advancing just shy of the interval still no fire.
+ advance(59_999);
+ expect(orch.pending).toHaveLength(0);
+ });
+
+ it("fire sends the task prompt with the explicit system prompt + model + effort, and marks completed on seal", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "you are a monitor",
+ taskPrompt: "check stuck chats",
+ model: "opencode/gpt-4o",
+ reasoningEffort: "high",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000); // 1-minute interval → fire
+ await flush(); // let the async fire() reach handleMessage
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ expect(turn.text).toBe("check stuck chats");
+ expect(turn.systemPrompt).toBe("you are a monitor");
+ expect(turn.modelName).toBe("opencode/gpt-4o");
+ expect(turn.reasoningEffort).toBe("high");
+ // Spawned conversations go to the DEDICATED heartbeat workspace (not
+ // the configured workspace), while the run stays tracked under ws-1.
+ expect(turn.workspaceId).toBe("heartbeat");
+
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+
+ turn.resolve();
+ await flush();
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("completed");
+ });
+
+ it("heartbeat conversations always go to the dedicated heartbeat workspace, regardless of the configured workspace", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ // Two DIFFERENT workspaces each configure a heartbeat.
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ await svc.updateConfig("ws-2", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // both fire (1-minute interval)
+ await flush();
+ expect(orch.pending).toHaveLength(2);
+ // Every spawned conversation is filed in the heartbeat workspace —
+ // NOT ws-1 or ws-2 — so heartbeat runs don't clog either workspace's
+ // tabs. The run history, however, stays tracked per configured workspace.
+ for (const turn of orch.pending) {
+ expect(turn.workspaceId).toBe("heartbeat");
+ }
+ // Run history is still per configured workspace.
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ expect((await svc.listRuns("ws-2"))[0]?.status).toBe("running");
+ for (const turn of orch.pending) {
+ turn.resolve();
+ }
+ await flush();
+ });
+
+ it("the turn cwd is pinned to the CONFIGURED workspace's defaultCwd (not the heartbeat workspace's empty defaultCwd)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ // The configured workspace has a defaultCwd.
+ getWorkspaceCwd: (wsId) => Promise.resolve(wsId === "ws-1" ? "/home/proj/ws-1" : null),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // The conversation is filed in the heartbeat workspace …
+ expect(turn.workspaceId).toBe("heartbeat");
+ // … but the turn's cwd is the CONFIGURED workspace's defaultCwd, so
+ // tools run where the prompt's [prompt:cwd] advertises (not the
+ // heartbeat workspace's empty defaultCwd → process.cwd()).
+ expect(turn.cwd).toBe("/home/proj/ws-1");
+ turn.resolve();
+ await flush();
+ });
+
+ it("omits the cwd override when the configured workspace has no defaultCwd (orchestrator falls back to the server default)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ // The configured workspace has NO defaultCwd.
+ getWorkspaceCwd: () => Promise.resolve(null),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.workspaceId).toBe("heartbeat");
+ // No cwd override sent — the orchestrator resolves the turn cwd from
+ // the heartbeat workspace (no defaultCwd → server default cwd).
+ expect(turn.cwd).toBeUndefined();
+ turn.resolve();
+ await flush();
+ });
+
+ it("omits the cwd override when getWorkspaceCwd is not wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No getWorkspaceCwd → no cwd override (the default).
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.workspaceId).toBe("heartbeat");
+ expect(turn.cwd).toBeUndefined();
+ turn.resolve();
+ await flush();
+ });
+
+ it("omits modelName/reasoningEffort when empty/null (inherit defaults)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ taskPrompt: "go",
+ model: "",
+ reasoningEffort: null,
+ intervalMinutes: 1,
+ });
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.modelName).toBeUndefined();
+ expect(turn.reasoningEffort).toBeUndefined();
+ turn.resolve();
+ await flush();
+ });
+
+ it("resolves [type:name] variables in both systemPrompt and taskPrompt before sending", async () => {
+ const orch = createFakeOrchestrator();
+ // A fake resolver that mirrors the real resolver's contract: substitute
+ // known [type:name] placeholders, leave unknown text verbatim.
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ return template
+ .replaceAll("[system:os]", "Linux (WSL)")
+ .replaceAll("[prompt:cwd]", "/repo")
+ .replaceAll("[prompt:workspace_id]", ctx.workspaceId)
+ .replaceAll("[prompt:conversation_id]", ctx.conversationId)
+ .replaceAll("[prompt:model]", ctx.model);
+ };
+ const { svc, advance } = createService({ orch, resolvePrompt });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id]).",
+ taskPrompt: "Check chats for [prompt:conversation_id] on [system:os].",
+ model: "opencode/gpt-4o",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ // Variables substituted — NOT left as literal [type:name] text.
+ expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
+ expect(turn.text).toBe(`Check chats for ${turn.conversationId} on Linux (WSL).`);
+ expect(turn.modelName).toBe("opencode/gpt-4o");
+ turn.resolve();
+ await flush();
+ });
+
+ it("passes prompts through raw when no resolver is wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No resolvePrompt → raw pass-through (the default).
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "raw [system:os] prompt",
+ taskPrompt: "raw [system:date] task",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // Unresolved — literals reach the orchestrator verbatim.
+ expect(turn.systemPrompt).toBe("raw [system:os] prompt");
+ expect(turn.text).toBe("raw [system:date] task");
+ turn.resolve();
+ await flush();
+ });
+
+ it("an empty systemPrompt inherits the global system prompt template (CR-HB-2)", async () => {
+ const orch = createFakeOrchestrator();
+ // The global getter returns the workspace's regular system prompt
+ // template (the same one GET /system-prompt returns). No resolver → the
+ // inherited template reaches the orchestrator verbatim (isolates CR-HB-2
+ // from CR-HB-1).
+ const { svc, advance } = createService({
+ orch,
+ getGlobalSystemPrompt: () => Promise.resolve("GLOBAL DEFAULT TEMPLATE"),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ // Empty systemPrompt = inherit the global default, NOT "no prompt".
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ expect(turn.systemPrompt).toBe("GLOBAL DEFAULT TEMPLATE");
+ turn.resolve();
+ await flush();
+ });
+
+ it("CR-HB-2 composes with CR-HB-1: the inherited global template's [type:name] placeholders are resolved", async () => {
+ const orch = createFakeOrchestrator();
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ return template
+ .replaceAll("[system:os]", "Linux (WSL)")
+ .replaceAll("[prompt:cwd]", "/repo")
+ .replaceAll("[prompt:workspace_id]", ctx.workspaceId);
+ };
+ // The global template carries [type:name] placeholders (like the real
+ // default template embeds [prompt:cwd] / [file:AGENTS.md]).
+ const { svc, advance } = createService({
+ orch,
+ resolvePrompt,
+ getGlobalSystemPrompt: () =>
+ Promise.resolve("You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id])."),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ // Empty → inherit the global template, THEN resolve its placeholders.
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ // The global template was inherited (not empty), then its [type:name]
+ // placeholders were substituted — NOT left literal, NOT empty.
+ expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
+ turn.resolve();
+ await flush();
+ });
+
+ it("a non-empty systemPrompt override bypasses the global template (only CR-HB-1 applies)", async () => {
+ const orch = createFakeOrchestrator();
+ const resolvePrompt = async (template: string): Promise<string> =>
+ template.replaceAll("[system:os]", "Linux (WSL)");
+ const { svc, advance } = createService({
+ orch,
+ resolvePrompt,
+ // A DISTINCT global template — must NOT be used when overriding.
+ getGlobalSystemPrompt: () => Promise.resolve("GLOBAL SHOULD NOT APPEAR [system:os]"),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "custom override on [system:os]",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // The override is used (and resolved), not the global template.
+ expect(turn.systemPrompt).toBe("custom override on Linux (WSL)");
+ turn.resolve();
+ await flush();
+ });
+
+ it("an empty systemPrompt stays empty when no global getter is wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No getGlobalSystemPrompt → empty stays empty (no system prompt).
+ // Mirrors the no-resolver pass-through default: both deps are optional.
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.systemPrompt).toBe("");
+ turn.resolve();
+ await flush();
+ });
+
+ it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ advance(60_000);
+ await flush();
+ const conversationId = orch.pending[0]?.conversationId;
+ const runId = (await svc.listRuns("ws-1"))[0]?.id;
+
+ const res = await svc.stopRun("ws-1", runId);
+ expect(res).toEqual({ ok: true });
+ expect(orch.stopped).toEqual([conversationId]);
+
+ await flush(); // the aborted turn's handleMessage resolves
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped");
+ });
+
+ it("stopRun is idempotent for an already-finished run", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ advance(60_000);
+ await flush();
+ orch.pending[0]?.resolve();
+ await flush();
+
+ const runId = (await svc.listRuns("ws-1"))[0]?.id;
+ const res = await svc.stopRun("ws-1", runId);
+ expect(res).toEqual({ ok: true });
+ expect(orch.stopped).toEqual([]); // no abort on a completed run
+ });
+
+ it("stopRun throws for an unknown run id", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await expect(svc.stopRun("ws-1", "nope")).rejects.toThrow();
+ });
+
+ it("disabling the config stops the schedule (no further fires)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" });
+ await svc.updateConfig("ws-1", { enabled: false });
+ advance(60_000);
+ expect(orch.pending).toHaveLength(0);
+ });
+
+ it("startAll sweeps stale running runs to stopped", async () => {
+ const storage = createMemoryStorage();
+ await storage.set(
+ "run:ws-1:stale",
+ JSON.stringify({
+ id: "stale",
+ conversationId: "c-stale",
+ triggeredAt: "2026-01-01T00:00:00.000Z",
+ status: "running",
+ }),
+ );
+ await storage.set(
+ "config:ws-1",
+ JSON.stringify({
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: 30,
+ model: "",
+ reasoningEffort: null,
+ }),
+ );
+
+ const { svc } = createService({ orch: createFakeOrchestrator(), storage });
+ await svc.startAll();
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped");
+ });
+
+ it("startAll arms enabled workspaces and skips disabled ones", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc: svc1, storage } = createService({ orch });
+ await svc1.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ await svc1.updateConfig("ws-2", { enabled: false, taskPrompt: "no" });
+ svc1.stopAll();
+
+ // Re-create a fresh service over the SAME storage (simulates a reboot),
+ // with new fake timers we can advance.
+ const fake = createFakeTimers();
+ let id = 1000;
+ const svc2 = createHeartbeatService({
+ storage,
+ orchestrator: orch,
+ timers: fake.timers,
+ generateId: () => `id-${++id}`,
+ });
+ await svc2.startAll();
+ fake.advance(60_000);
+ await flush();
+ // ws-1 (enabled) fired → its spawned conversation is filed in the
+ // heartbeat workspace; ws-2 (disabled) never fired.
+ expect(orch.pending).toHaveLength(1);
+ expect(orch.pending[0]?.workspaceId).toBe("heartbeat");
+ });
+
+ // ─── nextRunAt (CR-HB-3: server-authoritative next-run time) ────────────────
+
+ it("nextRunAt returns null when the heartbeat is disabled", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ // Default config → disabled → no schedule armed.
+ expect(await svc.nextRunAt("ws-1")).toBeNull();
+ });
+
+ it("nextRunAt returns the ISO timestamp of the next fire when enabled", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ // now=0, interval=1m → next fire at epoch-ms 60_000 → its ISO form.
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
+ });
+
+ it("nextRunAt returns null while a run is in progress, then the next fire after it completes", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
+
+ advance(60_000); // fire → run in progress
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ // In flight → no next run queued yet.
+ expect(await svc.nextRunAt("ws-1")).toBeNull();
+
+ orch.pending[0]?.resolve();
+ await flush();
+ // Re-armed at completion-time (60_000) + interval (60_000) = 120_000.
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(120_000).toISOString());
+ });
+
+ it("nextRunAt returns null after disabling the heartbeat", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ expect(await svc.nextRunAt("ws-1")).not.toBeNull();
+ await svc.updateConfig("ws-1", { enabled: false });
+ expect(await svc.nextRunAt("ws-1")).toBeNull();
+ });
+
+ it("nextRunAt reflects a changed interval on the next re-arm", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
+ // Re-arm with a 5-minute interval (not running) → recomputed fire time.
+ await svc.updateConfig("ws-1", { intervalMinutes: 5 });
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(5 * 60_000).toISOString());
+ });
+});