@desplega.ai/agent-swarm 1.55.0 → 1.56.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -100,6 +100,7 @@ const updateAgentProfileRoute = route({
100
100
  identityMd: z.string().max(65536).optional(),
101
101
  setupScript: z.string().max(65536).optional(),
102
102
  toolsMd: z.string().max(65536).optional(),
103
+ heartbeatMd: z.string().max(65536).optional(),
103
104
  changeSource: z.string().optional(),
104
105
  changedByAgentId: z.string().optional(),
105
106
  changeReason: z.string().optional(),
@@ -283,11 +284,12 @@ export async function handleAgentsRest(
283
284
  body.soulMd === undefined &&
284
285
  body.identityMd === undefined &&
285
286
  body.setupScript === undefined &&
286
- body.toolsMd === undefined
287
+ body.toolsMd === undefined &&
288
+ body.heartbeatMd === undefined
287
289
  ) {
288
290
  jsonError(
289
291
  res,
290
- "At least one field (role, description, capabilities, claudeMd, soulMd, identityMd, setupScript, or toolsMd) must be provided",
292
+ "At least one field (role, description, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, or heartbeatMd) must be provided",
291
293
  400,
292
294
  );
293
295
  return true;
@@ -317,6 +319,7 @@ export async function handleAgentsRest(
317
319
  identityMd: body.identityMd,
318
320
  setupScript: body.setupScript,
319
321
  toolsMd: body.toolsMd,
322
+ heartbeatMd: body.heartbeatMd,
320
323
  },
321
324
  versionMeta,
322
325
  );
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { runHeartbeatSweep } from "../heartbeat/heartbeat";
2
+ import { checkHeartbeatChecklist, runHeartbeatSweep } from "../heartbeat/heartbeat";
3
3
  import { route } from "./route-def";
4
4
  import { json } from "./utils";
5
5
 
@@ -18,6 +18,19 @@ const triggerSweep = route({
18
18
  auth: { apiKey: true },
19
19
  });
20
20
 
21
+ const triggerChecklist = route({
22
+ method: "post",
23
+ path: "/api/heartbeat/checklist",
24
+ pattern: ["api", "heartbeat", "checklist"],
25
+ summary: "Trigger an immediate heartbeat checklist check",
26
+ tags: ["Heartbeat"],
27
+ responses: {
28
+ 200: { description: "Checklist check completed successfully" },
29
+ 401: { description: "Unauthorized" },
30
+ },
31
+ auth: { apiKey: true },
32
+ });
33
+
21
34
  // ─── Handler ─────────────────────────────────────────────────────────────────
22
35
 
23
36
  export async function handleHeartbeat(
@@ -39,5 +52,20 @@ export async function handleHeartbeat(
39
52
  return true;
40
53
  }
41
54
 
55
+ if (triggerChecklist.match(req.method, pathSegments)) {
56
+ const parsed = await triggerChecklist.parse(req, res, pathSegments, new URLSearchParams());
57
+ if (!parsed) return true;
58
+
59
+ try {
60
+ await checkHeartbeatChecklist();
61
+ json(res, { success: true, message: "Heartbeat checklist check completed" });
62
+ } catch (err) {
63
+ const message =
64
+ err instanceof Error ? err.message : "Unknown error during heartbeat checklist check";
65
+ json(res, { success: false, error: message }, 500);
66
+ }
67
+ return true;
68
+ }
69
+
42
70
  return false;
43
71
  }
@@ -0,0 +1,417 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createAgent,
6
+ createTaskExtended,
7
+ getDb,
8
+ initDb,
9
+ startTask,
10
+ updateAgentProfile,
11
+ } from "../be/db";
12
+ import {
13
+ checkHeartbeatChecklist,
14
+ createBootTriageTask,
15
+ gatherSystemStatus,
16
+ isEffectivelyEmpty,
17
+ } from "../heartbeat/heartbeat";
18
+
19
+ // Side-effect import: register heartbeat templates (also done by heartbeat.ts,
20
+ // but other test files may call clearTemplateDefinitions() in parallel)
21
+ import "../heartbeat/templates";
22
+
23
+ const TEST_DB_PATH = "./test-heartbeat-checklist.sqlite";
24
+
25
+ describe("Heartbeat Checklist", () => {
26
+ beforeAll(async () => {
27
+ initDb(TEST_DB_PATH);
28
+ });
29
+
30
+ afterAll(async () => {
31
+ closeDb();
32
+ await unlink(TEST_DB_PATH).catch(() => {});
33
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
34
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
35
+ });
36
+
37
+ beforeEach(async () => {
38
+ getDb().run("DELETE FROM agent_tasks");
39
+ getDb().run("DELETE FROM agents");
40
+ // Re-register heartbeat templates — other test files (prompt-template-resolver,
41
+ // prompt-template-session) call clearTemplateDefinitions() in parallel
42
+ await import(`../heartbeat/templates?t=${Date.now()}`);
43
+ });
44
+
45
+ // ==========================================================================
46
+ // isEffectivelyEmpty()
47
+ // ==========================================================================
48
+
49
+ describe("isEffectivelyEmpty", () => {
50
+ test("returns true for empty string", () => {
51
+ expect(isEffectivelyEmpty("")).toBe(true);
52
+ });
53
+
54
+ test("returns true for whitespace-only", () => {
55
+ expect(isEffectivelyEmpty(" \n \n ")).toBe(true);
56
+ });
57
+
58
+ test("returns true for headers-only", () => {
59
+ expect(isEffectivelyEmpty("# Title\n## Subtitle")).toBe(true);
60
+ });
61
+
62
+ test("returns true for HTML comments only", () => {
63
+ expect(isEffectivelyEmpty("<!-- comment -->")).toBe(true);
64
+ });
65
+
66
+ test("returns true for multi-line HTML comments", () => {
67
+ expect(isEffectivelyEmpty("<!-- start\nsome content\nend -->")).toBe(true);
68
+ });
69
+
70
+ test("returns true for mix of headers + comments + empty items", () => {
71
+ const content = `# Heartbeat Checklist
72
+
73
+ <!-- Keep this section empty -->
74
+ ## Section
75
+
76
+ - [ ]
77
+ -
78
+ <!-- Another comment -->`;
79
+ expect(isEffectivelyEmpty(content)).toBe(true);
80
+ });
81
+
82
+ test("returns true for the default lead template", () => {
83
+ const content = `# Heartbeat Checklist
84
+
85
+ <!-- Keep this section empty to skip periodic heartbeat checks (no LLM cost). -->
86
+ <!-- Add actionable items below when you want periodic checks. -->
87
+ <!-- The lead agent reads this every 30 minutes and acts on any items found. -->
88
+
89
+ <!-- Examples (uncomment to activate):
90
+ - Check Slack for unaddressed requests older than 1 hour
91
+ - Review active tasks for any that seem stuck or blocked
92
+ - If idle workers exist and unassigned tasks are available, investigate why auto-assignment didn't handle them
93
+ - Post a daily summary to #agent-status at 5pm
94
+ -->`;
95
+ expect(isEffectivelyEmpty(content)).toBe(true);
96
+ });
97
+
98
+ test("returns false for content with real list items", () => {
99
+ expect(isEffectivelyEmpty("- Check Slack for messages")).toBe(false);
100
+ });
101
+
102
+ test("returns false for content with plain text paragraphs", () => {
103
+ expect(isEffectivelyEmpty("Review the task queue every hour")).toBe(false);
104
+ });
105
+
106
+ test("returns false for headers + real content", () => {
107
+ const content = `# Heartbeat Checklist
108
+ - Check if any tasks are stuck`;
109
+ expect(isEffectivelyEmpty(content)).toBe(false);
110
+ });
111
+ });
112
+
113
+ // ==========================================================================
114
+ // gatherSystemStatus()
115
+ // ==========================================================================
116
+
117
+ describe("gatherSystemStatus", () => {
118
+ test("returns markdown string", () => {
119
+ const status = gatherSystemStatus();
120
+ expect(typeof status).toBe("string");
121
+ expect(status.length).toBeGreaterThan(0);
122
+ });
123
+
124
+ test("includes task stats section with [auto-generated] label", () => {
125
+ const status = gatherSystemStatus();
126
+ expect(status).toContain("## Task Overview [auto-generated]");
127
+ });
128
+
129
+ test("includes agent status section with [auto-generated] label", () => {
130
+ const status = gatherSystemStatus();
131
+ expect(status).toContain("## Agent Status [auto-generated]");
132
+ });
133
+
134
+ test("handles empty DB gracefully", () => {
135
+ const status = gatherSystemStatus();
136
+ expect(status).toContain("In Progress: 0");
137
+ expect(status).toContain("Offline: 0");
138
+ });
139
+
140
+ test("reflects actual task and agent counts", () => {
141
+ const agent = createAgent({ name: "test-worker", isLead: false, status: "busy" });
142
+ createTaskExtended("Test task 1", { agentId: agent.id });
143
+ createTaskExtended("Test task 2");
144
+
145
+ const status = gatherSystemStatus();
146
+ // One task assigned (pending), one unassigned
147
+ expect(status).toContain("Pending: 1");
148
+ expect(status).toContain("Unassigned: 1");
149
+ expect(status).toContain("1 busy");
150
+ });
151
+
152
+ test("shows stalled tasks section when stalled tasks exist", () => {
153
+ const agent = createAgent({ name: "stall-worker", isLead: false, status: "busy" });
154
+ const task = createTaskExtended("Stalled task", { agentId: agent.id });
155
+ startTask(task.id);
156
+
157
+ // Make task stale (45 min)
158
+ const oldTime = new Date(Date.now() - 45 * 60 * 1000).toISOString();
159
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, task.id]);
160
+
161
+ const status = gatherSystemStatus();
162
+ expect(status).toContain("## Stalled Tasks [auto-generated]");
163
+ });
164
+ });
165
+
166
+ // ==========================================================================
167
+ // checkHeartbeatChecklist()
168
+ // ==========================================================================
169
+
170
+ describe("checkHeartbeatChecklist", () => {
171
+ test("skips when no lead agent registered", async () => {
172
+ createAgent({ name: "worker", isLead: false, status: "idle" });
173
+
174
+ await checkHeartbeatChecklist();
175
+
176
+ const tasks = getDb()
177
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
178
+ .all();
179
+ expect(tasks.length).toBe(0);
180
+ });
181
+
182
+ test("skips when heartbeatMd is NULL", async () => {
183
+ createAgent({ name: "lead", isLead: true, status: "idle" });
184
+
185
+ await checkHeartbeatChecklist();
186
+
187
+ const tasks = getDb()
188
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
189
+ .all();
190
+ expect(tasks.length).toBe(0);
191
+ });
192
+
193
+ test("skips when heartbeatMd is effectively empty (all comments/headers)", async () => {
194
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
195
+ updateAgentProfile(lead.id, {
196
+ heartbeatMd: "# Heartbeat Checklist\n\n<!-- No items yet -->\n",
197
+ });
198
+
199
+ await checkHeartbeatChecklist();
200
+
201
+ const tasks = getDb()
202
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
203
+ .all();
204
+ expect(tasks.length).toBe(0);
205
+ });
206
+
207
+ test("creates task when heartbeatMd has real content", async () => {
208
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
209
+ updateAgentProfile(lead.id, {
210
+ heartbeatMd: "# Heartbeat Checklist\n\n- Check if any tasks are stuck\n",
211
+ });
212
+
213
+ await checkHeartbeatChecklist();
214
+
215
+ const tasks = getDb()
216
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
217
+ .all() as Array<{ id: string; task: string; agentId: string; priority: number }>;
218
+ expect(tasks.length).toBe(1);
219
+ expect(tasks[0]!.agentId).toBe(lead.id);
220
+ expect(tasks[0]!.priority).toBe(60);
221
+ });
222
+
223
+ test("dedup: skips when active heartbeat-checklist task exists for lead", async () => {
224
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
225
+ updateAgentProfile(lead.id, {
226
+ heartbeatMd: "- Check tasks\n",
227
+ });
228
+
229
+ // First call — creates task
230
+ await checkHeartbeatChecklist();
231
+
232
+ // Second call — should skip (dedup)
233
+ await checkHeartbeatChecklist();
234
+
235
+ const tasks = getDb()
236
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
237
+ .all();
238
+ expect(tasks.length).toBe(1);
239
+ });
240
+
241
+ test("created task includes system status with [auto-generated] labels", async () => {
242
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
243
+ updateAgentProfile(lead.id, {
244
+ heartbeatMd: "- Review stalled tasks\n",
245
+ });
246
+
247
+ await checkHeartbeatChecklist();
248
+
249
+ const tasks = getDb()
250
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
251
+ .all() as Array<{ task: string }>;
252
+ expect(tasks.length).toBe(1);
253
+ expect(tasks[0]!.task).toContain("[auto-generated]");
254
+ expect(tasks[0]!.task).toContain("Task Overview");
255
+ });
256
+
257
+ test("created task includes HEARTBEAT.md content", async () => {
258
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
259
+ updateAgentProfile(lead.id, {
260
+ heartbeatMd: "- Check Slack for unaddressed requests\n- Review blocked tasks\n",
261
+ });
262
+
263
+ await checkHeartbeatChecklist();
264
+
265
+ const tasks = getDb()
266
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
267
+ .all() as Array<{ task: string }>;
268
+ expect(tasks.length).toBe(1);
269
+ expect(tasks[0]!.task).toContain("Check Slack for unaddressed requests");
270
+ expect(tasks[0]!.task).toContain("Review blocked tasks");
271
+ });
272
+
273
+ test("created task has correct tags", async () => {
274
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
275
+ updateAgentProfile(lead.id, {
276
+ heartbeatMd: "- Check tasks\n",
277
+ });
278
+
279
+ await checkHeartbeatChecklist();
280
+
281
+ const tasks = getDb()
282
+ .query("SELECT tags FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
283
+ .all() as Array<{ tags: string }>;
284
+ expect(tasks.length).toBe(1);
285
+ const tags = JSON.parse(tasks[0]!.tags);
286
+ expect(tags).toContain("checklist");
287
+ expect(tags).toContain("auto-generated");
288
+ // Must NOT contain "heartbeat" tag (would be filtered by default listing)
289
+ expect(tags).not.toContain("heartbeat");
290
+ });
291
+ });
292
+
293
+ // ==========================================================================
294
+ // createBootTriageTask()
295
+ // ==========================================================================
296
+
297
+ describe("createBootTriageTask", () => {
298
+ test("skips when no lead agent registered", async () => {
299
+ createAgent({ name: "worker", isLead: false, status: "idle" });
300
+
301
+ await createBootTriageTask();
302
+
303
+ const tasks = getDb().query("SELECT * FROM agent_tasks WHERE taskType = 'boot-triage'").all();
304
+ expect(tasks.length).toBe(0);
305
+ });
306
+
307
+ test("creates boot-triage task for lead", async () => {
308
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
309
+
310
+ await createBootTriageTask();
311
+
312
+ const tasks = getDb()
313
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'boot-triage'")
314
+ .all() as Array<{ id: string; agentId: string; priority: number; task: string }>;
315
+ expect(tasks.length).toBe(1);
316
+ expect(tasks[0]!.agentId).toBe(lead.id);
317
+ expect(tasks[0]!.priority).toBe(70);
318
+ });
319
+
320
+ test("boot-triage task includes reboot context", async () => {
321
+ createAgent({ name: "lead", isLead: true, status: "idle" });
322
+
323
+ await createBootTriageTask();
324
+
325
+ const tasks = getDb()
326
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'boot-triage'")
327
+ .all() as Array<{ task: string }>;
328
+ expect(tasks.length).toBe(1);
329
+ expect(tasks[0]!.task).toContain("Boot Triage");
330
+ expect(tasks[0]!.task).toContain("just restarted");
331
+ expect(tasks[0]!.task).toContain("Boot Event");
332
+ });
333
+
334
+ test("boot-triage task includes system status", async () => {
335
+ createAgent({ name: "lead", isLead: true, status: "idle" });
336
+
337
+ await createBootTriageTask();
338
+
339
+ const tasks = getDb()
340
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'boot-triage'")
341
+ .all() as Array<{ task: string }>;
342
+ expect(tasks[0]!.task).toContain("Task Overview [auto-generated]");
343
+ expect(tasks[0]!.task).toContain("Agent Status [auto-generated]");
344
+ });
345
+
346
+ test("shows fallback text when heartbeatMd is empty", async () => {
347
+ createAgent({ name: "lead", isLead: true, status: "idle" });
348
+
349
+ await createBootTriageTask();
350
+
351
+ const tasks = getDb()
352
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'boot-triage'")
353
+ .all() as Array<{ task: string }>;
354
+ expect(tasks[0]!.task).toContain("No standing orders configured");
355
+ });
356
+
357
+ test("includes heartbeatMd content when available", async () => {
358
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
359
+ updateAgentProfile(lead.id, {
360
+ heartbeatMd: "- Check Slack for unaddressed requests\n",
361
+ });
362
+
363
+ await createBootTriageTask();
364
+
365
+ const tasks = getDb()
366
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'boot-triage'")
367
+ .all() as Array<{ task: string }>;
368
+ expect(tasks[0]!.task).toContain("Check Slack for unaddressed requests");
369
+ });
370
+
371
+ test("dedup: skips when active boot-triage task exists", async () => {
372
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
373
+ updateAgentProfile(lead.id, {
374
+ heartbeatMd: "- Check tasks\n",
375
+ });
376
+
377
+ await createBootTriageTask();
378
+ await createBootTriageTask();
379
+
380
+ const tasks = getDb().query("SELECT * FROM agent_tasks WHERE taskType = 'boot-triage'").all();
381
+ expect(tasks.length).toBe(1);
382
+ });
383
+
384
+ test("boot-triage has correct tags", async () => {
385
+ createAgent({ name: "lead", isLead: true, status: "idle" });
386
+
387
+ await createBootTriageTask();
388
+
389
+ const tasks = getDb()
390
+ .query("SELECT tags FROM agent_tasks WHERE taskType = 'boot-triage'")
391
+ .all() as Array<{ tags: string }>;
392
+ const tags = JSON.parse(tasks[0]!.tags);
393
+ expect(tags).toContain("boot");
394
+ expect(tags).toContain("triage");
395
+ expect(tags).toContain("auto-generated");
396
+ });
397
+
398
+ test("boot-triage and heartbeat-checklist are independent (different taskTypes)", async () => {
399
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
400
+ updateAgentProfile(lead.id, {
401
+ heartbeatMd: "- Check tasks\n",
402
+ });
403
+
404
+ await createBootTriageTask();
405
+ await checkHeartbeatChecklist();
406
+
407
+ const bootTasks = getDb()
408
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'boot-triage'")
409
+ .all();
410
+ const checklistTasks = getDb()
411
+ .query("SELECT * FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
412
+ .all();
413
+ expect(bootTasks.length).toBe(1);
414
+ expect(checklistTasks.length).toBe(1);
415
+ });
416
+ });
417
+ });
@@ -18,7 +18,6 @@ import {
18
18
  import {
19
19
  codeLevelTriage,
20
20
  preflightGate,
21
- resetEscalationCooldowns,
22
21
  runHeartbeatSweep,
23
22
  startHeartbeat,
24
23
  stopHeartbeat,
@@ -53,7 +52,6 @@ describe("Heartbeat Triage", () => {
53
52
  getDb().run("DELETE FROM agent_tasks");
54
53
  getDb().run("DELETE FROM agents");
55
54
  getDb().run("DELETE FROM active_sessions");
56
- resetEscalationCooldowns();
57
55
  });
58
56
 
59
57
  // ==========================================================================
@@ -285,8 +283,6 @@ describe("Heartbeat Triage", () => {
285
283
  expect(findings.autoFailedTasks.length).toBe(0);
286
284
  expect(findings.stalledTasks.length).toBe(1);
287
285
  expect(findings.stalledTasks[0]!.id).toBe(task.id);
288
- expect(findings.escalationNeeded).toBe(true);
289
-
290
286
  // Task should NOT be failed
291
287
  const updated = getTaskById(task.id);
292
288
  expect(updated?.status).toBe("in_progress");
@@ -356,11 +352,11 @@ describe("Heartbeat Triage", () => {
356
352
  ).toBe(true);
357
353
  });
358
354
 
359
- test("no escalation when no stalled tasks", async () => {
355
+ test("no stalled tasks when workers are healthy", async () => {
360
356
  createAgent({ name: "healthy-worker", isLead: false, status: "idle" });
361
357
 
362
358
  const findings = await codeLevelTriage();
363
- expect(findings.escalationNeeded).toBe(false);
359
+ expect(findings.stalledTasks.length).toBe(0);
364
360
  });
365
361
 
366
362
  test("sets agent to idle after auto-failing its only task", async () => {
@@ -406,57 +402,6 @@ describe("Heartbeat Triage", () => {
406
402
  expect(tasks.length).toBe(1);
407
403
  });
408
404
 
409
- test("creates triage task for lead when stalled tasks found with fresh session", async () => {
410
- const lead = createAgent({ name: "triage-lead", isLead: true, status: "idle" });
411
- const worker = createAgent({ name: "stall-worker", isLead: false, status: "busy" });
412
- const task = createTaskExtended("Stalled task", { agentId: worker.id });
413
- startTask(task.id);
414
-
415
- // Create fresh active session (worker alive but task stale)
416
- insertActiveSession({
417
- agentId: worker.id,
418
- taskId: task.id,
419
- triggerType: "task_assigned",
420
- });
421
-
422
- // Make task stale (45 min)
423
- const oldTime = new Date(Date.now() - 45 * 60 * 1000).toISOString();
424
- getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, task.id]);
425
-
426
- await runHeartbeatSweep();
427
-
428
- // Verify triage task was created for lead
429
- const triageTasks = getDb()
430
- .query("SELECT * FROM agent_tasks WHERE taskType = 'heartbeat' AND agentId = ?")
431
- .all(lead.id) as Array<{ id: string; task: string }>;
432
- expect(triageTasks.length).toBe(1);
433
- expect(triageTasks[0]!.task).toContain("Stalled Tasks");
434
- });
435
-
436
- test("does not create duplicate triage tasks due to escalation cooldown", async () => {
437
- const lead = createAgent({ name: "triage-lead", isLead: true, status: "idle" });
438
- const worker = createAgent({ name: "stall-worker", isLead: false, status: "busy" });
439
- const task = createTaskExtended("Stalled task", { agentId: worker.id });
440
- startTask(task.id);
441
-
442
- insertActiveSession({
443
- agentId: worker.id,
444
- taskId: task.id,
445
- triggerType: "task_assigned",
446
- });
447
-
448
- const oldTime = new Date(Date.now() - 45 * 60 * 1000).toISOString();
449
- getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, task.id]);
450
-
451
- await runHeartbeatSweep();
452
- await runHeartbeatSweep();
453
-
454
- const triageTasks = getDb()
455
- .query("SELECT id FROM agent_tasks WHERE taskType = 'heartbeat' AND agentId = ?")
456
- .all(lead.id) as Array<{ id: string }>;
457
- expect(triageTasks.length).toBe(1);
458
- });
459
-
460
405
  test("auto-fails stalled task with no session during sweep", async () => {
461
406
  const worker = createAgent({ name: "dead-worker", isLead: false, status: "busy" });
462
407
  const task = createTaskExtended("Stalled no-session", { agentId: worker.id });
@@ -99,7 +99,7 @@ describe("template registration — all sources", () => {
99
99
  test("Heartbeat template is registered (1 event)", () => {
100
100
  const all = getAllTemplateDefinitions();
101
101
  const eventTypes = all.map((d) => d.eventType);
102
- expect(eventTypes).toContain("heartbeat.escalation.stalled");
102
+ expect(eventTypes).toContain("heartbeat.checklist");
103
103
  });
104
104
 
105
105
  test("Task lifecycle templates are registered (2 task_lifecycle)", () => {
@@ -67,6 +67,13 @@ export const registerUpdateProfileTool = (server: McpServer) => {
67
67
  .describe(
68
68
  "Environment-specific operational knowledge. Repos, services, SSH hosts, APIs, device names — anything specific to your setup. Synced to /workspace/TOOLS.md.",
69
69
  ),
70
+ heartbeatMd: z
71
+ .string()
72
+ .max(65536)
73
+ .optional()
74
+ .describe(
75
+ "Heartbeat checklist content (HEARTBEAT.md). Checked periodically — add standing orders for the lead to review. Synced to /workspace/HEARTBEAT.md.",
76
+ ),
70
77
  }),
71
78
  outputSchema: z.object({
72
79
  yourAgentId: z.string().uuid().optional(),
@@ -87,6 +94,7 @@ export const registerUpdateProfileTool = (server: McpServer) => {
87
94
  identityMd,
88
95
  setupScript,
89
96
  toolsMd,
97
+ heartbeatMd,
90
98
  },
91
99
  requestInfo,
92
100
  _meta,
@@ -159,20 +167,21 @@ export const registerUpdateProfileTool = (server: McpServer) => {
159
167
  soulMd === undefined &&
160
168
  identityMd === undefined &&
161
169
  setupScript === undefined &&
162
- toolsMd === undefined
170
+ toolsMd === undefined &&
171
+ heartbeatMd === undefined
163
172
  ) {
164
173
  return {
165
174
  content: [
166
175
  {
167
176
  type: "text",
168
- text: "At least one field (name, description, role, capabilities, claudeMd, soulMd, identityMd, setupScript, or toolsMd) must be provided.",
177
+ text: "At least one field (name, description, role, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, or heartbeatMd) must be provided.",
169
178
  },
170
179
  ],
171
180
  structuredContent: {
172
181
  yourAgentId: requestInfo.agentId,
173
182
  success: false,
174
183
  message:
175
- "At least one field (name, description, role, capabilities, claudeMd, soulMd, identityMd, setupScript, or toolsMd) must be provided.",
184
+ "At least one field (name, description, role, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, or heartbeatMd) must be provided.",
176
185
  },
177
186
  };
178
187
  }
@@ -207,6 +216,7 @@ export const registerUpdateProfileTool = (server: McpServer) => {
207
216
  identityMd,
208
217
  setupScript,
209
218
  toolsMd,
219
+ heartbeatMd,
210
220
  },
211
221
  {
212
222
  changeSource: isUpdatingSelf ? "self_edit" : "lead_coaching",
@@ -245,6 +255,13 @@ export const registerUpdateProfileTool = (server: McpServer) => {
245
255
  /* ignore */
246
256
  }
247
257
  }
258
+ if (heartbeatMd !== undefined) {
259
+ try {
260
+ await Bun.write("/workspace/HEARTBEAT.md", heartbeatMd);
261
+ } catch {
262
+ /* ignore */
263
+ }
264
+ }
248
265
  }
249
266
 
250
267
  if (!agent) {
@@ -268,6 +285,7 @@ export const registerUpdateProfileTool = (server: McpServer) => {
268
285
  if (identityMd !== undefined) updatedFields.push("identityMd");
269
286
  if (setupScript !== undefined) updatedFields.push("setupScript");
270
287
  if (toolsMd !== undefined) updatedFields.push("toolsMd");
288
+ if (heartbeatMd !== undefined) updatedFields.push("heartbeatMd");
271
289
 
272
290
  const targetLabel = isUpdatingSelf ? "own" : `agent ${targetAgentId}`;
273
291
  return {