@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.
- package/package.json +1 -1
- package/src/be/db.ts +9 -1
- package/src/be/migrations/025_workflow_run_cancelled_status.sql +0 -2
- package/src/be/migrations/027_heartbeat_md.sql +1 -0
- package/src/be/migrations/runner.ts +8 -0
- package/src/commands/runner.ts +24 -1
- package/src/heartbeat/heartbeat.ts +201 -92
- package/src/heartbeat/templates.ts +64 -9
- package/src/http/agents.ts +5 -2
- package/src/http/heartbeat.ts +29 -1
- package/src/tests/heartbeat-checklist.test.ts +417 -0
- package/src/tests/heartbeat.test.ts +2 -57
- package/src/tests/prompt-template-remaining.test.ts +1 -1
- package/src/tools/update-profile.ts +21 -3
- package/src/types.ts +3 -0
- package/templates/official/lead/CLAUDE.md +3 -0
- package/templates/official/lead/HEARTBEAT.md +12 -0
- package/templates/official/lead/config.json +2 -1
- package/templates/schema.ts +2 -0
package/src/http/agents.ts
CHANGED
|
@@ -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
|
|
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
|
);
|
package/src/http/heartbeat.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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 {
|