@desplega.ai/agent-swarm 1.135.2 → 1.136.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 (97) hide show
  1. package/dist/{actions-3az2vwwn.js → actions-agcbc8nd.js} +6 -6
  2. package/dist/{app-vygnfvrh.js → app-g2s78xya.js} +3 -3
  3. package/dist/{assistant-2jr2frrm.js → assistant-yd3jkb9d.js} +8 -8
  4. package/dist/{boot-reembed-y7r9cxt0.js → boot-reembed-6spqw4q0.js} +3 -3
  5. package/dist/{boot-reembed-ncbg16rb.js → boot-reembed-b22s9w5x.js} +4 -4
  6. package/dist/{boot-scrub-logs-t82rwa0y.js → boot-scrub-logs-6h3cqkc0.js} +2 -2
  7. package/dist/{claude-adapter-vvx2aw3v.js → claude-adapter-ng5y8zd7.js} +4 -4
  8. package/dist/{cli-mfa2eaef.js → cli-35jknh9e.js} +5 -5
  9. package/dist/{cli-cv2vjg2y.js → cli-3y553gws.js} +1 -1
  10. package/dist/{cli-nhvym0d2.js → cli-4gzv54ss.js} +15 -6
  11. package/dist/{cli-4dakdn30.js → cli-4r4jkqep.js} +6 -6
  12. package/dist/{cli-83razmqg.js → cli-54ht104h.js} +3 -3
  13. package/dist/{cli-6290n4yz.js → cli-5bnz1t86.js} +1 -1
  14. package/dist/{cli-6bz2hzvw.js → cli-68vayryq.js} +2 -2
  15. package/dist/{cli-1z01xdqh.js → cli-6gwgzdgw.js} +2 -2
  16. package/dist/{cli-1dfatkzm.js → cli-84vrhesc.js} +1 -1
  17. package/dist/{cli-jfrh963q.js → cli-8v57p983.js} +10 -10
  18. package/dist/{cli-3f0tnh34.js → cli-ads0hfw2.js} +81 -23
  19. package/dist/{cli-fmha74ga.js → cli-bnq8h7ea.js} +3 -3
  20. package/dist/{cli-j3xnd5b4.js → cli-d5ygfv18.js} +7 -6
  21. package/dist/{cli-8f4h27gm.js → cli-edwrx42s.js} +1 -1
  22. package/dist/{cli-r5vwg1ma.js → cli-fpjmcbhz.js} +4 -4
  23. package/dist/{cli-w0ygcv43.js → cli-gpdtxzar.js} +6 -4
  24. package/dist/{cli-4rdjdddh.js → cli-hz5hp6wn.js} +1 -1
  25. package/dist/{cli-6mmwmy45.js → cli-j7qp8gtf.js} +1 -1
  26. package/dist/{cli-5a5nrq3g.js → cli-jjhnma34.js} +5 -5
  27. package/dist/{cli-ey09krp2.js → cli-m3z53bed.js} +1 -1
  28. package/dist/{cli-cf6vq6qa.js → cli-mgef08pc.js} +1 -1
  29. package/dist/{cli-0jhyxtdj.js → cli-qckgdqmr.js} +2 -2
  30. package/dist/{cli-a1zrc2g6.js → cli-qhfkxrpg.js} +108 -33
  31. package/dist/{cli-yw2z5kj9.js → cli-spf767vc.js} +1 -1
  32. package/dist/{cli-1857msnd.js → cli-tee8w7tq.js} +1 -1
  33. package/dist/{cli-qxn3g3yx.js → cli-tmn19cdz.js} +61 -6
  34. package/dist/{cli-7ccy0h4r.js → cli-y16cesaq.js} +2 -2
  35. package/dist/{cli-cm4z7w7w.js → cli-z6e8eqrm.js} +2 -2
  36. package/dist/cli.js +16 -16
  37. package/dist/{codex-adapter-6p290yq3.js → codex-adapter-kw11ps3j.js} +2 -2
  38. package/dist/{codex-session-runner-7b3dhpmx.js → codex-session-runner-sffb69v7.js} +2 -2
  39. package/dist/{commands-wxg1g4nq.js → commands-0xxp4tz8.js} +2 -2
  40. package/dist/{db-8fqeheve.js → db-0npp39zd.js} +6 -2
  41. package/dist/{handlers-gwtqp17c.js → handlers-5p7a1jx6.js} +15 -9
  42. package/dist/{hook-gd9fwfp0.js → hook-hpefr0a8.js} +2 -2
  43. package/dist/{hook-mrqygs7p.js → hook-jjpxn4se.js} +3 -3
  44. package/dist/{http-x4mc1mqg.js → http-wfp33653.js} +322 -61
  45. package/dist/{index-yd654kac.js → index-17h657rg.js} +12 -8
  46. package/dist/{index-pzgasj16.js → index-m2p7ctyh.js} +10 -10
  47. package/dist/{index-2mdqrd0n.js → index-racb4wfq.js} +9 -9
  48. package/dist/{index-4djjkp9z.js → index-zh66wm65.js} +8 -8
  49. package/dist/{keepalive-3tycspcy.js → keepalive-wxjd633a.js} +4 -4
  50. package/dist/{lead-js1h3z7d.js → lead-359ec2z0.js} +23 -23
  51. package/dist/{maintenance-y91ry8q4.js → maintenance-rr03qjtb.js} +4 -4
  52. package/dist/{mistral-conversations-hdmnkf6h.js → mistral-conversations-qdrnmrb8.js} +1 -1
  53. package/dist/{oauth-refresh-sweep-s33gtxrw.js → oauth-refresh-sweep-jwj22yb8.js} +3 -3
  54. package/dist/{onboard-vndj64mb.js → onboard-asnhchcv.js} +2 -2
  55. package/dist/{openai-completions-cwy2kkzg.js → openai-completions-yzftqrft.js} +33 -4
  56. package/dist/{otel-impl-d2p84k6e.js → otel-impl-54cra6mw.js} +18 -4
  57. package/dist/{pi-mono-adapter-1phspz2y.js → pi-mono-adapter-29g7axgj.js} +669 -335
  58. package/dist/{pricing-refresh-c2bwsyk5.js → pricing-refresh-mkyy7c2y.js} +4 -4
  59. package/dist/{rbac-roles-2pedz5e9.js → rbac-roles-ak56tr75.js} +2 -2
  60. package/dist/{rbac-roles-reef5y2d.js → rbac-roles-kb3afm95.js} +3 -3
  61. package/dist/{render-v2-zd5r5wgf.js → render-v2-434asg49.js} +3 -3
  62. package/dist/{seed-pricing-2kyk1vhg.js → seed-pricing-nbmd7ah8.js} +3 -3
  63. package/dist/{setup-5ys9pwyr.js → setup-wh4qmjs4.js} +2 -2
  64. package/dist/{worker-nb1f7k04.js → worker-j1d27tjv.js} +23 -23
  65. package/openapi.json +167 -2
  66. package/package.json +6 -6
  67. package/src/be/db-retention.ts +275 -0
  68. package/src/be/db.ts +82 -7
  69. package/src/be/swarm-config-guard.ts +30 -0
  70. package/src/heartbeat/heartbeat.ts +21 -0
  71. package/src/http/index.ts +33 -33
  72. package/src/http/route-def.ts +93 -4
  73. package/src/http/stats.ts +37 -1
  74. package/src/http/tasks.ts +93 -0
  75. package/src/http/utils.ts +94 -0
  76. package/src/otel-impl.ts +21 -3
  77. package/src/otel.ts +15 -4
  78. package/src/slack/blocks.ts +1 -0
  79. package/src/slack/handlers.ts +7 -0
  80. package/src/tasks/sibling-awareness.ts +1 -0
  81. package/src/tasks/sibling-block.ts +17 -4
  82. package/src/tests/artifact-sdk.test.ts +20 -15
  83. package/src/tests/db-retention.test.ts +344 -0
  84. package/src/tests/docs-index-footprint.test.js +4 -5
  85. package/src/tests/draft-task-dispatch-race.test.ts +189 -0
  86. package/src/tests/events-http.test.ts +70 -27
  87. package/src/tests/otel-abort-span.test.ts +156 -0
  88. package/src/tests/otel-span-kind.test.ts +59 -0
  89. package/src/tests/promote-draft-task-route.test.ts +175 -0
  90. package/src/tests/route-def-find-route.test.ts +90 -4
  91. package/src/tests/sibling-block.test.ts +29 -1
  92. package/src/tests/slack-assistant-comention-production.test.ts +7 -1
  93. package/src/tests/steering-enabled.test.ts +6 -0
  94. package/src/tests/swarm-config-reserved-keys.test.ts +27 -0
  95. package/src/types.ts +2 -1
  96. package/templates/skills/swarm-scripts/SKILL.md +4 -0
  97. package/templates/skills/swarm-scripts/content.md +4 -0
