@desplega.ai/agent-swarm 2.2.0 → 2.3.0

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.
Files changed (107) hide show
  1. package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-v85a2bqy.js} +1 -1
  2. package/dist/{actions-w4pnb8vz.js → actions-zgcxfspj.js} +15 -15
  3. package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-jw9xfz42.js} +2 -2
  4. package/dist/{app-zneem7g9.js → app-4e1s4675.js} +3 -3
  5. package/dist/{assistant-wbzd2rnr.js → assistant-v238n9dk.js} +16 -16
  6. package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-p666cw5n.js} +1 -1
  7. package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-w6pm64j4.js} +1 -1
  8. package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-jcyjagn3.js} +2 -2
  9. package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-gbedmamp.js} +4 -4
  10. package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-q7wspzt8.js} +2 -2
  11. package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-wza784fb.js} +1 -1
  12. package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-yf400ptr.js} +2 -2
  13. package/dist/{cli-p46xdr1m.js → cli-0rh20bp3.js} +1 -1
  14. package/dist/{cli-e5y285rg.js → cli-1v8w782f.js} +1 -1
  15. package/dist/{cli-8c1m9mhy.js → cli-30m5q8mc.js} +5 -5
  16. package/dist/{cli-xnp2vkx7.js → cli-4hmaheb6.js} +6 -6
  17. package/dist/{cli-xhgtay7w.js → cli-4s1kqrqg.js} +1 -1
  18. package/dist/{cli-2jm992rm.js → cli-5d1m1qca.js} +2 -2
  19. package/dist/{cli-mvsfbbgq.js → cli-65q70v2v.js} +3 -3
  20. package/dist/{cli-th8krqbf.js → cli-6p7ddg72.js} +2 -2
  21. package/dist/{cli-6q7pyd3k.js → cli-6vcyw6v7.js} +1 -1
  22. package/dist/{cli-bddxamg7.js → cli-8qsx817b.js} +23 -22
  23. package/dist/{cli-ztcxv5nv.js → cli-9e9kjqsj.js} +1 -1
  24. package/dist/{cli-jrk8mj0t.js → cli-a17tzmn0.js} +1 -1
  25. package/dist/{cli-raejy0mp.js → cli-d43xqzbe.js} +1 -1
  26. package/dist/{cli-tba8fxf9.js → cli-dxwh4j32.js} +11 -11
  27. package/dist/{cli-c9vrwcpb.js → cli-ea7chd26.js} +19 -19
  28. package/dist/{cli-vmbakbq0.js → cli-fhvqt2fp.js} +1 -1
  29. package/dist/{cli-z3dk189k.js → cli-fp4xbw07.js} +6 -6
  30. package/dist/{cli-xx13gqf3.js → cli-g6p7c2my.js} +1 -1
  31. package/dist/{cli-c0dpzwnx.js → cli-hn3pftj2.js} +2 -2
  32. package/dist/{cli-h23mr2s6.js → cli-hz64bcnj.js} +2 -2
  33. package/dist/{cli-dw0njd9q.js → cli-jhmjwqcg.js} +5 -5
  34. package/dist/{cli-0k9rcwfy.js → cli-ka01m5rb.js} +28 -12
  35. package/dist/{cli-jhhb2v9n.js → cli-kqypty34.js} +2 -2
  36. package/dist/{cli-hwc4a9xm.js → cli-q1812jbw.js} +4 -4
  37. package/dist/{cli-8w65vwee.js → cli-qrwp40st.js} +1 -1
  38. package/dist/{cli-4c7k75rq.js → cli-re4wkbzx.js} +54 -41
  39. package/dist/{cli-z1hh99j0.js → cli-tr24eeny.js} +2 -2
  40. package/dist/{cli-q6gw45wj.js → cli-wtg6edvd.js} +1 -1
  41. package/dist/{cli-mnry16kh.js → cli-xgn4rwyv.js} +4 -4
  42. package/dist/{cli-5ks6x36q.js → cli-y3dvz6ge.js} +3 -3
  43. package/dist/{cli-ksrphveb.js → cli-yv0vgscc.js} +1 -0
  44. package/dist/{cli-ak4c12mh.js → cli-yv3n83x8.js} +1 -1
  45. package/dist/{cli-9ac0mkg5.js → cli-zes4tsbb.js} +1 -1
  46. package/dist/cli.js +14 -14
  47. package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-nhjj2veb.js} +1 -1
  48. package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-6hggrjbx.js} +1 -1
  49. package/dist/{commands-ktnqwrv4.js → commands-mrw1ph2w.js} +2 -2
  50. package/dist/{controller-nex2he8v.js → controller-pt1zam5w.js} +2 -2
  51. package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-ymw5541d.js} +16 -16
  52. package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-cf4zh5x0.js} +1 -1
  53. package/dist/{db-19tw9r5s.js → db-hhpy6m4x.js} +2 -2
  54. package/dist/{db-jvr86v7f.js → db-hy80219q.js} +1 -1
  55. package/dist/{dispatcher-d3mnsg2y.js → dispatcher-6pdjtkr1.js} +14 -14
  56. package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-fbx06re2.js} +1 -1
  57. package/dist/{external-channel-44cwer32.js → external-channel-j6vb6ya1.js} +2 -2
  58. package/dist/{grok-adapter-ysp907f6.js → grok-adapter-1193sprp.js} +2 -2
  59. package/dist/{handlers-t5pbabwf.js → handlers-tb3xgjnm.js} +17 -17
  60. package/dist/{heartbeat-2etacsqq.js → heartbeat-h1w1ktce.js} +17 -17
  61. package/dist/{hook-3kvmax6d.js → hook-ejja4bpe.js} +1 -1
  62. package/dist/{hook-mzxxs5n2.js → hook-skrbja18.js} +1 -1
  63. package/dist/{http-fn978m0g.js → http-mzh41mfj.js} +108 -52
  64. package/dist/{index-039x2sag.js → index-6rmnn89d.js} +40 -12
  65. package/dist/{index-w2wr89xd.js → index-epr2gnbw.js} +1 -1
  66. package/dist/{index-qyjbb7br.js → index-nyfcdpky.js} +17 -17
  67. package/dist/{index-e4tsa1h7.js → index-vjn3wbzf.js} +14 -14
  68. package/dist/{index-1r8hw4vd.js → index-xmmkyx82.js} +14 -14
  69. package/dist/{keepalive-d4nzs76x.js → keepalive-4djvbm2j.js} +3 -3
  70. package/dist/{lead-8h5n9k5f.js → lead-61kqp604.js} +23 -23
  71. package/dist/{maintenance-ea6jfw42.js → maintenance-f3xhnjsm.js} +1 -1
  72. package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-fpd6x6rw.js} +2 -2
  73. package/dist/{onboard-pvk7hdb2.js → onboard-196tjgyn.js} +2 -2
  74. package/dist/{otel-impl-qb7nhgdp.js → otel-impl-g8fh4c21.js} +1 -1
  75. package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-sdcf5yaz.js} +3 -3
  76. package/dist/{rbac-roles-9648nh00.js → rbac-roles-2x4g1wb5.js} +2 -2
  77. package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-bsp5657v.js} +1 -1
  78. package/dist/{render-v2-xkze1wef.js → render-v2-0pyb39bb.js} +3 -3
  79. package/dist/{resume-tq0bbvt5.js → resume-a86tkj6r.js} +5 -5
  80. package/dist/{setup-rnrazxvd.js → setup-ypbzcapd.js} +1 -1
  81. package/dist/{work-objects-e75q2rqs.js → work-objects-yjp8de12.js} +17 -17
  82. package/dist/{worker-4ww4hjef.js → worker-4ptnn1ge.js} +23 -23
  83. package/openapi.json +168 -6
  84. package/package.json +2 -2
  85. package/src/be/asset-key-audit.ts +4 -13
  86. package/src/be/db.ts +57 -33
  87. package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
  88. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
  89. package/src/heartbeat/heartbeat.ts +25 -7
  90. package/src/http/assets.ts +26 -4
  91. package/src/http/inbox-state.ts +58 -8
  92. package/src/kv-reserved-namespaces.ts +1 -0
  93. package/src/providers/amp-adapter.ts +1 -1
  94. package/src/tests/asset-key-api.test.ts +128 -0
  95. package/src/tests/asset-key-invariants.test.ts +179 -3
  96. package/src/tests/asset-key-migration.test.ts +35 -0
  97. package/src/tests/docs-index-footprint.test.js +2 -2
  98. package/src/tests/heartbeat-checklist.test.ts +88 -0
  99. package/src/tests/heartbeat-jev-gate.test.ts +394 -10
  100. package/src/tests/inbox-state.test.ts +167 -0
  101. package/src/tests/kv-tool.test.ts +15 -0
  102. package/src/tests/script-connections.test.ts +2 -0
  103. package/src/types.ts +2 -0
  104. package/templates/skills/code-quality/content.md +2 -2
  105. package/templates/skills/pages/content.md +4 -0
  106. package/templates/skills/swarm-scripts/SKILL.md +6 -0
  107. package/templates/skills/swarm-scripts/content.md +6 -0
