summaryrefslogtreecommitdiffhomepage
path: root/packages/heartbeat
diff options
context:
space:
mode:
Diffstat (limited to 'packages/heartbeat')
-rw-r--r--packages/heartbeat/package.json15
-rw-r--r--packages/heartbeat/src/config-store.test.ts152
-rw-r--r--packages/heartbeat/src/config-store.ts106
-rw-r--r--packages/heartbeat/src/extension.ts121
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts781
-rw-r--r--packages/heartbeat/src/heartbeat.ts354
-rw-r--r--packages/heartbeat/src/index.ts17
-rw-r--r--packages/heartbeat/src/run-store.test.ts72
-rw-r--r--packages/heartbeat/src/run-store.ts95
-rw-r--r--packages/heartbeat/src/scheduler.test.ts343
-rw-r--r--packages/heartbeat/src/scheduler.ts193
-rw-r--r--packages/heartbeat/tsconfig.json12
12 files changed, 2261 insertions, 0 deletions
diff --git a/packages/heartbeat/package.json b/packages/heartbeat/package.json
new file mode 100644
index 0000000..d83e631
--- /dev/null
+++ b/packages/heartbeat/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@dispatch/heartbeat",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*"
+ }
+}
diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts
new file mode 100644
index 0000000..9a6772d
--- /dev/null
+++ b/packages/heartbeat/src/config-store.test.ts
@@ -0,0 +1,152 @@
+import type { StorageNamespace } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import {
+ applyConfigUpdate,
+ createHeartbeatConfigStore,
+ DEFAULT_HEARTBEAT_CONFIG,
+} from "./config-store.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));
+ },
+ };
+}
+
+describe("applyConfigUpdate (pure)", () => {
+ it("leaves omitted fields unchanged from the default", () => {
+ const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {});
+ expect(next).toEqual(DEFAULT_HEARTBEAT_CONFIG);
+ });
+
+ it("applies provided fields", () => {
+ const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {
+ enabled: true,
+ systemPrompt: "you are a monitor",
+ taskPrompt: "check for stuck chats",
+ model: "opencode/gpt-4o",
+ });
+ expect(next.enabled).toBe(true);
+ expect(next.systemPrompt).toBe("you are a monitor");
+ expect(next.taskPrompt).toBe("check for stuck chats");
+ expect(next.model).toBe("opencode/gpt-4o");
+ // Untouched fields keep defaults.
+ expect(next.intervalMinutes).toBe(30);
+ expect(next.reasoningEffort).toBeNull();
+ });
+
+ it("clamps intervalMinutes to a minimum of 1", () => {
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes,
+ ).toBe(1);
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes,
+ ).toBe(1);
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes,
+ ).toBe(7);
+ });
+
+ it("truncates a non-integer interval to an integer", () => {
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 12.9 }).intervalMinutes,
+ ).toBe(12);
+ });
+
+ it("clears reasoningEffort when null is passed", () => {
+ const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "high" });
+ expect(withEffort.reasoningEffort).toBe("high");
+ const cleared = applyConfigUpdate(withEffort, { reasoningEffort: null });
+ expect(cleared.reasoningEffort).toBeNull();
+ });
+
+ it("treats an absent reasoningEffort as unchanged (distinct from null)", () => {
+ const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "low" });
+ const untouched = applyConfigUpdate(withEffort, { enabled: true });
+ expect(untouched.reasoningEffort).toBe("low");
+ });
+
+ it("defaults inactiveOnly to true (the heartbeat is quiet by default while the workspace is busy)", () => {
+ expect(DEFAULT_HEARTBEAT_CONFIG.inactiveOnly).toBe(true);
+ });
+
+ it("applies an inactiveOnly update", () => {
+ const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { inactiveOnly: false });
+ expect(next.inactiveOnly).toBe(false);
+ });
+
+ it("leaves inactiveOnly unchanged when absent from the update", () => {
+ const off = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { inactiveOnly: false });
+ const untouched = applyConfigUpdate(off, { enabled: true });
+ expect(untouched.inactiveOnly).toBe(false);
+ });
+});
+
+describe("createHeartbeatConfigStore", () => {
+ it("returns the default config for an unknown workspace", async () => {
+ const store = createHeartbeatConfigStore(createMemoryStorage());
+ expect(await store.get("ws-1")).toEqual(DEFAULT_HEARTBEAT_CONFIG);
+ });
+
+ it("persists and round-trips an update", async () => {
+ const storage = createMemoryStorage();
+ const store = createHeartbeatConfigStore(storage);
+ const next = await store.update("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(next.enabled).toBe(true);
+ expect(next.intervalMinutes).toBe(5);
+ // A fresh store over the same storage reads the persisted value.
+ const store2 = createHeartbeatConfigStore(storage);
+ expect(await store2.get("ws-1")).toEqual(next);
+ });
+
+ it("applies a partial update on top of existing config", async () => {
+ const storage = createMemoryStorage();
+ const store = createHeartbeatConfigStore(storage);
+ await store.update("ws-1", { enabled: true, systemPrompt: "a", taskPrompt: "b" });
+ const next = await store.update("ws-1", { taskPrompt: "c" });
+ expect(next.enabled).toBe(true);
+ expect(next.systemPrompt).toBe("a");
+ expect(next.taskPrompt).toBe("c");
+ });
+
+ it("round-trips inactiveOnly through persistence", async () => {
+ const storage = createMemoryStorage();
+ const store = createHeartbeatConfigStore(storage);
+ const next = await store.update("ws-1", { inactiveOnly: false });
+ expect(next.inactiveOnly).toBe(false);
+ const store2 = createHeartbeatConfigStore(storage);
+ expect((await store2.get("ws-1")).inactiveOnly).toBe(false);
+ });
+
+ it("defaults inactiveOnly to true for legacy configs persisted before the field existed", async () => {
+ const storage = createMemoryStorage();
+ // Simulate a config written by an older build that had no inactiveOnly
+ // field (a pre-inactive-only heartbeat config).
+ await storage.set("config:ws-1", JSON.stringify({ enabled: true, intervalMinutes: 5 }));
+ const store = createHeartbeatConfigStore(storage);
+ const config = await store.get("ws-1");
+ expect(config.enabled).toBe(true);
+ expect(config.intervalMinutes).toBe(5);
+ // The missing field defaults ON (feature on by default) — never `undefined`.
+ expect(config.inactiveOnly).toBe(true);
+ });
+
+ it("lists persisted workspace ids", async () => {
+ const store = createHeartbeatConfigStore(createMemoryStorage());
+ await store.update("ws-b", { enabled: true });
+ await store.update("ws-a", { enabled: false });
+ expect(await store.listWorkspaceIds()).toEqual(["ws-a", "ws-b"]);
+ });
+});
diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts
new file mode 100644
index 0000000..52aca3e
--- /dev/null
+++ b/packages/heartbeat/src/config-store.ts
@@ -0,0 +1,106 @@
+import type { StorageNamespace } from "@dispatch/kernel";
+import type { HeartbeatConfig, UpdateHeartbeatRequest } from "@dispatch/transport-contract";
+
+/**
+ * The default config returned for a workspace that has never configured a
+ * heartbeat. A heartbeat is OFF until explicitly enabled.
+ */
+export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = {
+ enabled: false,
+ inactiveOnly: true,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: 30,
+ model: "",
+ reasoningEffort: null,
+};
+
+/** Minimum scheduling interval, in minutes (a heartbeat can't fire faster). */
+export const MIN_INTERVAL_MINUTES = 1;
+
+/** Storage key for a workspace's heartbeat config. */
+function configKey(workspaceId: string): string {
+ return `config:${workspaceId}`;
+}
+
+/**
+ * Pure: apply a partial update to a config, returning the new config. Clamps
+ * `intervalMinutes` to a minimum of 1 (a positive integer). Fields not present
+ * in `update` are left unchanged. `reasoningEffort: null` clears the override
+ * (back to inheriting the workspace default); `undefined` (absent) leaves it.
+ *
+ * Validation of `reasoningEffort` (rejecting unrecognized strings) is the
+ * transport layer's job (→ HTTP 400); this function trusts a caller that has
+ * already validated, but only ever produces a valid `HeartbeatConfig`.
+ */
+export function applyConfigUpdate(
+ current: HeartbeatConfig,
+ update: UpdateHeartbeatRequest,
+): HeartbeatConfig {
+ const next: HeartbeatConfig = {
+ enabled: update.enabled !== undefined ? update.enabled : current.enabled,
+ inactiveOnly: update.inactiveOnly !== undefined ? update.inactiveOnly : current.inactiveOnly,
+ systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt,
+ taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt,
+ intervalMinutes:
+ update.intervalMinutes !== undefined
+ ? Math.max(MIN_INTERVAL_MINUTES, Math.trunc(update.intervalMinutes))
+ : current.intervalMinutes,
+ model: update.model !== undefined ? update.model : current.model,
+ reasoningEffort:
+ update.reasoningEffort !== undefined ? update.reasoningEffort : current.reasoningEffort,
+ };
+ return next;
+}
+
+export interface HeartbeatConfigStore {
+ /** The config for a workspace (the default when never set). */
+ readonly get: (workspaceId: string) => Promise<HeartbeatConfig>;
+ /** Apply a partial update and persist it; returns the new config. */
+ readonly update: (
+ workspaceId: string,
+ update: UpdateHeartbeatRequest,
+ ) => Promise<HeartbeatConfig>;
+ /** Every workspace id that has a persisted (non-default) config. */
+ readonly listWorkspaceIds: () => Promise<readonly string[]>;
+}
+
+export function createHeartbeatConfigStore(storage: StorageNamespace): HeartbeatConfigStore {
+ return {
+ async get(workspaceId: string): Promise<HeartbeatConfig> {
+ const raw = await storage.get(configKey(workspaceId));
+ if (raw === null) return DEFAULT_HEARTBEAT_CONFIG;
+ try {
+ const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>;
+ return {
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false,
+ inactiveOnly: typeof parsed.inactiveOnly === "boolean" ? parsed.inactiveOnly : true,
+ systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "",
+ taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "",
+ intervalMinutes:
+ typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0
+ ? Math.trunc(parsed.intervalMinutes)
+ : DEFAULT_HEARTBEAT_CONFIG.intervalMinutes,
+ model: typeof parsed.model === "string" ? parsed.model : "",
+ reasoningEffort: parsed.reasoningEffort ?? null,
+ };
+ } catch {
+ return DEFAULT_HEARTBEAT_CONFIG;
+ }
+ },
+
+ async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> {
+ const current = await this.get(workspaceId);
+ const next = applyConfigUpdate(current, update);
+ await storage.set(configKey(workspaceId), JSON.stringify(next));
+ return next;
+ },
+
+ async listWorkspaceIds(): Promise<readonly string[]> {
+ const keys = await storage.keys("config:");
+ const ids = keys.map((k) => k.slice("config:".length));
+ // De-duplicate + sort for a stable boot-scan order.
+ return [...new Set(ids)].sort();
+ },
+ };
+}
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts
new file mode 100644
index 0000000..b044619
--- /dev/null
+++ b/packages/heartbeat/src/extension.ts
@@ -0,0 +1,121 @@
+/**
+ * Heartbeat extension — manifest + activate(host).
+ *
+ * Wires the heartbeat service against the session-orchestrator + a storage
+ * namespace, registers the typed service handle, and arms every enabled
+ * workspace's scheduler on boot. Prompt templates (`systemPrompt` /
+ * `taskPrompt`) are resolved against the SAME variable catalog the global
+ * system-prompt template uses (via the system-prompt service's `resolveText`),
+ * so `[type:name]` placeholders reach the model substituted — not raw. An empty
+ * heartbeat `systemPrompt` inherits the global system prompt template (via
+ * `getTemplate`) before variable resolution runs — empty = inherit, not "no
+ * system prompt".
+ */
+
+import type { ConversationStore } from "@dispatch/conversation-store";
+import { conversationStoreHandle } from "@dispatch/conversation-store";
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import {
+ type SessionOrchestrator,
+ sessionOrchestratorHandle,
+} from "@dispatch/session-orchestrator";
+import type { SystemPromptService } from "@dispatch/system-prompt";
+import { systemPromptHandle } from "@dispatch/system-prompt";
+import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js";
+
+export const manifest: Manifest = {
+ id: "heartbeat",
+ name: "Heartbeat",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ // system-prompt provides `resolveText` (the variable resolver used to
+ // substitute [type:name] placeholders in heartbeat prompts); conversation-
+ // store resolves the workspace's default cwd (the resolver runs git / reads
+ // files against it, mirroring the global template). Both lookups are lazy
+ // (at fire time, not activation), but declaring them keeps the DAG honest.
+ dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"],
+ activation: "eager",
+ contributes: { services: ["heartbeat"] },
+};
+
+// Module-scoped store for deactivate (the extension object is created once).
+const store: { service: { stopAll: () => void } | null } = { service: null };
+
+export const extension: Extension = {
+ manifest,
+ async activate(host: HostAPI) {
+ const orchestrator = host.getService<SessionOrchestrator>(sessionOrchestratorHandle);
+ const storage = host.storage("heartbeat");
+ const logger = host.logger;
+
+ // Resolve [type:name] placeholders in heartbeat prompts via the
+ // system-prompt service (same resolver + variables as the global
+ // template). The cwd is the workspace's defaultCwd (resolved the same
+ // way the orchestrator resolves a new conversation's effective cwd);
+ // falling back to process.cwd() when the workspace has none. Both
+ // services are declared `dependsOn` (always activated before heartbeat).
+ const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle);
+ const conversationStore = host.getService<ConversationStore>(conversationStoreHandle);
+
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ const workspace = await conversationStore.getWorkspace(ctx.workspaceId);
+ const cwd = workspace?.defaultCwd ?? process.cwd();
+ return systemPromptService.resolveText(template, cwd, {
+ ...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}),
+ ...(ctx.model !== "" ? { model: ctx.model } : {}),
+ workspaceId: ctx.workspaceId,
+ });
+ };
+
+ const service = createHeartbeatService({
+ storage,
+ orchestrator,
+ logger,
+ resolvePrompt,
+ // CR-HB-2: an empty heartbeat systemPrompt inherits the global
+ // system prompt template (GET /system-prompt / regular
+ // conversations resolve) before variable resolution runs.
+ getGlobalSystemPrompt: () => systemPromptService.getTemplate(),
+ // Pin the heartbeat turn's cwd to the CONFIGURED workspace's
+ // defaultCwd (not the heartbeat workspace's empty defaultCwd) so
+ // the turn's tools run where the prompt's [prompt:cwd] advertises.
+ // Lazy (resolved at fire time, mirroring resolvePrompt).
+ getWorkspaceCwd: async (wsId) =>
+ (await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null,
+ // inactiveOnly: the configured workspace is "busy" while any of its
+ // conversations are driving or queued for a turn. The orchestrator
+ // sets persisted status "active" on turn start and "idle" on settle,
+ // so a single store read (filtered to the configured workspaceId) is
+ // the live active-agent check. The heartbeat's own spawned conversation
+ // lives in the DEDICATED heartbeat workspace, so it never self-blocks.
+ hasActiveAgents: async (wsId) => {
+ const active = await conversationStore.listConversations({
+ workspaceId: wsId,
+ status: ["active", "queued"],
+ });
+ return active.length > 0;
+ },
+ });
+
+ // Reconcile stale runs + arm enabled workspaces on boot.
+ await service.startAll();
+ store.service = service;
+
+ host.provideService(heartbeatServiceHandle, service);
+
+ host.logger.info("heartbeat extension activated");
+ },
+ deactivate() {
+ // Stop schedulers so no new fires happen during shutdown.
+ store.service?.stopAll();
+ store.service = null;
+ },
+};
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());
+ });
+});
diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts
new file mode 100644
index 0000000..e04c538
--- /dev/null
+++ b/packages/heartbeat/src/heartbeat.ts
@@ -0,0 +1,354 @@
+import type { Logger, ServiceHandle } from "@dispatch/kernel";
+import { defineService } from "@dispatch/kernel";
+import type { SessionOrchestrator } from "@dispatch/session-orchestrator";
+import type {
+ HeartbeatConfig,
+ HeartbeatRun,
+ StopHeartbeatRunResponse,
+ UpdateHeartbeatRequest,
+} from "@dispatch/transport-contract";
+import { createHeartbeatConfigStore, type HeartbeatConfigStore } from "./config-store.js";
+import { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js";
+import { HeartbeatScheduler, realTimers, type Timers } from "./scheduler.js";
+
+/**
+ * The dedicated workspace heartbeat-spawned conversations are filed in (NOT the
+ * configured workspace). The config + run history stay per-workspace (tracked
+ * under the configured workspaceId); only the spawned conversation's PLACEMENT
+ * moves here, so heartbeat conversations don't clog the configured workspace's
+ * tabs. The orchestrator auto-creates this workspace on first fire (via
+ * `ensureWorkspace`), so it appears on the workspaces home page like any other.
+ */
+export const HEARTBEAT_WORKSPACE_ID = "heartbeat";
+
+/**
+ * The heartbeat service surface — what transport-http consumes and what the
+ * extension wires into the host.
+ */
+export interface HeartbeatService {
+ /** The per-workspace heartbeat config (defaults when never set). */
+ readonly getConfig: (workspaceId: string) => Promise<HeartbeatConfig>;
+ /**
+ * Apply a partial config update. Side effect: arms/disarms the scheduler
+ * for this workspace (enabled → schedule; disabled → stop). Returns the new
+ * config.
+ */
+ readonly updateConfig: (
+ workspaceId: string,
+ update: UpdateHeartbeatRequest,
+ ) => Promise<HeartbeatConfig>;
+ /** Heartbeat runs for a workspace, most-recent first. */
+ readonly listRuns: (workspaceId: string) => Promise<readonly HeartbeatRun[]>;
+ /**
+ * The server-authoritative next-fire time for a workspace's heartbeat, as
+ * an ISO 8601 string — the moment the scheduler will fire the next run
+ * (the last run's completion + `intervalMinutes`, or the moment `enabled`
+ * was toggled on + `intervalMinutes` for the first run). `null` when the
+ * heartbeat is disabled/disarmed, or when a run is in flight and the next
+ * hasn't been queued yet (no countdown to show). A cheap read of the
+ * scheduler's pending fire time.
+ */
+ readonly nextRunAt: (workspaceId: string) => Promise<string | null>;
+ /**
+ * Stop an in-flight run (abort its turn). Idempotent for an already-finished
+ * run. Throws when the run id is unknown (→ HTTP 404).
+ */
+ readonly stopRun: (workspaceId: string, runId: string) => Promise<StopHeartbeatRunResponse>;
+ /** Boot: sweep stale runs + arm every enabled workspace's scheduler. */
+ readonly startAll: () => Promise<void>;
+ /** Shutdown: stop every scheduler. */
+ readonly stopAll: () => void;
+}
+
+/** Typed service handle the heartbeat extension provides and transport consumes. */
+export const heartbeatServiceHandle: ServiceHandle<HeartbeatService> =
+ defineService<HeartbeatService>("heartbeat");
+
+export interface HeartbeatServiceDeps {
+ /** Namespaced storage (from `host.storage("heartbeat")`). */
+ readonly storage: import("@dispatch/kernel").StorageNamespace;
+ /** The session orchestrator (drives heartbeat turns). */
+ readonly orchestrator: SessionOrchestrator;
+ readonly logger?: Logger;
+ /** Injectable timers (default: real). */
+ readonly timers?: Timers;
+ /** Injectable id generator (default: crypto.randomUUID). */
+ readonly generateId?: () => string;
+ /**
+ * Resolve `[type:name]` variable placeholders in a prompt template against
+ * the current environment — the SAME resolver + variable catalog the global
+ * system-prompt template uses (system/file/prompt/git groups). Applied once
+ * per run, when the turn is constructed (mirrors the global template's
+ * construct-once-per-conversation resolution). When omitted, templates pass
+ * through UNRESOLVED (raw) — the extension wires the real resolver; tests
+ * inject a fake.
+ */
+ readonly resolvePrompt?: (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ) => Promise<string>;
+ /**
+ * Resolve an empty heartbeat `systemPrompt` to the GLOBAL system prompt
+ * template — the same one `GET /system-prompt` returns / that regular
+ * conversations resolve. Applied ONLY when the heartbeat's persisted
+ * `systemPrompt` is `""` (inherit), and BEFORE variable resolution
+ * (`resolvePrompt` / CR-HB-1) runs on the result, so both apply in order:
+ * empty ⇒ global template, then `[type:name]` placeholders resolved. A
+ * non-empty `systemPrompt` is an explicit override and bypasses this.
+ * When omitted, empty stays empty (no system prompt) — the extension
+ * wires the real getter; tests inject a fake.
+ */
+ readonly getGlobalSystemPrompt?: () => Promise<string>;
+ /**
+ * The configured workspace's `defaultCwd` (or `null` when the workspace has
+ * none). Used to pin the heartbeat turn's cwd to the CONFIGURED workspace's
+ * directory — NOT the heartbeat workspace's (empty) defaultCwd — so the
+ * turn's tools run in the same directory the prompt's `[prompt:cwd]`
+ * variable advertises. Passed to the orchestrator as an explicit `cwd`
+ * override only when non-null; when `null` no override is sent and the
+ * orchestrator falls back to the server default cwd (matching the
+ * pre-heartbeat-workspace behavior for a workspace without a defaultCwd).
+ * When omitted, `null` (no override) — the extension wires the real getter
+ * (against `conversationStore.getWorkspace`); tests inject a fake.
+ */
+ readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ /**
+ * Whether the configured workspace currently has any ACTIVE agents —
+ * conversations driving (or queued for) a turn. When `config.inactiveOnly`
+ * is `true`, the heartbeat SKIPS a fire while this returns `true` (the
+ * workspace is busy). The extension wires the real check against
+ * `conversationStore.listConversations({ workspaceId, status: ["active",
+ * "queued"] })` (the configured workspace's persisted statuses — the
+ * orchestrator sets `"active"` on turn start, `"idle"` on settle); tests
+ * inject a fake. When omitted, `false` (no active agents → never skip) so
+ * the inactive-only feature degrades off cleanly — the heartbeat fires
+ * unconditionally, matching the pre-inactive-only behavior.
+ */
+ readonly hasActiveAgents?: (workspaceId: string) => Promise<boolean>;
+}
+
+interface ActiveRun {
+ readonly conversationId: string;
+ readonly workspaceId: string;
+ stopped: boolean;
+}
+
+export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatService {
+ const logger = deps.logger;
+ const timers = deps.timers ?? realTimers;
+ const generateId = deps.generateId ?? (() => crypto.randomUUID());
+ const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage);
+ const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage);
+ const orchestrator = deps.orchestrator;
+ // Default: pass templates through UNRESOLVED (raw). The extension wires the
+ // real resolver so [type:name] placeholders are substituted like the global
+ // system-prompt template; tests inject a fake.
+ const resolvePrompt = deps.resolvePrompt ?? ((template: string) => Promise.resolve(template));
+ // Default: an empty systemPrompt stays empty (no system prompt). The
+ // extension wires the real getter so empty INHERITS the global system
+ // prompt template (GET /system-prompt); tests inject a fake.
+ const getGlobalSystemPrompt = deps.getGlobalSystemPrompt ?? (() => Promise.resolve(""));
+ // Default: no cwd override (the orchestrator resolves the turn cwd from the
+ // conversation's workspace). The extension wires the real getter so the
+ // turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake.
+ const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null));
+ // Default: no active agents (never skip) — the inactive-only feature degrades
+ // off cleanly. The extension wires the real check (against the conversation
+ // store's persisted statuses); tests inject a fake.
+ const hasActiveAgents = deps.hasActiveAgents ?? (() => Promise.resolve(false));
+
+ // runId → active-run tracking (in-memory; the durable record lives in the
+ // run store). Used to (a) map a stop request to its conversation, and
+ // (b) keep a "stopped" flag so the turn's completion doesn't clobber a
+ // user-initiated stop.
+ const activeRuns = new Map<string, ActiveRun>();
+
+ const scheduler = new HeartbeatScheduler({
+ timers,
+ fire: (workspaceId) => fire(workspaceId),
+ });
+
+ async function fire(workspaceId: string): Promise<void> {
+ const config = await configStore.get(workspaceId);
+ // Race: disabled/disarmed between the timer firing and now.
+ if (!config.enabled) return;
+
+ // inactiveOnly: skip this fire when the configured workspace has active
+ // agents (a conversation driving or queued for a turn). The fire is
+ // silently skipped — no run is recorded — and the scheduler re-arms to
+ // try again at the next interval. The spawned heartbeat conversation lives
+ // in the DEDICATED heartbeat workspace, so it never self-blocks (a prior
+ // in-flight heartbeat run is NOT an active agent of the configured
+ // workspace). Disabled (inactiveOnly === false) fires unconditionally.
+ if (config.inactiveOnly && (await hasActiveAgents(workspaceId))) {
+ logger?.info("heartbeat: fire skipped — workspace has active agents", { workspaceId });
+ return;
+ }
+
+ const conversationId = generateId();
+ const runId = generateId();
+ const triggeredAt = new Date(timers.now()).toISOString();
+ const run: HeartbeatRun = {
+ id: runId,
+ conversationId,
+ triggeredAt,
+ status: "running",
+ };
+ await runStore.create(workspaceId, run);
+ activeRuns.set(runId, { conversationId, workspaceId, stopped: false });
+
+ logger?.info("heartbeat: run started", { workspaceId, runId, conversationId });
+
+ // Resolve the heartbeat's prompts ONCE, when the turn is constructed.
+ //
+ // CR-HB-2: an empty `systemPrompt` INHERITS the global system prompt
+ // template (the same one `GET /system-prompt` returns / that regular
+ // conversations resolve) — empty is an override-means-inherit flag, not
+ // "no system prompt". A non-empty `systemPrompt` is an explicit override.
+ // This step runs FIRST.
+ //
+ // CR-HB-1: then `[type:name]` variable placeholders in whichever prompt is
+ // in effect are resolved via the same resolver + variable catalog the
+ // global system-prompt template uses. The orchestrator sends an explicit
+ // systemPrompt override AS-IS (bypassing its own templated prompt), so
+ // resolution must happen HERE, before handleMessage. For prompts using
+ // stable variables (os/cwd/git) this yields a stable, cache-warm prompt
+ // across runs; time-bearing variables refresh per run (mirroring the
+ // global template's per-conversation resolution).
+ const baseSystemPrompt =
+ config.systemPrompt === "" ? await getGlobalSystemPrompt() : config.systemPrompt;
+ const resolveCtx = { workspaceId, conversationId, model: config.model };
+ // resolveCtx uses the CONFIGURED workspaceId — the heartbeat operates
+ // ON BEHALF OF the configured workspace, so `[prompt:workspace_id]` and
+ // `[prompt:cwd]` refer to it (not the heartbeat workspace the spawned
+ // conversation is filed in). The turn's cwd is pinned to the SAME
+ // configured workspace's defaultCwd (below) so tools run where the
+ // prompt's `[prompt:cwd]` advertises.
+ const [systemPrompt, taskPrompt, configuredWorkspaceCwd] = await Promise.all([
+ resolvePrompt(baseSystemPrompt, resolveCtx),
+ resolvePrompt(config.taskPrompt, resolveCtx),
+ getWorkspaceCwd(workspaceId),
+ ]);
+
+ try {
+ await orchestrator.handleMessage({
+ conversationId,
+ text: taskPrompt,
+ // Fire-and-forget: the heartbeat loop does not consume the
+ // streamed events (it only awaits turn completion to mark the
+ // run done). A no-op onEvent satisfies the required callback.
+ onEvent: () => {},
+ // Always passed explicitly — bypasses the orchestrator's templated
+ // workspace prompt. Resolved above: an empty config systemPrompt
+ // inherited the global template (CR-HB-2), then [type:name]
+ // placeholders were substituted (CR-HB-1). Still "" when the global
+ // template itself is empty (no system prompt).
+ systemPrompt,
+ ...(config.model !== "" ? { modelName: config.model } : {}),
+ ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}),
+ // Pin the turn cwd to the CONFIGURED workspace's defaultCwd so
+ // the heartbeat's tools run in the same directory its prompt
+ // variables advertise — NOT the heartbeat workspace's (empty)
+ // defaultCwd → process.cwd(). Omitted when the configured
+ // workspace has no defaultCwd (the orchestrator then falls back
+ // to the server default cwd, matching the pre-heartbeat-
+ // workspace behavior for a workspace without one).
+ ...(configuredWorkspaceCwd !== null ? { cwd: configuredWorkspaceCwd } : {}),
+ // File the spawned conversation in the DEDICATED heartbeat
+ // workspace (not the configured workspace) so heartbeat
+ // conversations don't clog the configured workspace's tabs. The
+ // orchestrator auto-creates this workspace on first fire
+ // (ensureWorkspace), so it appears on the workspaces home page.
+ workspaceId: HEARTBEAT_WORKSPACE_ID,
+ });
+ } finally {
+ const entry = activeRuns.get(runId);
+ activeRuns.delete(runId);
+ // If the user stopped it, stopRun already set "stopped"; don't
+ // clobber. Otherwise the turn sealed (normally or via abort) → done.
+ if (entry !== undefined && !entry.stopped) {
+ await runStore.setStatus(workspaceId, runId, "completed");
+ logger?.info("heartbeat: run completed", { workspaceId, runId });
+ }
+ }
+ }
+
+ return {
+ async getConfig(workspaceId) {
+ return configStore.get(workspaceId);
+ },
+
+ async updateConfig(workspaceId, update) {
+ const next = await configStore.update(workspaceId, update);
+ // Arm/disarm from the new config. An in-progress run is left alone;
+ // the new interval takes effect on the next re-arm.
+ scheduler.arm(workspaceId, next);
+ logger?.info("heartbeat: config updated", {
+ workspaceId,
+ enabled: next.enabled,
+ intervalMinutes: next.intervalMinutes,
+ });
+ return next;
+ },
+
+ async listRuns(workspaceId) {
+ return runStore.list(workspaceId);
+ },
+
+ async nextRunAt(workspaceId) {
+ const ms = scheduler.nextFireAt(workspaceId);
+ return ms === null ? null : new Date(ms).toISOString();
+ },
+
+ async stopRun(workspaceId, runId) {
+ const run = await runStore.get(workspaceId, runId);
+ if (run === null) {
+ throw new Error("Heartbeat run not found");
+ }
+ // Idempotent: an already-finished run is a no-op.
+ if (run.status !== "running") {
+ return { ok: true };
+ }
+ const entry = activeRuns.get(runId);
+ const conversationId = entry?.conversationId ?? run.conversationId;
+ if (entry !== undefined) {
+ entry.stopped = true;
+ }
+ await runStore.setStatus(workspaceId, runId, "stopped");
+ orchestrator.stopTurn(conversationId);
+ logger?.info("heartbeat: run stopped", { workspaceId, runId, conversationId });
+ return { ok: true };
+ },
+
+ async startAll() {
+ // Sweep stale "running" runs (orphaned by a prior crash/restart) →
+ // "stopped". Never leave the system showing an in-flight run that
+ // can never finish.
+ const workspaceIds = await configStore.listWorkspaceIds();
+ for (const workspaceId of workspaceIds) {
+ const runs = await runStore.list(workspaceId);
+ for (const run of runs) {
+ if (run.status === "running") {
+ await runStore.setStatus(workspaceId, run.id, "stopped");
+ }
+ }
+ const config = await configStore.get(workspaceId);
+ if (config.enabled) {
+ scheduler.arm(workspaceId, config);
+ logger?.info("heartbeat: scheduler armed on boot", {
+ workspaceId,
+ intervalMinutes: config.intervalMinutes,
+ });
+ }
+ }
+ },
+
+ stopAll() {
+ scheduler.disarmAll();
+ },
+ };
+}
diff --git a/packages/heartbeat/src/index.ts b/packages/heartbeat/src/index.ts
new file mode 100644
index 0000000..d25e338
--- /dev/null
+++ b/packages/heartbeat/src/index.ts
@@ -0,0 +1,17 @@
+export {
+ applyConfigUpdate,
+ createHeartbeatConfigStore,
+ DEFAULT_HEARTBEAT_CONFIG,
+ type HeartbeatConfigStore,
+ MIN_INTERVAL_MINUTES,
+} from "./config-store.js";
+export { extension, manifest } from "./extension.js";
+export {
+ createHeartbeatService,
+ HEARTBEAT_WORKSPACE_ID,
+ type HeartbeatService,
+ type HeartbeatServiceDeps,
+ heartbeatServiceHandle,
+} from "./heartbeat.js";
+export { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js";
+export { HeartbeatScheduler, realTimers, type TimerHandle, type Timers } from "./scheduler.js";
diff --git a/packages/heartbeat/src/run-store.test.ts b/packages/heartbeat/src/run-store.test.ts
new file mode 100644
index 0000000..8be21f5
--- /dev/null
+++ b/packages/heartbeat/src/run-store.test.ts
@@ -0,0 +1,72 @@
+import type { StorageNamespace } from "@dispatch/kernel";
+import type { HeartbeatRun } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { createHeartbeatRunStore } from "./run-store.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));
+ },
+ };
+}
+
+function run(id: string, conversationId: string, triggeredAt: string): HeartbeatRun {
+ return { id, conversationId, triggeredAt, status: "running" };
+}
+
+describe("createHeartbeatRunStore", () => {
+ it("creates and reads back a run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ const created = await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ expect(created.status).toBe("running");
+ const read = await store.get("ws-1", "r1");
+ expect(read).toEqual(created);
+ });
+
+ it("returns null for an unknown run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ expect(await store.get("ws-1", "nope")).toBeNull();
+ });
+
+ it("updates the status of a run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ const updated = await store.setStatus("ws-1", "r1", "completed");
+ expect(updated?.status).toBe("completed");
+ expect((await store.get("ws-1", "r1"))?.status).toBe("completed");
+ });
+
+ it("setStatus is a no-op (returns null) for an unknown run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ expect(await store.setStatus("ws-1", "ghost", "stopped")).toBeNull();
+ });
+
+ it("lists runs most-recent first by triggeredAt", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ await store.create("ws-1", run("r2", "c2", "2026-02-01T00:00:00.000Z"));
+ await store.create("ws-1", run("r3", "c3", "2026-01-15T00:00:00.000Z"));
+ const runs = await store.list("ws-1");
+ expect(runs.map((r) => r.id)).toEqual(["r2", "r3", "r1"]);
+ });
+
+ it("scopes runs per workspace", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ await store.create("ws-2", run("r2", "c2", "2026-01-01T00:00:00.000Z"));
+ expect((await store.list("ws-1")).map((r) => r.id)).toEqual(["r1"]);
+ expect((await store.list("ws-2")).map((r) => r.id)).toEqual(["r2"]);
+ });
+});
diff --git a/packages/heartbeat/src/run-store.ts b/packages/heartbeat/src/run-store.ts
new file mode 100644
index 0000000..9650181
--- /dev/null
+++ b/packages/heartbeat/src/run-store.ts
@@ -0,0 +1,95 @@
+import type { StorageNamespace } from "@dispatch/kernel";
+import type { HeartbeatRun, HeartbeatRunStatus } from "@dispatch/transport-contract";
+
+/** Storage key for a single heartbeat run record. */
+function runKey(workspaceId: string, runId: string): string {
+ return `run:${workspaceId}:${runId}`;
+}
+
+/** Prefix matching every run record for a workspace (for enumeration). */
+function runPrefix(workspaceId: string): string {
+ return `run:${workspaceId}:`;
+}
+
+/** Extract the runId from a full `run:<workspaceId>:<runId>` key. */
+function parseRunId(key: string, workspaceId: string): string {
+ const prefix = runPrefix(workspaceId);
+ return key.startsWith(prefix) ? key.slice(prefix.length) : key;
+}
+
+export interface HeartbeatRunStore {
+ /** Create a new run record (status `"running"`) and persist it. */
+ readonly create: (workspaceId: string, run: HeartbeatRun) => Promise<HeartbeatRun>;
+ /** Update the status of an existing run. No-op if the run is unknown. */
+ readonly setStatus: (
+ workspaceId: string,
+ runId: string,
+ status: HeartbeatRunStatus,
+ ) => Promise<HeartbeatRun | null>;
+ /** A single run by id, or `null` when unknown. */
+ readonly get: (workspaceId: string, runId: string) => Promise<HeartbeatRun | null>;
+ /** All runs for a workspace, most-recent first (by `triggeredAt`). */
+ readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>;
+}
+
+export function createHeartbeatRunStore(storage: StorageNamespace): HeartbeatRunStore {
+ async function readRun(workspaceId: string, runId: string): Promise<HeartbeatRun | null> {
+ const raw = await storage.get(runKey(workspaceId, runId));
+ if (raw === null) return null;
+ try {
+ const parsed = JSON.parse(raw) as Partial<HeartbeatRun>;
+ if (
+ typeof parsed.id !== "string" ||
+ typeof parsed.conversationId !== "string" ||
+ typeof parsed.triggeredAt !== "string" ||
+ typeof parsed.status !== "string"
+ ) {
+ return null;
+ }
+ return {
+ id: parsed.id,
+ conversationId: parsed.conversationId,
+ triggeredAt: parsed.triggeredAt,
+ status: parsed.status as HeartbeatRunStatus,
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ return {
+ async create(workspaceId: string, run: HeartbeatRun): Promise<HeartbeatRun> {
+ await storage.set(runKey(workspaceId, run.id), JSON.stringify(run));
+ return run;
+ },
+
+ async setStatus(
+ workspaceId: string,
+ runId: string,
+ status: HeartbeatRunStatus,
+ ): Promise<HeartbeatRun | null> {
+ const run = await readRun(workspaceId, runId);
+ if (run === null) return null;
+ const updated: HeartbeatRun = { ...run, status };
+ await storage.set(runKey(workspaceId, runId), JSON.stringify(updated));
+ return updated;
+ },
+
+ async get(workspaceId: string, runId: string): Promise<HeartbeatRun | null> {
+ return readRun(workspaceId, runId);
+ },
+
+ async list(workspaceId: string): Promise<readonly HeartbeatRun[]> {
+ const keys = await storage.keys(runPrefix(workspaceId));
+ const runs: HeartbeatRun[] = [];
+ for (const key of keys) {
+ const runId = parseRunId(key, workspaceId);
+ const run = await readRun(workspaceId, runId);
+ if (run !== null) runs.push(run);
+ }
+ // Most-recent first by triggeredAt (ISO-8601 sorts lexicographically).
+ runs.sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt));
+ return runs;
+ },
+ };
+}
diff --git a/packages/heartbeat/src/scheduler.test.ts b/packages/heartbeat/src/scheduler.test.ts
new file mode 100644
index 0000000..82e5c4d
--- /dev/null
+++ b/packages/heartbeat/src/scheduler.test.ts
@@ -0,0 +1,343 @@
+import { describe, expect, it } from "vitest";
+import { HeartbeatScheduler, type Timers } from "./scheduler.js";
+
+interface FakeTimer {
+ readonly fn: () => void;
+ readonly firesAt: number;
+}
+
+/**
+ * A controllable fake clock: `advance(ms)` moves virtual time forward and runs
+ * any timers that became due. The injected `fire` returns a deferred the test
+ * resolves manually, so we can assert the "running" state and the re-arm that
+ * happens only AFTER the run completes.
+ */
+function createFakeTimers() {
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map<number, FakeTimer>();
+ const timersApi: Timers = {
+ now: () => now,
+ setTimeout: (fn, ms) => {
+ const id = nextId++;
+ timers.set(id, { fn, firesAt: now + ms });
+ return id as unknown as ReturnType<typeof setTimeout>;
+ },
+ clearTimeout: (handle) => {
+ if (handle !== undefined) timers.delete(handle as unknown as number);
+ },
+ };
+ return {
+ timers: timersApi,
+ advance(ms: number): number {
+ now += ms;
+ let fired = 0;
+ 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();
+ fired++;
+ }
+ return fired;
+ },
+ pendingCount(): number {
+ return timers.size;
+ },
+ };
+}
+
+/** A deferred promise the test resolves to signal run completion. */
+function createDeferred(): { promise: Promise<void>; resolve: () => void } {
+ let resolve!: () => void;
+ const promise = new Promise<void>((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+// Flush pending microtasks (the scheduler's .finally re-arm runs as a microtask
+// after the fire promise resolves).
+const flush = async (): Promise<void> => {
+ await new Promise((r) => queueMicrotask(r));
+};
+
+describe("HeartbeatScheduler", () => {
+ it("arms a timer that fires after intervalMinutes", () => {
+ const fake = createFakeTimers();
+ const fires: string[] = [];
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: (ws) => {
+ fires.push(ws);
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.isArmed("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(1);
+
+ // Just shy of the interval → no fire.
+ expect(fake.advance(59_999)).toBe(0);
+ expect(fires).toEqual([]);
+
+ // Exactly the interval (1 minute = 60_000ms) → fires.
+ expect(fake.advance(1)).toBe(1);
+ expect(fires).toEqual(["ws-1"]);
+ // While the run is in progress: running, no pending timer.
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(0);
+ });
+
+ it("re-arms only after the run completes (reset timer after each run)", async () => {
+ const fake = createFakeTimers();
+ const fires: string[] = [];
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: (ws) => {
+ fires.push(ws);
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // first fire
+ expect(fires).toEqual(["ws-1"]);
+ // Run in progress — no new timer yet.
+ expect(fake.pendingCount()).toBe(0);
+
+ // Completing the run schedules the next fire.
+ deferreds[0]?.resolve();
+ await flush();
+ expect(scheduler.isRunning("ws-1")).toBe(false);
+ expect(fake.pendingCount()).toBe(1);
+
+ // Next fire after another interval.
+ fake.advance(60_000);
+ expect(fires).toEqual(["ws-1", "ws-1"]);
+ });
+
+ it("uses a longer interval for a higher intervalMinutes", () => {
+ const fake = createFakeTimers();
+ let fireCount = 0;
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ fireCount++;
+ return createDeferred().promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
+ // 1 minute wouldn't fire a 30-minute schedule.
+ expect(fake.advance(60_000)).toBe(0);
+ // 28 more minutes (29 total) still wouldn't fire.
+ expect(fake.advance(28 * 60_000)).toBe(0);
+ // The remaining minute completes the 30 minutes → fires.
+ expect(fake.advance(60_000)).toBe(1);
+ expect(fireCount).toBe(1);
+ });
+
+ it("disarms (clears the pending timer) when disabled", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(fake.pendingCount()).toBe(1);
+ scheduler.arm("ws-1", { enabled: false, intervalMinutes: 1 });
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ expect(fake.pendingCount()).toBe(0);
+ });
+
+ it("disarmAll stops every schedule", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ scheduler.arm("ws-2", { enabled: true, intervalMinutes: 1 });
+ expect(fake.pendingCount()).toBe(2);
+ scheduler.disarmAll();
+ expect(fake.pendingCount()).toBe(0);
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ expect(scheduler.isArmed("ws-2")).toBe(false);
+ });
+
+ it("does not re-arm after a disarm during a run", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // fire in progress
+ scheduler.disarm("ws-1"); // disarm mid-run
+ deferreds[0]?.resolve();
+ await flush();
+ // Disarmed → no re-arm scheduled.
+ expect(fake.pendingCount()).toBe(0);
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ });
+
+ it("a config update during a run applies the new interval on the next re-arm", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // first fire (interval=1m)
+ // Update interval to 5m while the run is in progress.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(0); // no new timer while running
+ deferreds[0]?.resolve();
+ await flush();
+ // Re-armed with the NEW 5-minute interval.
+ expect(fake.advance(60_000)).toBe(0); // 1 minute isn't enough now
+ expect(fake.advance(4 * 60_000)).toBe(1); // completes 5 minutes → fires
+ });
+
+ it("a thrown fire is swallowed and the loop continues", async () => {
+ const fake = createFakeTimers();
+ let fireCount = 0;
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ fireCount++;
+ return Promise.reject(new Error("boom"));
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000);
+ await flush();
+ expect(fireCount).toBe(1);
+ // Loop continues: next fire scheduled.
+ expect(fake.pendingCount()).toBe(1);
+ fake.advance(60_000);
+ expect(fireCount).toBe(2);
+ });
+
+ // ─── nextFireAt (CR-HB-3: server-authoritative next-run time) ──────────────
+
+ it("nextFireAt returns null for a workspace with no schedule", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ expect(scheduler.nextFireAt("unknown")).toBeNull();
+ });
+
+ it("nextFireAt returns the absolute fire time when armed (now + intervalMs)", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ // now=0, interval=1m → next fire at epoch-ms 60_000.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ });
+
+ it("nextFireAt reflects a longer interval", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(30 * 60_000);
+ });
+
+ it("nextFireAt reflects a new interval on re-arm (not running)", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ // Re-arm with a 5-minute interval (armed, not running) → recomputed.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(5 * 60_000);
+ });
+
+ it("nextFireAt returns null when disarmed", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ scheduler.disarm("ws-1");
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+ });
+
+ it("nextFireAt returns null while a run is in progress, then the next fire after it completes", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+
+ fake.advance(60_000); // fire → run in progress
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ // In flight → no next run queued yet.
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+
+ deferreds[0]?.resolve();
+ await flush();
+ // Re-armed at completion-time (60_000) + interval (60_000) = 120_000.
+ expect(scheduler.nextFireAt("ws-1")).toBe(120_000);
+ });
+
+ it("nextFireAt returns null after disarming mid-run (no re-arm)", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // fire in progress
+ expect(scheduler.nextFireAt("ws-1")).toBeNull(); // running
+ scheduler.disarm("ws-1"); // disarm mid-run
+ deferreds[0]?.resolve();
+ await flush();
+ // Disarmed → no re-arm, no fire time.
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+ });
+});
diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts
new file mode 100644
index 0000000..8b00b61
--- /dev/null
+++ b/packages/heartbeat/src/scheduler.ts
@@ -0,0 +1,193 @@
+/**
+ * Heartbeat scheduler — the imperative timer loop.
+ *
+ * The scheduler owns NO knowledge of conversations, prompts, or the
+ * orchestrator. It only manages per-workspace timers: when armed (a workspace's
+ * heartbeat is `enabled`), it schedules a fire after `intervalMinutes`; when the
+ * fire's work completes, it re-arms (resets the timer). This is the "reset timer
+ * after each run" semantics — the interval is measured from run-completion to
+ * the next fire, not a fixed wall-clock schedule.
+ *
+ * The actual run work (create a conversation, send the task prompt, track the
+ * run) is the `fire(workspaceId)` callback, injected by the heartbeat service.
+ * This keeps the scheduler pure-ish (only I/O is the injected timers) and
+ * testable with fake timers + a fake fire.
+ */
+
+/** A handle returned by `setTimeout`, opaque to the scheduler. */
+export type TimerHandle = ReturnType<typeof setTimeout>;
+
+/** Injectable timers — the only I/O effect the scheduler touches. */
+export interface Timers {
+ readonly now: () => number;
+ readonly setTimeout: (fn: () => void, ms: number) => TimerHandle;
+ readonly clearTimeout: (handle: TimerHandle | undefined) => void;
+}
+
+/** The real timers (used at runtime; overridable in tests). */
+export const realTimers: Timers = {
+ now: () => Date.now(),
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
+ clearTimeout: (handle) => {
+ if (handle !== undefined) clearTimeout(handle);
+ },
+};
+
+export interface SchedulerDeps {
+ readonly timers: Timers;
+ /** Do the run work for a workspace. Resolves when the turn seals. */
+ readonly fire: (workspaceId: string) => Promise<void>;
+}
+
+interface WorkspaceSchedule {
+ /** Current interval (minutes), updated by `arm` on config changes. */
+ intervalMinutes: number;
+ /** Pending wait timer (null while a fire is in progress). */
+ timer: TimerHandle | undefined;
+ /** A fire is in progress for this workspace. */
+ running: boolean;
+ /** Scheduling is active (the workspace's heartbeat is enabled). */
+ armed: boolean;
+ /**
+ * Absolute epoch-ms timestamp of the next scheduled fire, or null when no
+ * fire is pending — the schedule is disarmed, or a run is in progress
+ * (the next fire is scheduled only after the run completes). This is the
+ * server-authoritative next-run time the `/heartbeat/next-run` endpoint
+ * derives its response from.
+ */
+ nextFireMs: number | null;
+}
+
+const MINUTE_MS = 60_000;
+
+/**
+ * Manages per-workspace heartbeat timers. A single instance is owned by the
+ * heartbeat service for its lifetime.
+ */
+export class HeartbeatScheduler {
+ private readonly schedules = new Map<string, WorkspaceSchedule>();
+ private readonly timers: Timers;
+ private readonly fire: (workspaceId: string) => Promise<void>;
+
+ constructor(deps: SchedulerDeps) {
+ this.timers = deps.timers;
+ this.fire = deps.fire;
+ }
+
+ /**
+ * Arm (or re-arm) a workspace's schedule from its config. When `enabled`
+ * is false the schedule is disarmed. When a config update arrives while a
+ * run is in progress, the new `intervalMinutes` takes effect on the next
+ * re-arm (the in-progress run is never cancelled here).
+ */
+ arm(
+ workspaceId: string,
+ config: {
+ readonly enabled: boolean;
+ readonly intervalMinutes: number;
+ },
+ ): void {
+ if (!config.enabled) {
+ this.disarm(workspaceId);
+ return;
+ }
+ let schedule = this.schedules.get(workspaceId);
+ if (schedule === undefined) {
+ schedule = {
+ intervalMinutes: config.intervalMinutes,
+ timer: undefined,
+ running: false,
+ armed: true,
+ nextFireMs: null,
+ };
+ this.schedules.set(workspaceId, schedule);
+ } else {
+ schedule.intervalMinutes = config.intervalMinutes;
+ schedule.armed = true;
+ }
+ // If a fire is in progress, let it finish — it will re-arm with the
+ // (possibly new) interval. Otherwise schedule the next fire now.
+ if (!schedule.running) {
+ this.clearTimer(schedule);
+ this.scheduleNext(workspaceId, schedule);
+ }
+ }
+
+ /** Stop scheduling for a workspace (clears a pending timer; an in-progress run finishes on its own). */
+ disarm(workspaceId: string): void {
+ const schedule = this.schedules.get(workspaceId);
+ if (schedule === undefined) return;
+ schedule.armed = false;
+ this.clearTimer(schedule);
+ this.schedules.delete(workspaceId);
+ }
+
+ /** Stop all schedules (deactivate). */
+ disarmAll(): void {
+ for (const workspaceId of [...this.schedules.keys()]) {
+ this.disarm(workspaceId);
+ }
+ }
+
+ /** Whether a schedule is currently armed (enabled) for a workspace. */
+ isArmed(workspaceId: string): boolean {
+ return this.schedules.get(workspaceId)?.armed ?? false;
+ }
+
+ /** Whether a fire is currently in progress for a workspace. */
+ isRunning(workspaceId: string): boolean {
+ return this.schedules.get(workspaceId)?.running ?? false;
+ }
+
+ /**
+ * The absolute epoch-ms timestamp of the next scheduled fire for a workspace,
+ * or null when no fire is pending — the heartbeat is disabled/disarmed, or a
+ * run is in progress (the next fire is scheduled only after the run
+ * completes). This is the server-authoritative next-run time.
+ */
+ nextFireAt(workspaceId: string): number | null {
+ return this.schedules.get(workspaceId)?.nextFireMs ?? null;
+ }
+
+ private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void {
+ const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS);
+ // Record the absolute fire time (now + delay) BEFORE arming the timer
+ // so `nextFireAt` reports it while the timer is pending.
+ schedule.nextFireMs = this.timers.now() + ms;
+ schedule.timer = this.timers.setTimeout(() => {
+ this.onTick(workspaceId);
+ }, ms);
+ }
+
+ private onTick(workspaceId: string): void {
+ const schedule = this.schedules.get(workspaceId);
+ // Race: the schedule was disarmed after the timer was queued.
+ if (schedule === undefined || !schedule.armed) return;
+ schedule.timer = undefined;
+ // A fire is now in progress — no next run is queued yet (it's scheduled
+ // only after the run completes), so report no pending fire time.
+ schedule.nextFireMs = null;
+ schedule.running = true;
+ this.fire(workspaceId)
+ .catch(() => {
+ // The service records the run outcome; a thrown fire is logged
+ // by the service. Swallow so the loop continues.
+ })
+ .finally(() => {
+ const current = this.schedules.get(workspaceId);
+ if (current === undefined) return;
+ current.running = false;
+ if (current.armed) {
+ this.scheduleNext(workspaceId, current);
+ }
+ });
+ }
+
+ private clearTimer(schedule: WorkspaceSchedule): void {
+ if (schedule.timer !== undefined) {
+ this.timers.clearTimeout(schedule.timer);
+ schedule.timer = undefined;
+ }
+ schedule.nextFireMs = null;
+ }
+}
diff --git a/packages/heartbeat/tsconfig.json b/packages/heartbeat/tsconfig.json
new file mode 100644
index 0000000..b947b31
--- /dev/null
+++ b/packages/heartbeat/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../transport-contract" },
+ { "path": "../session-orchestrator" },
+ { "path": "../system-prompt" },
+ { "path": "../conversation-store" }
+ ]
+}