@@ -0,0 +1,344 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getDbClient, initDb } from "../be/db";
4
+ import {
5
+ DB_RETENTION_TABLES,
6
+ getDbRetentionStats,
7
+ resetDbRetentionForTests,
8
+ runDbRetentionTick,
9
+ startDbRetention,
10
+ stopDbRetention,
11
+ } from "../be/db-retention";
12
+ import { MAX_DB_RETENTION_DAYS, validateConfigValue } from "../be/swarm-config-guard";
13
+
14
+ const TEST_DB_PATH = "./test-db-retention.sqlite";
15
+ const NOW = new Date("2026-08-23T12:00:00.000Z");
16
+ const RETENTION_KEYS = DB_RETENTION_TABLES.map((table) => table.envKey);
17
+
18
+ async function removeDbFiles(): Promise<void> {
19
+ for (const suffix of ["", "-wal", "-shm"])
20
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => undefined);
21
+ }
22
+
23
+ async function insertRow(
24
+ table: (typeof DB_RETENTION_TABLES)[number]["table"],
25
+ id: string,
26
+ createdAt: string,
27
+ ): Promise<void> {
28
+ const client = getDbClient();
29
+ if (table === "session_logs") {
30
+ await client.run(
31
+ "INSERT INTO session_logs (id, sessionId, iteration, cli, content, lineNumber, createdAt) VALUES (?, ?, 0, 'bun', 'log', 1, ?)",
32
+ [id, `session-${id}`, createdAt],
33
+ );
34
+ return;
35
+ }
36
+ if (table === "agent_log") {
37
+ await client.run("INSERT INTO agent_log (id, eventType, createdAt) VALUES (?, 'test', ?)", [
38
+ id,
39
+ createdAt,
40
+ ]);
41
+ return;
42
+ }
43
+ await client.run(
44
+ "INSERT INTO events (id, category, event, source, createdAt) VALUES (?, 'test', 'retention.test', 'test', ?)",
45
+ [id, createdAt],
46
+ );
47
+ }
48
+
49
+ async function countRows(table: (typeof DB_RETENTION_TABLES)[number]["table"]): Promise<number> {
50
+ const row = await getDbClient().get<{ count: number }>(`SELECT COUNT(*) AS count FROM ${table}`);
51
+ return row?.count ?? 0;
52
+ }
53
+
54
+ beforeAll(async () => {
55
+ closeDb();
56
+ await removeDbFiles();
57
+ initDb(TEST_DB_PATH);
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await resetDbRetentionForTests();
62
+ closeDb();
63
+ await removeDbFiles();
64
+ });
65
+
66
+ beforeEach(async () => {
67
+ await resetDbRetentionForTests();
68
+ for (const table of DB_RETENTION_TABLES) await getDbClient().run(`DELETE FROM ${table.table}`);
69
+ for (const key of [...RETENTION_KEYS, "DB_RETENTION_DRY_RUN"]) delete process.env[key];
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await stopDbRetention();
74
+ });
75
+
76
+ describe("DB retention", () => {
77
+ test("keeps the closed allowlist limited to the three approved tables", () => {
78
+ expect(DB_RETENTION_TABLES.map((table) => table.table)).toEqual([
79
+ "session_logs",
80
+ "agent_log",
81
+ "events",
82
+ ]);
83
+ });
84
+
85
+ test("validates each retention setting as an integer of at least one day", () => {
86
+ for (const key of RETENTION_KEYS) {
87
+ expect(validateConfigValue(key, "30")).toBeNull();
88
+ expect(validateConfigValue(key, "0")).toContain("between 1");
89
+ expect(validateConfigValue(key, "-1")).toContain("integer");
90
+ expect(validateConfigValue(key, "abc")).toContain("integer");
91
+ expect(validateConfigValue(key, String(MAX_DB_RETENTION_DAYS))).toBeNull();
92
+ expect(validateConfigValue(key, String(MAX_DB_RETENTION_DAYS + 1))).toContain("between");
93
+ }
94
+ });
95
+
96
+ test("validates the dry-run setting as a strict boolean literal", () => {
97
+ for (const value of ["true", "false", "1", "0"]) {
98
+ expect(validateConfigValue("DB_RETENTION_DRY_RUN", value)).toBeNull();
99
+ }
100
+ expect(validateConfigValue("DB_RETENTION_DRY_RUN", "treu")).toContain(
101
+ "Invalid DB_RETENTION_DRY_RUN",
102
+ );
103
+ });
104
+
105
+ test("is opt-in and rejects invalid retention windows", async () => {
106
+ for (const table of DB_RETENTION_TABLES)
107
+ await insertRow(table.table, `${table.table}-old`, "2026-08-01T00:00:00.000Z");
108
+ process.env.SESSION_LOG_RETENTION_DAYS = "0";
109
+ process.env.AGENT_LOG_RETENTION_DAYS = "abc";
110
+ process.env.EVENTS_RETENTION_DAYS = "-1";
111
+
112
+ await runDbRetentionTick({ now: NOW });
113
+
114
+ for (const table of DB_RETENTION_TABLES) expect(await countRows(table.table)).toBe(1);
115
+ });
116
+
117
+ test("sweeps only enabled tables and preserves rows at or after the ISO cutoff", async () => {
118
+ const cutoff = "2026-08-22T12:00:00.000Z";
119
+ for (const table of DB_RETENTION_TABLES) {
120
+ await insertRow(table.table, `${table.table}-old`, "2026-08-22T11:59:59.999Z");
121
+ await insertRow(table.table, `${table.table}-cutoff`, cutoff);
122
+ }
123
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
124
+
125
+ await runDbRetentionTick({ now: NOW });
126
+
127
+ expect(await countRows("session_logs")).toBe(1);
128
+ expect(await countRows("agent_log")).toBe(2);
129
+ expect(await countRows("events")).toBe(2);
130
+ expect(await getDbRetentionStats().sessionLogs).toMatchObject({
131
+ rowsDeleted: 1,
132
+ dryRun: false,
133
+ });
134
+ });
135
+
136
+ test("never deletes old rows from critical tables", async () => {
137
+ const old = "2020-01-01T00:00:00.000Z";
138
+ const client = getDbClient();
139
+ await client.run(
140
+ "INSERT INTO agents (id, name, status, createdAt, lastUpdatedAt) VALUES ('critical-agent', 'Critical', 'idle', ?, ?)",
141
+ [old, old],
142
+ );
143
+ await client.run(
144
+ "INSERT INTO agent_tasks (id, task, status, source, createdAt, lastUpdatedAt) VALUES ('critical-task', 'keep', 'completed', 'mcp', ?, ?)",
145
+ [old, old],
146
+ );
147
+ await client.run(
148
+ "INSERT INTO agent_memory (id, scope, name, content, source, createdAt, accessedAt) VALUES ('critical-memory', 'swarm', 'keep', 'keep', 'manual', ?, ?)",
149
+ [old, old],
150
+ );
151
+ await client.run(
152
+ "INSERT INTO permission_audit (id, ts, principalType, verb, decision, source) VALUES ('critical-audit', ?, 'operator', 'config.read', 'allow', 'http')",
153
+ [old],
154
+ );
155
+ for (const table of DB_RETENTION_TABLES) process.env[table.envKey] = "1";
156
+
157
+ await runDbRetentionTick({ now: NOW });
158
+
159
+ for (const table of ["agents", "agent_tasks", "agent_memory", "permission_audit"]) {
160
+ const row = await client.get<{ count: number }>(
161
+ `SELECT COUNT(*) AS count FROM ${table} WHERE id LIKE 'critical-%'`,
162
+ );
163
+ expect(row?.count).toBe(1);
164
+ }
165
+ });
166
+
167
+ test("uses bounded batches and continues on the next tick", async () => {
168
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
169
+ for (let index = 0; index < 5; index++) {
170
+ await insertRow("session_logs", `old-${index}`, "2026-08-01T00:00:00.000Z");
171
+ }
172
+
173
+ await runDbRetentionTick({ now: NOW, batchSize: 2, perTableBatchCap: 1 });
174
+ expect(await countRows("session_logs")).toBe(3);
175
+ await runDbRetentionTick({ now: NOW, batchSize: 2, perTableBatchCap: 1 });
176
+ expect(await countRows("session_logs")).toBe(1);
177
+ await runDbRetentionTick({ now: NOW, batchSize: 2, perTableBatchCap: 1 });
178
+ expect(await countRows("session_logs")).toBe(0);
179
+ expect(getDbRetentionStats().sessionLogs).toMatchObject({
180
+ rowsDeleted: 1,
181
+ batches: 1,
182
+ cumulativeRowsDeleted: 5,
183
+ });
184
+ });
185
+
186
+ test("dry run reports candidates without deleting any enabled table rows", async () => {
187
+ for (const table of DB_RETENTION_TABLES) {
188
+ process.env[table.envKey] = "1";
189
+ await insertRow(table.table, `${table.table}-old`, "2026-08-01T00:00:00.000Z");
190
+ }
191
+ process.env.DB_RETENTION_DRY_RUN = "true";
192
+
193
+ await runDbRetentionTick({ now: NOW });
194
+
195
+ for (const table of DB_RETENTION_TABLES) expect(await countRows(table.table)).toBe(1);
196
+ expect(getDbRetentionStats()).toMatchObject({
197
+ sessionLogs: { rowsDeleted: 1, dryRun: true },
198
+ agentLog: { rowsDeleted: 1, dryRun: true },
199
+ events: { rowsDeleted: 1, dryRun: true },
200
+ });
201
+ });
202
+
203
+ test("uses dry run when the deployed dry-run value is invalid", async () => {
204
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
205
+ process.env.DB_RETENTION_DRY_RUN = "treu";
206
+ await insertRow("session_logs", "invalid-dry-run-old", "2026-08-01T00:00:00.000Z");
207
+
208
+ await runDbRetentionTick({ now: NOW });
209
+
210
+ expect(await countRows("session_logs")).toBe(1);
211
+ expect(getDbRetentionStats().sessionLogs).toMatchObject({ rowsDeleted: 1, dryRun: true });
212
+ });
213
+
214
+ test("dry run reports an exact count above the former safety cap", async () => {
215
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
216
+ process.env.DB_RETENTION_DRY_RUN = "true";
217
+ await getDbClient().run(
218
+ `WITH RECURSIVE candidates(value) AS (
219
+ SELECT 1
220
+ UNION ALL
221
+ SELECT value + 1 FROM candidates WHERE value < 250001
222
+ )
223
+ INSERT INTO session_logs (id, sessionId, iteration, cli, content, lineNumber, createdAt)
224
+ SELECT 'dry-' || value, 'dry-session', 0, 'bun', 'log', value, '2026-08-01T00:00:00.000Z'
225
+ FROM candidates`,
226
+ );
227
+
228
+ await runDbRetentionTick({ now: NOW });
229
+
230
+ expect(getDbRetentionStats().sessionLogs?.rowsDeleted).toBe(250_001);
231
+ expect(await countRows("session_logs")).toBe(250_001);
232
+ });
233
+
234
+ test("dry run scans sparse tables in bounded keyset pages when no rows are eligible", async () => {
235
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
236
+ process.env.DB_RETENTION_DRY_RUN = "true";
237
+ await getDbClient().run(
238
+ `WITH RECURSIVE recent(value) AS (
239
+ SELECT 1
240
+ UNION ALL
241
+ SELECT value + 1 FROM recent WHERE value < 10000
242
+ )
243
+ INSERT INTO session_logs (id, sessionId, iteration, cli, content, lineNumber, createdAt)
244
+ SELECT 'recent-' || printf('%05d', value), 'recent-session', 0, 'bun', 'log', value,
245
+ '2026-08-23T11:59:59.999Z'
246
+ FROM recent`,
247
+ );
248
+
249
+ await runDbRetentionTick({ now: NOW, batchSize: 100, wallClockCapMs: 1 });
250
+
251
+ expect(getDbRetentionStats().sessionLogs).toBeUndefined();
252
+ expect(await countRows("session_logs")).toBe(10_000);
253
+ });
254
+
255
+ test("bounds pages fetched on a non-dry sweep of a sparse table with no eligible rows", async () => {
256
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
257
+ await getDbClient().run(
258
+ `WITH RECURSIVE recent(value) AS (
259
+ SELECT 1
260
+ UNION ALL
261
+ SELECT value + 1 FROM recent WHERE value < 10000
262
+ )
263
+ INSERT INTO session_logs (id, sessionId, iteration, cli, content, lineNumber, createdAt)
264
+ SELECT 'recent-' || printf('%05d', value), 'recent-session', 0, 'bun', 'log', value,
265
+ '2026-08-23T11:59:59.999Z'
266
+ FROM recent`,
267
+ );
268
+
269
+ const startedAt = Date.now();
270
+ await runDbRetentionTick({ now: NOW, batchSize: 1, perTableBatchCap: 1 });
271
+
272
+ // A page counter must cap the loop even when zero rows are eligible for
273
+ // deletion, since batches only increments on a page with a match. Without
274
+ // that cap this sweep walks all 10,000 pages (with a 5ms yield between
275
+ // each) instead of stopping after the one page the cap allows.
276
+ expect(Date.now() - startedAt).toBeLessThan(500);
277
+ expect(getDbRetentionStats().sessionLogs).toMatchObject({ rowsDeleted: 0, batches: 0 });
278
+ expect(await countRows("session_logs")).toBe(10_000);
279
+ });
280
+
281
+ test("stops a dry-run count when its wall-clock budget expires", async () => {
282
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
283
+ process.env.DB_RETENTION_DRY_RUN = "true";
284
+ await getDbClient().run(
285
+ `WITH RECURSIVE candidates(value) AS (
286
+ SELECT 1
287
+ UNION ALL
288
+ SELECT value + 1 FROM candidates WHERE value < 1000
289
+ )
290
+ INSERT INTO session_logs (id, sessionId, iteration, cli, content, lineNumber, createdAt)
291
+ SELECT 'deadline-' || value, 'deadline-session', 0, 'bun', 'log', value, '2026-08-01T00:00:00.000Z'
292
+ FROM candidates`,
293
+ );
294
+
295
+ const startedAt = Date.now();
296
+ await runDbRetentionTick({ now: NOW, batchSize: 1, wallClockCapMs: 10 });
297
+
298
+ expect(Date.now() - startedAt).toBeLessThan(150);
299
+ expect(await countRows("session_logs")).toBe(1000);
300
+ expect(getDbRetentionStats().sessionLogs).toBeUndefined();
301
+ });
302
+
303
+ test("rejects an excessive window without aborting later table sweeps", async () => {
304
+ process.env.SESSION_LOG_RETENTION_DAYS = String(MAX_DB_RETENTION_DAYS + 1);
305
+ process.env.AGENT_LOG_RETENTION_DAYS = "1";
306
+ await insertRow("session_logs", "oversized-window", "2026-08-01T00:00:00.000Z");
307
+ await insertRow("agent_log", "valid-window", "2026-08-01T00:00:00.000Z");
308
+
309
+ await runDbRetentionTick({ now: NOW });
310
+
311
+ expect(await countRows("session_logs")).toBe(1);
312
+ expect(await countRows("agent_log")).toBe(0);
313
+ });
314
+
315
+ test("shutdown cancels an in-flight sweep between batches and waits for it", async () => {
316
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
317
+ for (let index = 0; index < 100; index++) {
318
+ await insertRow("session_logs", `shutdown-${index}`, "2026-08-01T00:00:00.000Z");
319
+ }
320
+
321
+ const tick = runDbRetentionTick({ now: NOW, batchSize: 1, perTableBatchCap: 100 });
322
+ while ((await countRows("session_logs")) === 100) {
323
+ await Bun.sleep(1);
324
+ }
325
+ await stopDbRetention();
326
+ await tick;
327
+
328
+ const remaining = await countRows("session_logs");
329
+ expect(remaining).toBeGreaterThan(0);
330
+ expect(remaining).toBeLessThan(100);
331
+ await Bun.sleep(20);
332
+ expect(await countRows("session_logs")).toBe(remaining);
333
+ });
334
+
335
+ test("runs the first lifecycle tick immediately and can stop cleanly", async () => {
336
+ process.env.SESSION_LOG_RETENTION_DAYS = "1";
337
+ await insertRow("session_logs", "lifecycle-old", "2026-08-01T00:00:00.000Z");
338
+
339
+ await startDbRetention(60_000);
340
+
341
+ expect(await countRows("session_logs")).toBe(0);
342
+ await stopDbRetention();
343
+ });
344
+ });
@@ -78,7 +78,7 @@ describe("docs API index footprint", () => {
78
78
  expect(tasks).toContain('"path":"/api/task-templates","method":"get"');
79
79
  });