@@ -4,6 +4,7 @@ import { closeDb, createTaskExtended, createUser, getDb, getDbClient, initDb } f
4
4
  const FRESH_DB = "./test-asset-key-migration-fresh.sqlite";
5
5
  const HISTORICAL_DB = "./test-asset-key-migration-historical.sqlite";
6
6
  const HISTORY_REBUILD_DB = "./test-asset-key-migration-history-rebuild.sqlite";
7
+ const INBOX_UPGRADE_DB = "./test-asset-key-migration-inbox-upgrade.sqlite";
7
8
  const LEGACY_STATUS_DB = "./test-asset-key-migration-legacy-status.sqlite";
8
9
 
9
10
  const globals = globalThis as typeof globalThis & {
@@ -97,6 +98,7 @@ beforeAll(async () => {
97
98
  await removeDb(HISTORICAL_DB);
98
99
  await removeDb(HISTORY_REBUILD_DB);
99
100
  await removeDb(LEGACY_STATUS_DB);
101
+ await removeDb(INBOX_UPGRADE_DB);
100
102
  initDb(FRESH_DB);
101
103
  });
102
104
 
@@ -108,6 +110,7 @@ afterAll(async () => {
108
110
  await removeDb(HISTORICAL_DB);
109
111
  await removeDb(HISTORY_REBUILD_DB);
110
112
  await removeDb(LEGACY_STATUS_DB);
113
+ await removeDb(INBOX_UPGRADE_DB);
111
114
  });
112
115
 
113
116
  describe("asset namespace key migrations", () => {
@@ -173,6 +176,38 @@ describe("asset namespace key migrations", () => {
173
176
  expect(historySchema).toContain("'script'");
174
177
  });
175
178
 
179
+ test("inbox read cursor is nullable on fresh databases and has no backfill on upgrade", async () => {
180
+ const column = await getDbClient().get<{ notnull: number; dflt_value: string | null }>(
181
+ "SELECT \"notnull\", dflt_value FROM pragma_table_info('inbox_item_state') WHERE name = 'lastReadAt'",
182
+ );
183
+ expect(column).toEqual({ notnull: 0, dflt_value: null });
184
+ closeDb();
185
+ initDb(INBOX_UPGRADE_DB);
186
+ try {
187
+ const user = await createUser({ name: "Upgrade User", email: "inbox-upgrade@example.com" });
188
+ const readAt = "2026-01-01T00:00:00.000Z";
189
+ await getDbClient().run(
190
+ `INSERT INTO inbox_item_state (userId, itemType, itemId, status, readAt)
191
+ VALUES (?, 'to_read', 'existing-item', 'done', ?)`,
192
+ [user.id, readAt],
193
+ );
194
+ await getDbClient().run("ALTER TABLE inbox_item_state DROP COLUMN lastReadAt");
195
+ await getDbClient().run("DELETE FROM _migrations WHERE version = 227");
196
+ closeDb();
197
+ initDb(INBOX_UPGRADE_DB);
198
+ expect(
199
+ await getDbClient().get(
200
+ "SELECT status, readAt, lastReadAt FROM inbox_item_state WHERE itemId = 'existing-item'",
201
+ ),
202
+ ).toEqual({ status: "done", readAt, lastReadAt: null });
203
+ closeDb();
204
+ expect(() => initDb(INBOX_UPGRADE_DB)).not.toThrow();
205
+ } finally {
206
+ closeDb();
207
+ initDb(FRESH_DB);
208
+ }
209
+ });
210
+
176
211
  test("old insert statements may omit key and repeated shared namespaces are valid", () => {
177
212
  const now = new Date().toISOString();
178
213
  const taskId = crypto.randomUUID();
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(430);
107
- expect(generatedKeys).toHaveLength(430);
106
+ expect(operationKeys).toHaveLength(431);
107
+ expect(generatedKeys).toHaveLength(431);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -13,10 +13,14 @@ import {
13
13
  closeDb,
14
14
  createAgent,
15
15
  createTaskExtended,
16
+ deletePromptTemplate,
16
17
  getDbClient,
18
+ getKv,
19
+ getRecentFailedTasks,
17
20
  initDb,
18
21
  startTask,
19
22
  updateAgentProfile,
23
+ upsertPromptTemplate,
20
24
  upsertSwarmConfig,
21
25
  } from "../be/db";
22
26
  import { getScript, upsertScriptByName } from "../be/scripts/db";
@@ -240,6 +244,89 @@ describe("Heartbeat Checklist", () => {
240
244
  expect(tasks[0]!.priority).toBe(60);
241
245
  });
242
246
 
247
+ test("receipt stores exactly the ten rendered full IDs from twelve failures", async () => {
248
+ const lead = await createAgent({ name: "lead", isLead: true, status: "idle" });
249
+ await updateAgentProfile(lead.id, { heartbeatMd: "- Check failures" });
250
+ for (let i = 0; i < 12; i++) {
251
+ const task = await createTaskExtended(`receipt failure ${i}`, { agentId: lead.id });
252
+ await getDbClient().run(
253
+ "UPDATE agent_tasks SET status = 'failed', failureReason = ?, finishedAt = ? WHERE id = ?",
254
+ [`receipt reason ${i}`, new Date(Date.now() - (i + 1) * 60e3).toISOString(), task.id],
255
+ );
256
+ }
257
+ const failures = await getRecentFailedTasks(6);
258
+ const before = Date.now();
259
+ await checkHeartbeatChecklist();
260
+ const checklist = await getDbClient().get<{ id: string; task: string }>(
261
+ "SELECT id, task FROM agent_tasks WHERE taskType = 'heartbeat-checklist'",
262
+ );
263
+ expect(checklist).toBeDefined();
264
+ const receipt = await getKv("heartbeat-checklist:reported-failures", checklist!.id);
265
+ expect(receipt?.value).toEqual(failures.slice(0, 10).map((task) => task.id));
266
+ expect(receipt?.expiresAt).toBeGreaterThanOrEqual(before + 7 * 24 * 3600e3);
267
+ expect(receipt?.expiresAt).toBeLessThanOrEqual(Date.now() + 7 * 24 * 3600e3);
268
+ for (const failure of failures.slice(0, 10)) {
269
+ expect(checklist!.task).toContain(`[${failure.id.slice(0, 8)}]`);
270
+ }
271
+ for (const failure of failures.slice(10)) {
272
+ expect(checklist!.task).not.toContain(`[${failure.id.slice(0, 8)}]`);
273
+ }
274
+ });
275
+
276
+ test("receipt excludes failures when the global checklist template omits system status", async () => {
277
+ const lead = await createAgent({ name: "lead", isLead: true, status: "idle" });
278
+ await updateAgentProfile(lead.id, { heartbeatMd: "- Check standing orders" });
279
+ const failure = await createTaskExtended("Unrendered failure", { agentId: lead.id });
280
+ await getDbClient().run(
281
+ "UPDATE agent_tasks SET status = 'failed', failureReason = ?, finishedAt = ? WHERE id = ?",
282
+ ["boom", new Date(Date.now() - 1000).toISOString(), failure.id],
283
+ );
284
+ const template = upsertPromptTemplate({
285
+ eventType: "heartbeat.checklist",
286
+ scope: "global",
287
+ state: "enabled",
288
+ body: "Review standing orders:\n{{heartbeat_content}}",
289
+ });
290
+ try {
291
+ await checkHeartbeatChecklist();
292
+ const checklist = await getDbClient().get<{ id: string; task: string }>(
293
+ "SELECT id, task FROM agent_tasks WHERE taskType = 'heartbeat-checklist'",
294
+ );
295
+ expect(checklist?.task).toBe("Review standing orders:\n- Check standing orders");
296
+ expect(
297
+ (await getKv("heartbeat-checklist:reported-failures", checklist!.id))?.value,
298
+ ).toEqual([]);
299
+ } finally {
300
+ await deletePromptTemplate(template.id);
301
+ }
302
+ });
303
+
304
+ test("receipt write errors log and preserve the created checklist", async () => {
305
+ const lead = await createAgent({ name: "lead", isLead: true, status: "idle" });
306
+ await updateAgentProfile(lead.id, { heartbeatMd: "- Check failures" });
307
+ await getDbClient().run(`CREATE TRIGGER reject_heartbeat_receipt
308
+ BEFORE INSERT ON kv_entries
309
+ WHEN NEW.namespace = 'heartbeat-checklist:reported-failures'
310
+ BEGIN SELECT RAISE(FAIL, 'receipt unavailable'); END`);
311
+ const log = spyOn(console, "error").mockImplementation(() => {});
312
+ try {
313
+ await checkHeartbeatChecklist();
314
+ const checklist = await getDbClient().get<{ id: string }>(
315
+ "SELECT id FROM agent_tasks WHERE taskType = 'heartbeat-checklist'",
316
+ );
317
+ expect(checklist).toBeDefined();
318
+ expect(log).toHaveBeenCalledWith(
319
+ expect.stringContaining(
320
+ `Failed to save reported failures for checklist ${checklist!.id}`,
321
+ ),
322
+ expect.any(Error),
323
+ );
324
+ } finally {
325
+ log.mockRestore();
326
+ await getDbClient().run("DROP TRIGGER reject_heartbeat_receipt");
327
+ }
328
+ });
329
+
243
330
  test("dedup: skips when active heartbeat-checklist task exists for lead", async () => {
244
331
  const lead = await createAgent({ name: "lead", isLead: true, status: "idle" });
245
332
  await updateAgentProfile(lead.id, {
@@ -725,6 +812,7 @@ describe("Heartbeat Checklist", () => {
725
812
  expect(tasks.length).toBe(1);
726
813
  expect(tasks[0]!.agentId).toBe(lead.id);
727
814
  expect(tasks[0]!.priority).toBe(70);
815
+ expect(await getKv("heartbeat-checklist:reported-failures", tasks[0]!.id)).toBeNull();
728
816
  });
729
817
 
730
818
  test("boot-triage task includes reboot context", async () => {
@@ -1,3 +1,4 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import {
@@ -19,7 +20,9 @@ type Fixture = {
19
20
  configs: Record<string, string>;
20
21
  heartbeatMd?: string;
21
22
  prevStatus?: string;
23
+ reportedFailures?: unknown;
22
24
  prevOutput?: string | null;
25
+ prevCreatedAt?: string | null;
23
26
  failed?: Array<Record<string, unknown>>;
24
27
  approvalStatus?: string;
25
28
  /** Stands in for `ctx.swarm.systemOne_decide`; returns the bridge result. */
@@ -33,6 +36,7 @@ function fakeCtx(f: Fixture) {
33
36
  kvSets: 0,
34
37
  decides: [] as Array<Record<string, any>>,
35
38
  approvalReads: 0,
39
+ previousReads: 0,
36
40
  };
37
41
  const table = (columns: string[], rows: unknown[][]) => ({
38
42
  success: true,
@@ -56,17 +60,48 @@ function fakeCtx(f: Fixture) {
56
60
  },
57
61
  };
58
62
  },
59
- db_query: async ({ sql }: { sql: string }) => {
63
+ db_query: async ({ sql, params = [] }: { sql: string; params?: any[] }) => {
60
64
  if (sql.includes("FROM approval_requests")) {
61
65
  calls.approvalReads++;
62
66
  return table(["status"], f.approvalStatus ? [[f.approvalStatus]] : []);
63
67
  }
64
68
  if (sql.includes("heartbeatMd")) return table(["heartbeatMd"], [[f.heartbeatMd ?? ""]]);
65
- if (sql.includes("'heartbeat-checklist'")) {
66
- return table(
67
- ["id", "output", "status"],
68
- [["prev-id", f.prevOutput ?? null, f.prevStatus ?? ""]],
69
- );
69
+ if (sql.includes("'heartbeat-checklist'") || sql.includes("finishedAt <= ?")) {
70
+ if (sql.includes("'heartbeat-checklist'")) calls.previousReads++;
71
+ // Execute historical selection against real rows, including its bounds and limit.
72
+ const db = new Database(":memory:");
73
+ try {
74
+ db.run(`CREATE TABLE agent_tasks (
75
+ id TEXT, output TEXT, task TEXT, agentId TEXT, taskType TEXT,
76
+ status TEXT, createdAt TEXT, finishedAt TEXT
77
+ )`);
78
+ if (f.prevCreatedAt !== null)
79
+ db.run("INSERT INTO agent_tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
80
+ "prev-id",
81
+ f.prevOutput ?? null,
82
+ f.prevStatus ?? "",
83
+ LEAD,
84
+ "heartbeat-checklist",
85
+ "completed",
86
+ f.prevCreatedAt ?? "2026-10-15T13:00:00.000Z",
87
+ null,
88
+ ]);
89
+ for (const failure of f.failed ?? []) {
90
+ db.run("INSERT INTO agent_tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
91
+ failure.id,
92
+ null,
93
+ failure.task,
94
+ failure.agentId,
95
+ "test",
96
+ "failed",
97
+ "2026-10-15T06:00:00.000Z",
98
+ failure.finishedAt,
99
+ ]);
100
+ }
101
+ return { data: { rows: db.query(sql).all(...params) } };
102
+ } finally {
103
+ db.close();
104
+ }
70
105
  }
71
106
  if (sql.includes("completed24")) {
72
107
  return table(
@@ -75,7 +110,10 @@ function fakeCtx(f: Fixture) {
75
110
  );
76
111
  }
77
112
  if (sql.includes("status = 'failed' AND finishedAt")) {
78
- const rows = f.failed ?? [];
113
+ const rows = (f.failed ?? [])
114
+ .filter((r) => String(r.finishedAt) > params[0])
115
+ .sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt)))
116
+ .slice(0, 10);
79
117
  return table(
80
118
  ["id", "agentId", "task", "failureReason", "finishedAt"],
81
119
  rows.map((r) => [r.id, r.agentId, r.task, r.failureReason, r.finishedAt]),
@@ -93,8 +131,13 @@ function fakeCtx(f: Fixture) {
93
131
  }
94
132
  throw new Error(`unexpected sql: ${sql}`);
95
133
  },
96
- kv_getOrNull: async ({ key }: { key: string }) =>
97
- kv.has(key) ? { key, value: kv.get(key) } : null,
134
+ kv_getOrNull: async ({ namespace, key }: { namespace: string; key: string }) => {
135
+ if (namespace === "heartbeat-checklist:reported-failures") {
136
+ expect(key).toBe("prev-id");
137
+ return f.reportedFailures === undefined ? null : { key, value: f.reportedFailures };
138
+ }
139
+ return kv.has(key) ? { key, value: kv.get(key) } : null;
140
+ },
98
141
  kv_set: async ({ key, value }: { key: string; value: unknown }) => {
99
142
  calls.kvSets++;
100
143
  kv.set(key, value);
@@ -168,6 +211,7 @@ describe("heartbeat-jev-gate with a provider", () => {
168
211
  test("wake at or above the threshold, with the new failures and due items in summary", async () => {
169
212
  const { ctx } = fakeCtx({
170
213
  configs: { TYPESAFE_API_KEY: "x" },
214
+ reportedFailures: ["aaaaaaaa-0000"],
171
215
  heartbeatMd: [
172
216
  "# Standing orders",
173
217
  `- Watch: release on ${MMDD} — lift after ship`,
@@ -176,7 +220,7 @@ describe("heartbeat-jev-gate with a provider", () => {
176
220
  ].join("\n"),
177
221
  // The previous checklist already listed aaaaaaaa, so only bbbbbbbb is new.
178
222
  prevStatus:
179
- '## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
223
+ '## Current System Status [auto-generated]\n## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
180
224
  prevOutput: "All clear.\n\nNothing to do.",
181
225
  failed: [
182
226
  {
@@ -217,6 +261,346 @@ describe("heartbeat-jev-gate with a provider", () => {
217
261
  });
218
262
  });
219
263
 
264
+ for (const ordersFirst of [true, false]) {
265
+ test(`previous failures are not new with standing orders ${ordersFirst ? "before" : "after"} status`, async () => {
266
+ const failures =
267
+ '## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n';
268
+ const orders = "## Your Standing Orders\n- Already triaged.\n";
269
+ const status = `## Current System Status [auto-generated]\n${failures}`;
270
+ const { ctx } = fakeCtx({
271
+ configs: { TYPESAFE_API_KEY: "x" },
272
+ reportedFailures: ["aaaaaaaa-0000"],
273
+ prevStatus: ordersFirst ? orders + status : status + orders,
274
+ failed: [
275
+ {
276
+ id: "aaaaaaaa-0000",
277
+ agentId: "12345678-bbbb",
278
+ task: "old failure",
279
+ failureReason: "boom",
280
+ finishedAt: "2026-10-15T12:00:00Z",
281
+ },
282
+ {
283
+ id: "bbbbbbbb-0000",
284
+ agentId: "12345678-bbbb",
285
+ task: "new failure",
286
+ failureReason: "exit 1",
287
+ finishedAt: "2026-10-15T13:01:00.000Z",
288
+ },
289
+ ],
290
+ jev: jevSays(0.9),
291
+ });
292
+ const verdict = await run(ctx, { dryRun: true });
293
+ expect(verdict.state.new_failures_since_previous_heartbeat).toEqual([
294
+ "bbbbbbbb: new failure | exit 1",
295
+ ]);
296
+ expect(verdict.state.failures_last_6h_total).toBe(2);
297
+ });
298
+ }
299
+
300
+ test.each([
301
+ "## Recent Failures (last 6h) [auto-generated]\nNone.\n",
302
+ "",
303
+ ])("failures outside the previous Recent Failures section stay new (%s)", async (section) => {
304
+ const { ctx } = fakeCtx({
305
+ configs: { TYPESAFE_API_KEY: "x" },
306
+ prevStatus: `## Current System Status [auto-generated]\n${section}## Gate Findings [auto-generated]\n- [aaaaaaaa] "quoted failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n`,
307
+ failed: [
308
+ {
309
+ id: "aaaaaaaa-0000",
310
+ agentId: "12345678-bbbb",
311
+ task: "quoted failure",
312
+ failureReason: "boom",
313
+ finishedAt: "2026-10-15T13:01:00.000Z",
314
+ },
315
+ ],
316
+ jev: jevSays(0.9),
317
+ });
318
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
319
+ "aaaaaaaa: quoted failure | boom",
320
+ ]);
321
+ });
322
+
323
+ test.each([
324
+ "- Item 544: fix `## Recent Failures` parsing.\n",
325
+ "## Recent Failures\nSnapshot notes.\n",
326
+ '## Recent Failures (last 6h) [auto-generated]\n- [bbbbbbbb] "snapshot quote" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
327
+ ])("snapshot failure headings cannot hijack generated status (%s)", async (snapshot) => {
328
+ const { ctx, calls } = fakeCtx({
329
+ configs: { TYPESAFE_API_KEY: "x" },
330
+ reportedFailures: ["aaaaaaaa-0000"],
331
+ prevStatus: [
332
+ "## Your Standing Orders (snapshot from HEARTBEAT.md)",
333
+ snapshot,
334
+ "## Instructions",
335
+ "Review the generated status.",
336
+ "## Current System Status [auto-generated]",
337
+ "## Recent Failures (last 6h) [auto-generated]",
338
+ '- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
339
+ ].join("\n"),
340
+ failed: [
341
+ {
342
+ id: "aaaaaaaa-0000",
343
+ agentId: "12345678-bbbb",
344
+ task: "old failure",
345
+ failureReason: "boom",
346
+ finishedAt: "2026-10-15T12:00:00Z",
347
+ },
348
+ {
349
+ id: "bbbbbbbb-0000",
350
+ agentId: "12345678-bbbb",
351
+ task: "snapshot quote",
352
+ failureReason: "boom",
353
+ finishedAt: "2026-10-15T13:01:00.000Z",
354
+ },
355
+ ],
356
+ jev: jevSays(0.9),
357
+ });
358
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
359
+ "bbbbbbbb: snapshot quote | boom",
360
+ ]);
361
+ expect(calls.previousReads).toBe(1);
362
+ });
363
+
364
+ test("an absent generated failures section cannot use a trailing snapshot heading", async () => {
365
+ const { ctx, calls } = fakeCtx({
366
+ configs: { TYPESAFE_API_KEY: "x" },
367
+ prevStatus: [
368
+ "## Current System Status [auto-generated]",
369
+ "## Task Overview [auto-generated]",
370
+ "- Failed (24h): 0",
371
+ "## Agent Status [auto-generated]",
372
+ "- Online: 6",
373
+ "## Your Standing Orders (snapshot from HEARTBEAT.md)",
374
+ "## Recent Failures (last 6h) [auto-generated]",
375
+ '- [aaaaaaaa] "snapshot quote" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
376
+ ].join("\n"),
377
+ failed: [
378
+ {
379
+ id: "aaaaaaaa-0000",
380
+ agentId: "12345678-bbbb",
381
+ task: "snapshot quote",
382
+ failureReason: "boom",
383
+ finishedAt: "2026-10-15T13:01:00.000Z",
384
+ },
385
+ ],
386
+ jev: jevSays(0.9),
387
+ });
388
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
389
+ "aaaaaaaa: snapshot quote | boom",
390
+ ]);
391
+ expect(calls.previousReads).toBe(1);
392
+ });
393
+
394
+ test("a failed-task title heading does not hide later failures", async () => {
395
+ const { ctx, calls } = fakeCtx({
396
+ configs: { TYPESAFE_API_KEY: "x" },
397
+ reportedFailures: ["aaaaaaaa-0000", "bbbbbbbb-0000"],
398
+ prevStatus: [
399
+ "## Current System Status [auto-generated]",
400
+ "## Recent Failures (last 6h) [auto-generated]",
401
+ '- [aaaaaaaa] "Fix the task\n## Requirements" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
402
+ '- [bbbbbbbb] "later failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
403
+ "## Gate Findings [auto-generated]",
404
+ '- [cccccccc] "outside failures" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
405
+ ].join("\n"),
406
+ failed: [
407
+ {
408
+ id: "aaaaaaaa-0000",
409
+ agentId: "12345678-bbbb",
410
+ task: "Fix the task\n## Requirements",
411
+ failureReason: "boom",
412
+ finishedAt: "2026-10-15T12:00:00Z",
413
+ },
414
+ {
415
+ id: "bbbbbbbb-0000",
416
+ agentId: "12345678-bbbb",
417
+ task: "later failure",
418
+ failureReason: "boom",
419
+ finishedAt: "2026-10-15T12:00:00Z",
420
+ },
421
+ {
422
+ id: "cccccccc-0000",
423
+ agentId: "12345678-bbbb",
424
+ task: "outside failures",
425
+ failureReason: "boom",
426
+ finishedAt: "2026-10-15T13:01:00.000Z",
427
+ },
428
+ ],
429
+ jev: jevSays(0.9),
430
+ });
431
+ const verdict = await run(ctx, { dryRun: true });
432
+ expect(verdict.state.new_failures_since_previous_heartbeat).toEqual([
433
+ "cccccccc: outside failures | boom",
434
+ ]);
435
+ expect(verdict.state.failures_last_6h_total).toBe(3);
436
+ expect(calls.previousReads).toBe(1);
437
+ });
438
+
439
+ test("code-fenced snapshot system and agent markers cannot hide previous failures", async () => {
440
+ const { ctx, calls } = fakeCtx({
441
+ configs: { TYPESAFE_API_KEY: "x" },
442
+ reportedFailures: ["aaaaaaaa-0000"],
443
+ prevStatus: [
444
+ "## Your Standing Orders (snapshot from HEARTBEAT.md)",
445
+ "```md",
446
+ "## Current System Status [auto-generated]",
447
+ "## Agent Status [auto-generated]",
448
+ "```",
449
+ "## Current System Status [auto-generated]",
450
+ "## Recent Failures (last 6h) [auto-generated]",
451
+ '- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00.000Z',
452
+ "## Agent Status [auto-generated]",
453
+ ].join("\n"),
454
+ failed: [
455
+ {
456
+ id: "aaaaaaaa-0000",
457
+ agentId: "12345678-bbbb",
458
+ task: "old failure",
459
+ failureReason: "boom",
460
+ finishedAt: "2026-10-15T12:00:00.000Z",
461
+ },
462
+ ],
463
+ jev: jevSays(0.9),
464
+ });
465
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toBe(
466
+ "none",
467
+ );
468
+ expect(calls.previousReads).toBe(1);
469
+ });
470
+
471
+ test("a generated Requirements heading in a failed title cannot hide later failures", async () => {
472
+ const { ctx } = fakeCtx({
473
+ configs: { TYPESAFE_API_KEY: "x" },
474
+ reportedFailures: ["aaaaaaaa-0000", "bbbbbbbb-0000"],
475
+ prevStatus: [
476
+ "## Current System Status [auto-generated]",
477
+ "## Recent Failures (last 6h) [auto-generated]",
478
+ '- [aaaaaaaa] "Fix\n## Requirements [auto-generated]\nbody" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00.000Z',
479
+ '- [bbbbbbbb] "later failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:01:00.000Z',
480
+ "## Agent Status [auto-generated]",
481
+ ].join("\n"),
482
+ failed: [
483
+ {
484
+ id: "aaaaaaaa-0000",
485
+ agentId: "12345678-bbbb",
486
+ task: "Fix\n## Requirements [auto-generated]\nbody",
487
+ failureReason: "boom",
488
+ finishedAt: "2026-10-15T12:00:00.000Z",
489
+ },
490
+ {
491
+ id: "bbbbbbbb-0000",
492
+ agentId: "12345678-bbbb",
493
+ task: "later failure",
494
+ failureReason: "boom",
495
+ finishedAt: "2026-10-15T12:01:00.000Z",
496
+ },
497
+ ],
498
+ jev: jevSays(0.9),
499
+ });
500
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toBe(
501
+ "none",
502
+ );
503
+ });
504
+
505
+ test("a failure finished after previous creation stays new even if its ID is quoted", async () => {
506
+ const { ctx } = fakeCtx({
507
+ configs: { TYPESAFE_API_KEY: "x" },
508
+ prevStatus:
509
+ '## Current System Status [auto-generated]\n## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "new failure" — agent: 12345678, reason: boom, at: 2026-10-15T13:00:00.001Z',
510
+ failed: [
511
+ {
512
+ id: "aaaaaaaa-0000",
513
+ agentId: "12345678-bbbb",
514
+ task: "new failure",
515
+ failureReason: "boom",
516
+ finishedAt: "2026-10-15T13:00:00.001Z",
517
+ },
518
+ ],
519
+ jev: jevSays(0.9),
520
+ });
521
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
522
+ "aaaaaaaa: new failure | boom",
523
+ ]);
524
+ });
525
+
526
+ test.each([
527
+ { reportedFailures: ["aaaaaaaa-0000"] },
528
+ { reportedFailures: '["aaaaaaaa-0000"]' },
529
+ ])("a failure unseen during gathering stays new before checklist creation (%j)", async ({
530
+ reportedFailures,
531
+ }) => {
532
+ const { ctx } = fakeCtx({
533
+ configs: { TYPESAFE_API_KEY: "x" },
534
+ reportedFailures,
535
+ prevCreatedAt: "2026-10-15T13:00:00.000Z",
536
+ failed: [
537
+ {
538
+ id: "aaaaaaaa-0000",
539
+ agentId: "12345678-bbbb",
540
+ task: "reported",
541
+ failureReason: "boom",
542
+ finishedAt: "2026-10-15T12:00:00.000Z",
543
+ },
544
+ {
545
+ id: "bbbbbbbb-0000",
546
+ agentId: "12345678-bbbb",
547
+ task: "unseen",
548
+ failureReason: "boom",
549
+ finishedAt: "2026-10-15T12:59:59.999Z",
550
+ },
551
+ ],
552
+ jev: jevSays(0.9),
553
+ });
554
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
555
+ "bbbbbbbb: unseen | boom",
556
+ ]);
557
+ });
558
+
559
+ test.each(
560
+ [undefined, "not JSON", {}, [42]].map((reportedFailures) => ({ reportedFailures })),
561
+ )("missing or invalid receipts treat every current failure as new (%j)", async ({
562
+ reportedFailures,
563
+ }) => {
564
+ const { ctx } = fakeCtx({
565
+ configs: { TYPESAFE_API_KEY: "x" },
566
+ reportedFailures,
567
+ prevStatus: '## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "quoted"',
568
+ failed: [
569
+ {
570
+ id: "aaaaaaaa-0000",
571
+ agentId: "12345678-bbbb",
572
+ task: "still new",
573
+ failureReason: "boom",
574
+ finishedAt: "2026-10-15T12:00:00.000Z",
575
+ },
576
+ ],
577
+ jev: jevSays(0.9),
578
+ });
579
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
580
+ "aaaaaaaa: still new | boom",
581
+ ]);
582
+ });
583
+
584
+ test("without a previous checklist all current failures are new", async () => {
585
+ const { ctx } = fakeCtx({
586
+ configs: { TYPESAFE_API_KEY: "x" },
587
+ prevCreatedAt: null,
588
+ failed: [
589
+ {
590
+ id: "aaaaaaaa-0000",
591
+ agentId: "12345678-bbbb",
592
+ task: "first failure",
593
+ failureReason: "boom",
594
+ finishedAt: "2026-10-15T12:00:00.000Z",
595
+ },
596
+ ],
597
+ jev: jevSays(0.9),
598
+ });
599
+ expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
600
+ "aaaaaaaa: first failure | boom",
601
+ ]);
602
+ });
603
+
220
604
  test("threshold and time zone come from swarm config", async () => {
221
605
  const { ctx } = fakeCtx({
222
606
  configs: {