summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 00:02:22 +0900
committerAdam Malczewski <[email protected]>2026-06-26 00:02:22 +0900
commitc5c34ed70e0f04b7b936fa7a1d88ef807472fb96 (patch)
tree2eb3d32159c2875cf518fc47b7f48010d506aafe
parent9b91d1bca83e7599fb7d7de6038cedf186e61764 (diff)
downloaddispatch-c5c34ed70e0f04b7b936fa7a1d88ef807472fb96.tar.gz
dispatch-c5c34ed70e0f04b7b936fa7a1d88ef807472fb96.zip
feat(heartbeat): workspace heartbeat loop with configurable AI monitoring
-rw-r--r--bun.lock15
-rw-r--r--packages/heartbeat/package.json13
-rw-r--r--packages/heartbeat/src/config-store.test.ts115
-rw-r--r--packages/heartbeat/src/config-store.ts108
-rw-r--r--packages/heartbeat/src/extension.ts52
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts318
-rw-r--r--packages/heartbeat/src/heartbeat.ts205
-rw-r--r--packages/heartbeat/src/index.ts16
-rw-r--r--packages/heartbeat/src/run-store.test.ts72
-rw-r--r--packages/heartbeat/src/run-store.ts103
-rw-r--r--packages/heartbeat/src/scheduler.test.ts240
-rw-r--r--packages/heartbeat/src/scheduler.ts159
-rw-r--r--packages/heartbeat/tsconfig.json10
-rw-r--r--packages/host-bin/package.json1
-rw-r--r--packages/host-bin/src/main.ts5
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts86
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts77
-rw-r--r--packages/transport-http/package.json1
-rw-r--r--packages/transport-http/src/app.ts172
-rw-r--r--packages/transport-http/src/extension.ts9
-rw-r--r--packages/transport-http/src/seam.ts2
-rw-r--r--packages/transport-http/tsconfig.json1
-rw-r--r--tsconfig.json3
24 files changed, 1752 insertions, 33 deletions
diff --git a/bun.lock b/bun.lock
index 33a9249..202ead7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -58,6 +58,15 @@
"@dispatch/kernel": "workspace:*",
},
},
+ "packages/heartbeat": {
+ "name": "@dispatch/heartbeat",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*",
+ },
+ },
"packages/host-bin": {
"name": "@dispatch/host-bin",
"version": "0.0.0",
@@ -67,6 +76,7 @@
"@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
"@dispatch/exec-backend": "workspace:*",
+ "@dispatch/heartbeat": "workspace:*",
"@dispatch/journal-sink": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/lsp": "workspace:*",
@@ -310,7 +320,7 @@
},
"packages/transport-contract": {
"name": "@dispatch/transport-contract",
- "version": "0.22.0",
+ "version": "0.23.0",
"dependencies": {
"@dispatch/ui-contract": "workspace:*",
"@dispatch/wire": "workspace:*",
@@ -322,6 +332,7 @@
"dependencies": {
"@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
+ "@dispatch/heartbeat": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/lsp": "workspace:*",
"@dispatch/mcp": "workspace:*",
@@ -384,6 +395,8 @@
"@dispatch/exec-backend": ["@dispatch/exec-backend@workspace:packages/exec-backend"],
+ "@dispatch/heartbeat": ["@dispatch/heartbeat@workspace:packages/heartbeat"],
+
"@dispatch/host-bin": ["@dispatch/host-bin@workspace:packages/host-bin"],
"@dispatch/journal-sink": ["@dispatch/journal-sink@workspace:packages/journal-sink"],
diff --git a/packages/heartbeat/package.json b/packages/heartbeat/package.json
new file mode 100644
index 0000000..40212b9
--- /dev/null
+++ b/packages/heartbeat/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@dispatch/heartbeat",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/session-orchestrator": "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..0cb6afa
--- /dev/null
+++ b/packages/heartbeat/src/config-store.test.ts
@@ -0,0 +1,115 @@
+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");
+ });
+});
+
+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("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..74c01e2
--- /dev/null
+++ b/packages/heartbeat/src/config-store.ts
@@ -0,0 +1,108 @@
+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,
+ 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,
+ 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,
+ 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..66580e6
--- /dev/null
+++ b/packages/heartbeat/src/extension.ts
@@ -0,0 +1,52 @@
+/**
+ * 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.
+ */
+
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import {
+ type SessionOrchestrator,
+ sessionOrchestratorHandle,
+} from "@dispatch/session-orchestrator";
+import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js";
+
+export const manifest: Manifest = {
+ id: "heartbeat",
+ name: "Heartbeat",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ dependsOn: ["session-orchestrator"],
+ 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;
+
+ const service = createHeartbeatService({ storage, orchestrator, logger });
+
+ // 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..c185c92
--- /dev/null
+++ b/packages/heartbeat/src/heartbeat.test.ts
@@ -0,0 +1,318 @@
+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;
+ 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 } : {}),
+ 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;
+}) {
+ const fake = createFakeTimers();
+ let id = 0;
+ const storage = opts.storage ?? createMemoryStorage();
+ const svc = createHeartbeatService({
+ storage,
+ orchestrator: opts.orch,
+ timers: fake.timers,
+ generateId: () => `id-${++id}`,
+ });
+ 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);
+ });
+
+ 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");
+ expect(turn.workspaceId).toBe("ws-1");
+
+ 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("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("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();
+ expect(orch.pending.filter((t) => t.workspaceId === "ws-1")).toHaveLength(1);
+ expect(orch.pending.filter((t) => t.workspaceId === "ws-2")).toHaveLength(0);
+ });
+});
diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts
new file mode 100644
index 0000000..d1efe47
--- /dev/null
+++ b/packages/heartbeat/src/heartbeat.ts
@@ -0,0 +1,205 @@
+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 {
+ type HeartbeatConfigStore,
+ createHeartbeatConfigStore,
+} from "./config-store.js";
+import { type HeartbeatRunStore, createHeartbeatRunStore } from "./run-store.js";
+import { HeartbeatScheduler, type Timers, realTimers } from "./scheduler.js";
+
+/**
+ * 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[]>;
+ /**
+ * 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;
+}
+
+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;
+
+ // 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;
+
+ 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 });
+
+ try {
+ await orchestrator.handleMessage({
+ conversationId,
+ text: config.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 an explicit override (incl. the empty string = no system
+ // prompt) — bypasses the templated workspace prompt.
+ systemPrompt: config.systemPrompt,
+ ...(config.model !== "" ? { modelName: config.model } : {}),
+ ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}),
+ workspaceId,
+ });
+ } 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 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..3886be5
--- /dev/null
+++ b/packages/heartbeat/src/index.ts
@@ -0,0 +1,16 @@
+export { extension, manifest } from "./extension.js";
+export {
+ heartbeatServiceHandle,
+ createHeartbeatService,
+ type HeartbeatService,
+ type HeartbeatServiceDeps,
+} from "./heartbeat.js";
+export {
+ createHeartbeatConfigStore,
+ applyConfigUpdate,
+ DEFAULT_HEARTBEAT_CONFIG,
+ MIN_INTERVAL_MINUTES,
+ type HeartbeatConfigStore,
+} from "./config-store.js";
+export { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js";
+export { HeartbeatScheduler, realTimers, type Timers, type TimerHandle } 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..9448838
--- /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..7522aab
--- /dev/null
+++ b/packages/heartbeat/src/run-store.ts
@@ -0,0 +1,103 @@
+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..355410c
--- /dev/null
+++ b/packages/heartbeat/src/scheduler.test.ts
@@ -0,0 +1,240 @@
+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);
+ });
+});
diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts
new file mode 100644
index 0000000..53ffb96
--- /dev/null
+++ b/packages/heartbeat/src/scheduler.ts
@@ -0,0 +1,159 @@
+/**
+ * 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;
+}
+
+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 };
+ 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;
+ }
+
+ private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void {
+ const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_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;
+ 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;
+ }
+ }
+}
diff --git a/packages/heartbeat/tsconfig.json b/packages/heartbeat/tsconfig.json
new file mode 100644
index 0000000..088ee0f
--- /dev/null
+++ b/packages/heartbeat/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../transport-contract" },
+ { "path": "../session-orchestrator" }
+ ]
+}
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
index 64f436e..f27bf06 100644
--- a/packages/host-bin/package.json
+++ b/packages/host-bin/package.json
@@ -11,6 +11,7 @@
"@dispatch/cache-warming": "workspace:*",
"@dispatch/credential-store": "workspace:*",
"@dispatch/exec-backend": "workspace:*",
+ "@dispatch/heartbeat": "workspace:*",
"@dispatch/provider-openai-compat": "workspace:*",
"@dispatch/provider-umans": "workspace:*",
"@dispatch/message-queue": "workspace:*",
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
index 571628f..26015ed 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { extension as authApikeyExt } from "@dispatch/auth-apikey";
import { extension as cacheWarmingExt } from "@dispatch/cache-warming";
+import { extension as heartbeatExt } from "@dispatch/heartbeat";
import { extension as conversationStoreExt } from "@dispatch/conversation-store";
import { createCredentialStoreExtension } from "@dispatch/credential-store";
import { createExecBackendExtension } from "@dispatch/exec-backend";
@@ -105,6 +106,10 @@ const CORE_EXTENSIONS: readonly Extension[] = [
// tool-serving extensions) to keep the DAG honest — and before
// transport-http, whose routes consume the ComputerService it provides.
sshExt,
+ // heartbeat PROVIDES the HeartbeatService (per-workspace AI loop) the
+ // HTTP routes delegate to. Placed before transport-http (which depends on
+ // it) — mirrors how ssh precedes transport-http for the same reason.
+ heartbeatExt,
createTransportHttpExtension(),
// Surface extensions — dependency order: surface-registry first, then consumers.
createSurfaceRegistryExtension(),
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 4aa77f7..9778d1f 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -55,6 +55,16 @@ export interface StartTurnInput {
* resolve the effective cwd from the workspace's `defaultCwd`.
*/
readonly workspaceId?: string;
+ /**
+ * An EXPLICIT system-prompt override. When provided (a string, including the
+ * empty string), it is sent to the provider AS-IS and the system-prompt
+ * SERVICE is bypassed entirely (no template construction, no
+ * persist/reuse). This is how a caller that owns its own prompt (e.g. the
+ * heartbeat extension) drives a turn without the templated workspace prompt.
+ * Omitted/`undefined` = the existing behavior (construct/reuse via the
+ * system-prompt service when loaded).
+ */
+ readonly systemPrompt?: string;
}
export type StartTurnResult =
@@ -277,6 +287,8 @@ export interface SessionOrchestrator {
computerId?: string;
reasoningEffort?: ReasoningEffort;
workspaceId?: string;
+ /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */
+ systemPrompt?: string;
}): Promise<void>;
}
@@ -424,6 +436,7 @@ export function createSessionOrchestrator(
computerId: string | undefined,
reasoningEffortOverride: ReasoningEffort | undefined,
workspaceId: string,
+ systemPromptOverride: string | undefined,
): void {
const turnId = generateTurnId();
const controller = new AbortController();
@@ -609,37 +622,47 @@ export function createSessionOrchestrator(
// where a cwd change left the prompt stale. When the system-prompt
// service isn't loaded, no system prompt is sent (current behavior
// preserved).
- const systemPromptService = deps.resolveSystemPrompt?.();
+ //
+ // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a
+ // string, incl. empty), it is sent to the provider AS-IS and the
+ // system-prompt service is bypassed entirely (no construct/reuse).
+ // This lets a caller that owns its own prompt (e.g. the heartbeat
+ // extension) drive a turn without the templated workspace prompt.
let systemPrompt: string | undefined;
- if (systemPromptService !== undefined) {
- if (isNewConversation) {
- systemPrompt = await systemPromptService.construct(
- conversationId,
- effectiveCwd ?? process.cwd(),
- {
- ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- },
- );
- } else {
- const meta = await systemPromptService.getWithMeta(conversationId);
- const currentCwd = effectiveCwd ?? process.cwd();
- const currentComputerId = effectiveComputerId ?? null;
- // Invalidate when cwd OR computerId changed (switching computers
- // must rebuild the prompt against the remote OS/hostname).
- if (
- meta.prompt !== null &&
- meta.cwd === currentCwd &&
- meta.computerId === currentComputerId
- ) {
- systemPrompt = meta.prompt;
+ if (systemPromptOverride !== undefined) {
+ systemPrompt = systemPromptOverride;
+ } else {
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ if (systemPromptService !== undefined) {
+ if (isNewConversation) {
+ systemPrompt = await systemPromptService.construct(
+ conversationId,
+ effectiveCwd ?? process.cwd(),
+ {
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ },
+ );
} else {
- systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
- ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- });
+ const meta = await systemPromptService.getWithMeta(conversationId);
+ const currentCwd = effectiveCwd ?? process.cwd();
+ const currentComputerId = effectiveComputerId ?? null;
+ // Invalidate when cwd OR computerId changed (switching computers
+ // must rebuild the prompt against the remote OS/hostname).
+ if (
+ meta.prompt !== null &&
+ meta.cwd === currentCwd &&
+ meta.computerId === currentComputerId
+ ) {
+ systemPrompt = meta.prompt;
+ } else {
+ systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ });
+ }
}
}
}
@@ -773,7 +796,7 @@ export function createSessionOrchestrator(
}
const orchestrator: SessionOrchestrator = {
- startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) {
+ startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId, systemPrompt }) {
if (activeTurns.has(conversationId)) {
return { started: false, reason: "already-active" };
}
@@ -785,6 +808,7 @@ export function createSessionOrchestrator(
computerId,
reasoningEffort,
workspaceId ?? "default",
+ systemPrompt,
);
const turn = activeTurns.get(conversationId);
const turnId = turn !== undefined ? turn.turnId : "";
@@ -880,6 +904,7 @@ export function createSessionOrchestrator(
computerId,
reasoningEffort,
workspaceId,
+ systemPrompt,
}) {
const turnInput: StartTurnInput = {
conversationId,
@@ -889,6 +914,7 @@ export function createSessionOrchestrator(
...(computerId !== undefined ? { computerId } : {}),
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
};
const result = orchestrator.startTurn(turnInput);
if (!result.started) {
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 842a28b..7f58c63 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.22.0",
+ "version": "0.23.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index b32c8a0..2c3ec64 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -908,3 +908,80 @@ export interface TestComputerResponse {
readonly ok: boolean;
readonly error?: string;
}
+
+// ─── Heartbeat ───────────────────────────────────────────────────────────────
+
+/**
+ * The per-workspace Heartbeat config — a single record stored per workspace.
+ *
+ * A heartbeat is an AI that runs on a configurable loop per workspace: when
+ * `enabled`, the backend summons a NEW conversation every `intervalMinutes`
+ * minutes, gives the heartbeat AI `systemPrompt` + sends `taskPrompt` as the
+ * opening user message, and inherits the workspace's computer (SSH) + cwd +
+ * standard tool kit. The main purpose is monitoring (e.g. checking if chats
+ * are stuck).
+ *
+ * `model` is a model name in `<credentialName>/<model>` form (one of the strings
+ * from `GET /models`), or the empty string to use the server default.
+ * `reasoningEffort` is `null` to inherit the workspace default, or an explicit
+ * level. The scheduler resets its timer after each run completes (not a fixed
+ * wall-clock schedule); on backend restart it resumes scheduling for enabled
+ * heartbeats.
+ */
+export interface HeartbeatConfig {
+ /** Whether the heartbeat loop is active for this workspace. */
+ readonly enabled: boolean;
+ /** Custom system prompt for the heartbeat AI (empty = no system prompt). */
+ readonly systemPrompt: string;
+ /** Task prompt sent as the first user message when the heartbeat fires. */
+ readonly taskPrompt: string;
+ /** How often to fire, in minutes (default 30). */
+ readonly intervalMinutes: number;
+ /** Model name (`<credentialName>/<model>`), or empty string = server default. */
+ readonly model: string;
+ /** Reasoning-effort level, or `null` = inherit the workspace default. */
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * Body of `PUT /workspaces/:id/heartbeat` — a partial update. All fields are
+ * optional; only the provided fields are applied. `reasoningEffort` accepts
+ * `null` to clear to the workspace default. `intervalMinutes` must be a positive
+ * integer (clamped server-side to a minimum of 1). An unrecognized
+ * `reasoningEffort` → HTTP 400.
+ */
+export interface UpdateHeartbeatRequest {
+ readonly enabled?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
+}
+
+/** The status of a single heartbeat run. */
+export type HeartbeatRunStatus = "running" | "completed" | "stopped";
+
+/**
+ * One heartbeat run — created each time the heartbeat fires. A run tracks the
+ * conversation it spawned and its lifecycle. `triggeredAt` is an ISO-8601
+ * timestamp. `status` is `"running"` while the turn is in flight,
+ * `"completed"` when the turn sealed normally, or `"stopped"` when the user
+ * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`).
+ */
+export interface HeartbeatRun {
+ readonly id: string;
+ readonly conversationId: string;
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
+}
+
+/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */
+export interface HeartbeatRunsResponse {
+ readonly runs: readonly HeartbeatRun[];
+}
+
+/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
+export interface StopHeartbeatRunResponse {
+ readonly ok: true;
+}
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index e7eb85c..7990c2b 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -8,6 +8,7 @@
"dependencies": {
"@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
+ "@dispatch/heartbeat": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/lsp": "workspace:*",
"@dispatch/mcp": "workspace:*",
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 2e81c46..36b27a4 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1,4 +1,5 @@
import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel";
+import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat";
import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt";
import type {
CloseConversationResponse,
@@ -14,6 +15,8 @@ import type {
ConversationStatusResponse,
CwdResponse,
DeleteWorkspaceResponse,
+ HeartbeatConfig,
+ HeartbeatRunsResponse,
LastMessageResponse,
LspServerInfo,
LspStatusResponse,
@@ -28,11 +31,13 @@ import type {
SetConversationComputerRequest,
SetSystemPromptTemplateRequest,
SetWorkspaceDefaultComputerRequest,
+ StopHeartbeatRunResponse,
SystemPromptTemplateResponse,
SystemPromptVariablesResponse,
TestComputerResponse,
ThroughputResponse,
TitleResponse,
+ UpdateHeartbeatRequest,
WarmResponse,
WorkspaceListResponse,
WorkspaceResponse,
@@ -47,6 +52,7 @@ import {
isParseError,
isReasoningEffortParseError,
isSinceSeqError,
+ isValidReasoningEffort,
isWindowParamError,
parseChatBody,
parseModelBody,
@@ -64,6 +70,7 @@ import {
type ConversationStore,
type CredentialStore,
conversationOpened,
+ type HeartbeatService,
isValidWorkspaceSlug,
type LspServerStatus,
type LspService,
@@ -87,6 +94,13 @@ export interface CreateServerOptions {
/** Optional — system prompt builder service (GET/PUT template). */
readonly systemPromptService?: SystemPromptService;
/**
+ * Optional — per-workspace heartbeat loop service (provided by the
+ * `heartbeat` extension). When absent (heartbeat not loaded), the
+ * `/workspaces/:id/heartbeat*` routes degrade: GET returns defaults,
+ * PUT/POST return 503.
+ */
+ readonly heartbeatService?: HeartbeatService;
+ /**
* Optional — computer discovery + live connection service (provided by the
* `ssh` extension). When absent (ssh not loaded), the `/computers*` routes
* degrade: list returns `[]`, status returns "disconnected", test returns
@@ -1340,6 +1354,164 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ // ─── Heartbeat (per-workspace AI loop) ─────────────────────────────────────
+ // The config + run history for a workspace's heartbeat loop. Delegated to
+ // the HeartbeatService (provided by the `heartbeat` extension). When
+ // heartbeat is NOT loaded the routes degrade: GET config → the defaults,
+ // GET runs → empty, PUT/POST → 503 (mirrors how /system-prompt returns the
+ // default template when its service is absent but 503s writes).
+
+ app.get("/workspaces/:id/heartbeat", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (opts.heartbeatService === undefined) {
+ // Graceful: no heartbeat configured → return the defaults so the FE
+ // always gets a usable config shape (enabled: false, etc.).
+ const body: HeartbeatConfig = DEFAULT_HEARTBEAT_CONFIG;
+ return c.json(body, 200);
+ }
+ try {
+ const config = await opts.heartbeatService.getConfig(workspaceId);
+ log.info("heartbeat: config read", { workspaceId, enabled: config.enabled });
+ const body: HeartbeatConfig = config;
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("heartbeat: config read failure", { err, workspaceId });
+ return c.json({ error: "Failed to read heartbeat config" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id/heartbeat", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (opts.heartbeatService === undefined) {
+ return c.json({ error: "Heartbeat service not available" }, 503);
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("heartbeat: invalid JSON body", { workspaceId });
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+
+ // Build a partial update, validating each present field. All fields are
+ // optional (a partial update); only provided fields are forwarded.
+ const update: Record<string, unknown> = {};
+
+ if (obj.enabled !== undefined) {
+ if (typeof obj.enabled !== "boolean") {
+ return c.json({ error: "Field 'enabled' must be a boolean" }, 400);
+ }
+ update.enabled = obj.enabled;
+ }
+
+ if (obj.systemPrompt !== undefined) {
+ if (typeof obj.systemPrompt !== "string") {
+ return c.json({ error: "Field 'systemPrompt' must be a string" }, 400);
+ }
+ update.systemPrompt = obj.systemPrompt;
+ }
+
+ if (obj.taskPrompt !== undefined) {
+ if (typeof obj.taskPrompt !== "string") {
+ return c.json({ error: "Field 'taskPrompt' must be a string" }, 400);
+ }
+ update.taskPrompt = obj.taskPrompt;
+ }
+
+ if (obj.intervalMinutes !== undefined) {
+ if (typeof obj.intervalMinutes !== "number" || !Number.isFinite(obj.intervalMinutes)) {
+ return c.json({ error: "Field 'intervalMinutes' must be a number" }, 400);
+ }
+ update.intervalMinutes = obj.intervalMinutes;
+ }
+
+ if (obj.model !== undefined) {
+ if (typeof obj.model !== "string") {
+ return c.json({ error: "Field 'model' must be a string" }, 400);
+ }
+ update.model = obj.model;
+ }
+
+ // `reasoningEffort` accepts a valid level string OR null (clear the
+ // override → inherit the workspace default). Absent (undefined) leaves
+ // it unchanged. An unrecognized string → 400.
+ if (obj.reasoningEffort !== undefined) {
+ if (obj.reasoningEffort !== null && !isValidReasoningEffort(obj.reasoningEffort)) {
+ return c.json(
+ {
+ error:
+ "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null",
+ },
+ 400,
+ );
+ }
+ update.reasoningEffort = obj.reasoningEffort;
+ }
+
+ try {
+ const config = await opts.heartbeatService.updateConfig(
+ workspaceId,
+ update as UpdateHeartbeatRequest,
+ );
+ log.info("heartbeat: config updated", {
+ workspaceId,
+ enabled: config.enabled,
+ intervalMinutes: config.intervalMinutes,
+ });
+ const response: HeartbeatConfig = config;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("heartbeat: config update failure", { err, workspaceId });
+ return c.json({ error: "Failed to update heartbeat config" }, 500);
+ }
+ });
+
+ app.get("/workspaces/:id/heartbeat/runs", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (opts.heartbeatService === undefined) {
+ // Graceful: no heartbeat → no runs.
+ const body: HeartbeatRunsResponse = { runs: [] };
+ return c.json(body, 200);
+ }
+ try {
+ const runs = await opts.heartbeatService.listRuns(workspaceId);
+ log.info("heartbeat: runs listed", { workspaceId, count: runs.length });
+ const body: HeartbeatRunsResponse = { runs };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("heartbeat: runs list failure", { err, workspaceId });
+ return c.json({ error: "Failed to list heartbeat runs" }, 500);
+ }
+ });
+
+ app.post("/workspaces/:id/heartbeat/runs/:runId/stop", async (c) => {
+ const workspaceId = c.req.param("id");
+ const runId = c.req.param("runId");
+ if (opts.heartbeatService === undefined) {
+ return c.json({ error: "Heartbeat service not available" }, 503);
+ }
+ try {
+ const result = await opts.heartbeatService.stopRun(workspaceId, runId);
+ log.info("heartbeat: run stopped", { workspaceId, runId });
+ const body: StopHeartbeatRunResponse = result;
+ return c.json(body, 200);
+ } catch (err) {
+ // stopRun throws "Heartbeat run not found" for an unknown run id.
+ const message = err instanceof Error ? err.message : String(err);
+ if (message.includes("not found")) {
+ return c.json({ error: "Heartbeat run not found" }, 404);
+ }
+ log.error("heartbeat: run stop failure", { err, workspaceId, runId });
+ return c.json({ error: "Failed to stop heartbeat run" }, 500);
+ }
+ });
+
// ─── System prompt template ───────────────────────────────────────────────
app.get("/system-prompt/variables", (c) => {
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 4ab43ce..e3f9ffc 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -7,6 +7,7 @@ import {
computerServiceHandle,
conversationStoreHandle,
credentialStoreHandle,
+ heartbeatServiceHandle,
lspServiceHandle,
mcpServiceHandle,
sessionOrchestratorHandle,
@@ -23,6 +24,7 @@ export const manifest: Manifest = {
dependsOn: [
"conversation-store",
"credential-store",
+ "heartbeat",
"lsp",
"mcp",
"session-orchestrator",
@@ -60,9 +62,12 @@ export const manifest: Manifest = {
"/system-prompt/variables",
"/workspaces",
"/workspaces/:id",
- "/workspaces/:id/title",
"/workspaces/:id/default-cwd",
"/workspaces/:id/default-computer",
+ "/workspaces/:id/heartbeat",
+ "/workspaces/:id/heartbeat/runs",
+ "/workspaces/:id/heartbeat/runs/:runId/stop",
+ "/workspaces/:id/title",
],
},
activation: "eager",
@@ -88,6 +93,7 @@ export function createTransportHttpExtension(): Extension & {
const lspService = host.getService(lspServiceHandle);
const mcpService = host.getService(mcpServiceHandle);
const systemPromptService = host.getService(systemPromptHandle);
+ const heartbeatService = host.getService(heartbeatServiceHandle);
// Optional: the `ssh` extension provides ComputerService. It is NOT in
// dependsOn (ssh may be absent), so resolve defensively — when no
// provider registered the handle, the computer routes degrade to
@@ -111,6 +117,7 @@ export function createTransportHttpExtension(): Extension & {
lspService,
mcpService,
systemPromptService,
+ heartbeatService,
...(computerService !== undefined ? { computerService } : {}),
logger,
emit: host.emit.bind(host),
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index ef28a09..d48bd70 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -6,6 +6,8 @@ export type { ConversationStore } from "@dispatch/conversation-store";
export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store";
export type { CredentialStore } from "@dispatch/credential-store";
export { credentialStoreHandle } from "@dispatch/credential-store";
+export type { HeartbeatService } from "@dispatch/heartbeat";
+export { heartbeatServiceHandle } from "@dispatch/heartbeat";
export type { LspServerStatus, LspService } from "@dispatch/lsp";
export { lspServiceHandle } from "@dispatch/lsp";
export type { McpServerStatus, McpService } from "@dispatch/mcp";
diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json
index 8dd2439..001a373 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -5,6 +5,7 @@
"references": [
{ "path": "../conversation-store" },
{ "path": "../credential-store" },
+ { "path": "../heartbeat" },
{ "path": "../kernel" },
{ "path": "../lsp" },
{ "path": "../session-orchestrator" },
diff --git a/tsconfig.json b/tsconfig.json
index aab3ac1..cde3850 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -95,6 +95,9 @@
"path": "./packages/lsp"
},
{
+ "path": "./packages/heartbeat"
+ },
+ {
"path": "./packages/system-prompt"
},
{