80
80
 
81
- test("OpenAPI and generated pages keep all 351 operations", async () => {
81
+ test("OpenAPI and generated pages keep all 352 operations", async () => {
82
82
  const spec = await Bun.file(resolve(repoRoot, "openapi.json")).json();
83
83
  const operationKeys = [];
84
84
  const tags = new Set();
@@ -102,18 +102,17 @@ describe("docs API index footprint", () => {
102
102
  }
103
103
 
104
104
  const generatedKeys = await generatedOperationKeys();
105
- expect(operationKeys).toHaveLength(351);
106
- expect(generatedKeys).toHaveLength(351);
105
+ expect(operationKeys).toHaveLength(352);
106
+ expect(generatedKeys).toHaveLength(352);
107
107
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
108
108
  });
109
109
 
110
- test("keeps retired routes out of the 135-page sitemap source inventory", async () => {
110
+ test("keeps retired routes out of the sitemap source inventory", async () => {
111
111
  const urls = contentRoutes().map((route) => `${baseUrl}${route}`);
112
112
  const sitemapSource = await Bun.file(resolve(repoRoot, "docs-site/app/sitemap.ts")).text();
113
113
 
114
114
  expect(sitemapSource).toContain("source.getPages().map((page) => ({");
115
115
  expect(sitemapSource).toMatch(/url:\s*`\$\{baseUrl\}\$\{page\.url\}`/);
116
- expect(urls).toHaveLength(135);
117
116
  expect(new Set(urls).size).toBe(urls.length);
118
117
  for (const route of retiredRoutes) {
119
118
  expect(urls).not.toContain(`${baseUrl}${route}`);
@@ -0,0 +1,189 @@
1
+ /**
2
+ * #1240 — a UI task with attachments is pool-claimable before its
3
+ * attachments exist. Covers the `draft` status that closes the race:
4
+ * created dispatch-ineligible, promoted once the upload batch settles (or
5
+ * times out via the heartbeat sweep for an abandoned draft).
6
+ */
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { unlink } from "node:fs/promises";
9
+ import {
10
+ claimTask,
11
+ closeDb,
12
+ createAgent,
13
+ createTaskExtended,
14
+ getDbClient,
15
+ getPendingTaskForAgent,
16
+ getTaskById,
17
+ initDb,
18
+ promoteAbandonedDraftTasks,
19
+ promoteDraftTask,
20
+ } from "../be/db";
21
+
22
+ const TEST_DB_PATH = "./test-draft-task-dispatch-race.sqlite";
23
+
24
+ describe("Draft task dispatch race (#1240)", () => {
25
+ beforeAll(async () => {
26
+ try {
27
+ await unlink(TEST_DB_PATH);
28
+ } catch {
29
+ // File doesn't exist
30
+ }
31
+ closeDb();
32
+ initDb(TEST_DB_PATH);
33
+ });
34
+
35
+ afterAll(async () => {
36
+ closeDb();
37
+ try {
38
+ await unlink(TEST_DB_PATH);
39
+ await unlink(`${TEST_DB_PATH}-wal`);
40
+ await unlink(`${TEST_DB_PATH}-shm`);
41
+ } catch {
42
+ // Files may not exist
43
+ }
44
+ });
45
+
46
+ beforeEach(async () => {
47
+ await getDbClient().run("DELETE FROM agent_tasks");
48
+ await getDbClient().run("DELETE FROM agents");
49
+ await getDbClient().run("DELETE FROM agent_log");
50
+ });
51
+
52
+ test("status:'draft' wins over agentId — task lands in draft, not pending", async () => {
53
+ const agent = await createAgent({ name: "lead-1", isLead: true, status: "idle" });
54
+ const task = await createTaskExtended("Draft task with owner", {
55
+ agentId: agent.id,
56
+ status: "draft",
57
+ });
58
+ expect(task.status).toBe("draft");
59
+ expect(task.agentId).toBe(agent.id);
60
+ });
61
+
62
+ test("status:'draft' wins over offeredTo — task lands in draft, not offered", async () => {
63
+ const agent = await createAgent({ name: "offeree-1", isLead: false, status: "idle" });
64
+ const task = await createTaskExtended("Draft task offered", {
65
+ offeredTo: agent.id,
66
+ status: "draft",
67
+ });
68
+ expect(task.status).toBe("draft");
69
+ });
70
+
71
+ test("a draft task assigned to an agent is NOT returned by getPendingTaskForAgent", async () => {
72
+ const agent = await createAgent({ name: "lead-2", isLead: true, status: "idle" });
73
+ await createTaskExtended("Draft task, uploads in flight", {
74
+ agentId: agent.id,
75
+ status: "draft",
76
+ });
77
+
78
+ const pending = await getPendingTaskForAgent(agent.id);
79
+ expect(pending).toBeNull();
80
+ });
81
+
82
+ test("a draft task with no owner is NOT returned by pool claim", async () => {
83
+ const agent = await createAgent({ name: "worker-1", isLead: false, status: "idle" });
84
+ const task = await createTaskExtended("Draft task, no owner yet", {
85
+ status: "draft",
86
+ });
87
+
88
+ const claimed = await claimTask(task.id, agent.id);
89
+ expect(claimed).toBeNull();
90
+
91
+ const stored = await getTaskById(task.id);
92
+ expect(stored?.status).toBe("draft");
93
+ expect(stored?.agentId).toBeNull();
94
+ });
95
+
96
+ test("promoteDraftTask moves an owned draft to pending, then it dispatches normally", async () => {
97
+ const agent = await createAgent({ name: "lead-3", isLead: true, status: "idle" });
98
+ const task = await createTaskExtended("Draft task to promote", {
99
+ agentId: agent.id,
100
+ status: "draft",
101
+ });
102
+
103
+ // Not dispatch-eligible yet.
104
+ expect(await getPendingTaskForAgent(agent.id)).toBeNull();
105
+
106
+ const promoted = await promoteDraftTask(task.id);
107
+ expect(promoted?.status).toBe("pending");
108
+ expect(promoted?.agentId).toBe(agent.id);
109
+
110
+ // Now dispatch-eligible.
111
+ const pending = await getPendingTaskForAgent(agent.id);
112
+ expect(pending?.id).toBe(task.id);
113
+ });
114
+
115
+ test("promoteDraftTask moves an unowned draft to unassigned, then pool claim succeeds", async () => {
116
+ const worker = await createAgent({ name: "worker-2", isLead: false, status: "idle" });
117
+ const task = await createTaskExtended("Draft task, no owner", { status: "draft" });
118
+
119
+ expect(await claimTask(task.id, worker.id)).toBeNull();
120
+
121
+ const promoted = await promoteDraftTask(task.id);
122
+ expect(promoted?.status).toBe("unassigned");
123
+
124
+ const claimed = await claimTask(task.id, worker.id);
125
+ expect(claimed?.status).toBe("in_progress");
126
+ expect(claimed?.agentId).toBe(worker.id);
127
+ });
128
+
129
+ test("promoteDraftTask moves an offered draft to offered", async () => {
130
+ const agent = await createAgent({ name: "offeree-2", isLead: false, status: "idle" });
131
+ const task = await createTaskExtended("Draft task, offered", {
132
+ offeredTo: agent.id,
133
+ status: "draft",
134
+ });
135
+
136
+ const promoted = await promoteDraftTask(task.id);
137
+ expect(promoted?.status).toBe("offered");
138
+ expect(promoted?.offeredTo).toBe(agent.id);
139
+ });
140
+
141
+ test("promoteDraftTask is a no-op on a task that already left draft", async () => {
142
+ const task = await createTaskExtended("Already pending", {});
143
+ expect(task.status).toBe("unassigned");
144
+
145
+ const result = await promoteDraftTask(task.id);
146
+ expect(result).toBeNull();
147
+
148
+ const stored = await getTaskById(task.id);
149
+ expect(stored?.status).toBe("unassigned");
150
+ });
151
+
152
+ test("promoteAbandonedDraftTasks leaves a fresh draft untouched", async () => {
153
+ const agent = await createAgent({ name: "lead-4", isLead: true, status: "idle" });
154
+ const task = await createTaskExtended("Fresh draft", {
155
+ agentId: agent.id,
156
+ status: "draft",
157
+ });
158
+
159
+ const promotedCount = await promoteAbandonedDraftTasks(5);
160
+ expect(promotedCount).toBe(0);
161
+
162
+ const stored = await getTaskById(task.id);
163
+ expect(stored?.status).toBe("draft");
164
+ });
165
+
166
+ test("promoteAbandonedDraftTasks promotes a draft past the timeout", async () => {
167
+ const agent = await createAgent({ name: "lead-5", isLead: true, status: "idle" });
168
+ const task = await createTaskExtended("Abandoned draft (tab closed)", {
169
+ agentId: agent.id,
170
+ status: "draft",
171
+ });
172
+
173
+ // Simulate an abandoned draft — back-date lastUpdatedAt past the timeout.
174
+ const staleTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
175
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
176
+ staleTime,
177
+ task.id,
178
+ ]);
179
+
180
+ const promotedCount = await promoteAbandonedDraftTasks(5);
181
+ expect(promotedCount).toBe(1);
182
+
183
+ const stored = await getTaskById(task.id);
184
+ expect(stored?.status).toBe("pending");
185
+
186
+ const pending = await getPendingTaskForAgent(agent.id);
187
+ expect(pending?.id).toBe(task.id);
188
+ });
189
+ });
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import type { Subprocess } from "bun";
4
- import { getFreePort, SERVER_BOOT_HOOK_TIMEOUT_MS, waitForServer } from "./test-net";
4
+ import { getFreePort, SERVER_BOOT_HOOK_TIMEOUT_MS } from "./test-net";
5
5
 
6
6
  let TEST_PORT = 0;
7
7
  const TEST_DB_PATH = `/tmp/test-events-http-${Date.now()}.sqlite`;
@@ -10,6 +10,44 @@ const TEST_API_KEY = "test-events-http-key";
10
10
 
11
11
  let serverProc: Subprocess;
12
12
 
13
+ /**
14
+ * Poll the child until the authenticated events route answers.
15
+ *
16
+ * A refused connection means the child is still booting: keep waiting. The
17
+ * ceiling matches waitForServer() in test-net.ts because a boot under
18
+ * --parallel load on a shared runner takes far longer than the ~1 s it takes
19
+ * idle, and restarting a slow child only resets its progress.
20
+ *
21
+ * Any HTTP response that is not a 200 events list comes from another server:
22
+ * a parallel test claimed the released getFreePort() socket first and the
23
+ * child's own listen failed with EADDRINUSE. Report "foreign" so the caller
24
+ * retries on a fresh port right away.
25
+ */
26
+ async function waitForEventsApi(timeoutMs = 60_000): Promise<"ready" | "foreign"> {
27
+ const deadline = Date.now() + timeoutMs;
28
+ while (Date.now() < deadline) {
29
+ try {
30
+ const response = await fetch(`${BASE}/api/events`, {
31
+ headers: { Authorization: `Bearer ${TEST_API_KEY}` },
32
+ });
33
+ const body = (await response.json().catch(() => null)) as { events?: unknown } | null;
34
+ return response.status === 200 && Array.isArray(body?.events) ? "ready" : "foreign";
35
+ } catch {
36
+ // Connection refused: the child has not bound the port yet.
37
+ }
38
+ await Bun.sleep(50);
39
+ }
40
+ throw new Error(`Events API did not become ready on ${BASE} within ${timeoutMs}ms`);
41
+ }
42
+
43
+ async function cleanupTestDb(): Promise<void> {
44
+ for (const suffix of ["", "-wal", "-shm"]) {
45
+ try {
46
+ await unlink(`${TEST_DB_PATH}${suffix}`);
47
+ } catch {}
48
+ }
49
+ }
50
+
13
51
  async function api(
14
52
  method: string,
15
53
  path: string,
@@ -36,31 +74,40 @@ async function api(
36
74
  const get = (p: string) => api("GET", p);
37
75
  const post = (p: string, body?: unknown) => api("POST", p, { body });
38
76
 
77
+ const PORT_CLAIM_ATTEMPTS = 3;
78
+
39
79
  beforeAll(async () => {
40
- TEST_PORT = await getFreePort();
41
- BASE = `http://localhost:${TEST_PORT}`;
80
+ for (let attempt = 1; attempt <= PORT_CLAIM_ATTEMPTS; attempt++) {
81
+ TEST_PORT = await getFreePort();
82
+ BASE = `http://localhost:${TEST_PORT}`;
83
+ await cleanupTestDb();
42
84
 
43
- try {
44
- await unlink(TEST_DB_PATH);
45
- } catch {}
85
+ serverProc = Bun.spawn(["bun", "src/http.ts"], {
86
+ cwd: `${import.meta.dir}/../..`,
87
+ env: {
88
+ ...process.env,
89
+ PORT: String(TEST_PORT),
90
+ DATABASE_PATH: TEST_DB_PATH,
91
+ API_KEY: TEST_API_KEY,
92
+ CAPABILITIES: "core",
93
+ SLACK_BOT_TOKEN: "",
94
+ GITHUB_WEBHOOK_SECRET: "",
95
+ AGENTMAIL_API_KEY: "",
96
+ },
97
+ stdout: "ignore",
98
+ stderr: "ignore",
99
+ });
46
100
 
47
- serverProc = Bun.spawn(["bun", "src/http.ts"], {
48
- cwd: `${import.meta.dir}/../..`,
49
- env: {
50
- ...process.env,
51
- PORT: String(TEST_PORT),
52
- DATABASE_PATH: TEST_DB_PATH,
53
- API_KEY: TEST_API_KEY,
54
- CAPABILITIES: "core",
55
- SLACK_BOT_TOKEN: "",
56
- GITHUB_WEBHOOK_SECRET: "",
57
- AGENTMAIL_API_KEY: "",
58
- },
59
- stdout: "ignore",
60
- stderr: "ignore",
61
- });
101
+ // A slow boot throws here and fails the suite: afterAll still kills the child.
102
+ if ((await waitForEventsApi()) === "ready") return;
62
103
 
63
- await waitForServer(`${BASE}/health`);
104
+ // Another server owns this port. Drop the child and try a fresh port.
105
+ serverProc.kill();
106
+ await serverProc.exited.catch(() => {});
107
+ }
108
+ throw new Error(
109
+ `another server claimed the events-http port on ${PORT_CLAIM_ATTEMPTS} consecutive attempts`,
110
+ );
64
111
  }, SERVER_BOOT_HOOK_TIMEOUT_MS);
65
112
 
66
113
  afterAll(async () => {
@@ -71,11 +118,7 @@ afterAll(async () => {
71
118
  } catch {}
72
119
  }
73
120
  await Bun.sleep(50);
74
- try {
75
- await unlink(TEST_DB_PATH);
76
- await unlink(`${TEST_DB_PATH}-wal`);
77
- await unlink(`${TEST_DB_PATH}-shm`);
78
- } catch {}
121
+ await cleanupTestDb();
79
122
  });
80
123
 
81
124
  describe("POST /api/events — single event", () => {