summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-29 01:51:03 +0900
committerAdam Malczewski <[email protected]>2026-06-29 01:51:03 +0900
commitfdc2d0df8fa5ffca8c1c0957cc8bbbe5f4a5304c (patch)
tree27422f665b3c332f0a2a923dc64466dedf16baea /packages/transport-http
parent6dd9ea9b935e5011c16faed6c869c976cf5ff172 (diff)
downloaddispatch-fdc2d0df8fa5ffca8c1c0957cc8bbbe5f4a5304c.tar.gz
dispatch-fdc2d0df8fa5ffca8c1c0957cc8bbbe5f4a5304c.zip
feat(heartbeat): add inactiveOnly mode (skip fire while workspace has active agents)
A new per-workspace heartbeat setting `inactiveOnly` (default true): when on, the heartbeat SKIPS a scheduled fire whenever the configured workspace has any active agents — a conversation whose persisted status is "active" (driving a turn) or "queued" (waiting on the message queue). The fire is silently skipped (no run recorded); the scheduler re-arms and retries at the next interval. Set false to fire unconditionally (the prior behavior). The check is wired against conversationStore.listConversations({ workspaceId, status: ["active","queued"] }) at fire time — the orchestrator sets "active" on turn start and "idle" on settle, so it is the live busy/idle signal. The spawned heartbeat conversation lives in the dedicated heartbeat workspace, so a heartbeat run never counts as an active agent of the configured workspace (no self-block). - transport-contract: add inactiveOnly to HeartbeatConfig + UpdateHeartbeatRequest - heartbeat config-store: default true, apply/persist, legacy-parse default true - heartbeat service: injectable hasActiveAgents dep + skip logic in fire() - heartbeat extension: wire hasActiveAgents against the conversation store - transport-http: validate inactiveOnly (boolean) on PUT /workspaces/:id/heartbeat - tests: config-store (+4), heartbeat service (+7), transport-http (+3) - notes/heartbeat-setting-handoff.md: API contract for the frontend Verification: typecheck EXIT 0; tests 2013 passed | 6 skipped; biome EXIT 0.
Diffstat (limited to 'packages/transport-http')
-rw-r--r--packages/transport-http/src/app.test.ts90
-rw-r--r--packages/transport-http/src/app.ts10
2 files changed, 100 insertions, 0 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 03f1959..cf09738 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -539,6 +539,35 @@ function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService
};
}
+/**
+ * A HeartbeatService fake that CAPTURES the updateConfig call (workspaceId +
+ * partial update) and returns a config echoing the captured update on top of
+ * the defaults — for asserting the PUT /workspaces/:id/heartbeat route forwards
+ * validated fields to the service.
+ */
+function createCapturingHeartbeatService(): HeartbeatService & {
+ readonly captured: { workspaceId: string; update: Record<string, unknown> }[];
+} {
+ const captured: { workspaceId: string; update: Record<string, unknown> }[] = [];
+ const svc: HeartbeatService = {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ async updateConfig(workspaceId, update) {
+ captured.push({ workspaceId, update: update as Record<string, unknown> });
+ return { ...DEFAULT_HEARTBEAT_CONFIG, ...update };
+ },
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => null,
+ };
+ return Object.assign(svc, {
+ get captured() {
+ return captured;
+ },
+ });
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -4541,3 +4570,64 @@ describe("GET /workspaces/:id/heartbeat/next-run", () => {
expect(body.nextRunAt).toBeNull();
});
});
+
+describe("PUT /workspaces/:id/heartbeat", () => {
+ it("forwards inactiveOnly to the service and echoes it in the response", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: false }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { inactiveOnly: boolean };
+ expect(body.inactiveOnly).toBe(false);
+ expect(hb.captured).toHaveLength(1);
+ expect(hb.captured[0]?.workspaceId).toBe("ws-1");
+ expect(hb.captured[0]?.update.inactiveOnly).toBe(false);
+ });
+
+ it("rejects a non-boolean inactiveOnly with 400", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: "yes" }),
+ });
+ expect(res.status).toBe(400);
+ // The service was NOT called (validation happened first).
+ expect(hb.captured).toHaveLength(0);
+ });
+
+ it("omits inactiveOnly from the forwarded update when absent (leaves it unchanged)", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ expect(res.status).toBe(200);
+ expect(hb.captured[0]?.update.inactiveOnly).toBeUndefined();
+ });
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 656be9d..cbf83e7 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1673,6 +1673,16 @@ export function createApp(opts: CreateServerOptions): Hono {
update.enabled = obj.enabled;
}
+ // inactiveOnly: when true (the default), the heartbeat skips a fire while
+ // the configured workspace has active agents. A boolean; absent leaves it
+ // unchanged.
+ if (obj.inactiveOnly !== undefined) {
+ if (typeof obj.inactiveOnly !== "boolean") {
+ return c.json({ error: "Field 'inactiveOnly' must be a boolean" }, 400);
+ }
+ update.inactiveOnly = obj.inactiveOnly;
+ }
+
if (obj.systemPrompt !== undefined) {
if (typeof obj.systemPrompt !== "string") {
return c.json({ error: "Field 'systemPrompt' must be a string" }, 400);