@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,275 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { isEnvFlagEnabled } from "../utils/env-flag";
3
+ import { getDbClient } from "./db";
4
+ import { MAX_DB_RETENTION_DAYS } from "./swarm-config-guard";
5
+
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const RETENTION_INTERVAL_MS = 60 * 60 * 1000;
8
+ const BATCH_SIZE = 5_000;
9
+ const PER_TABLE_BATCH_CAP = 40;
10
+ const WALL_CLOCK_CAP_MS = 60_000;
11
+ const YIELD_MS = 5;
12
+
13
+ /**
14
+ * This is deliberately a closed list. Neither table nor column names may come
15
+ * from operator configuration: a retention policy must never target a table
16
+ * that code review has not explicitly approved as safe to delete from.
17
+ */
18
+ export const DB_RETENTION_TABLES = [
19
+ {
20
+ table: "session_logs",
21
+ timeColumn: "createdAt",
22
+ envKey: "SESSION_LOG_RETENTION_DAYS",
23
+ metricsKey: "sessionLogs",
24
+ },
25
+ {
26
+ table: "agent_log",
27
+ timeColumn: "createdAt",
28
+ envKey: "AGENT_LOG_RETENTION_DAYS",
29
+ metricsKey: "agentLog",
30
+ },
31
+ {
32
+ table: "events",
33
+ timeColumn: "createdAt",
34
+ envKey: "EVENTS_RETENTION_DAYS",
35
+ metricsKey: "events",
36
+ },
37
+ ] as const;
38
+
39
+ type RetentionTable = (typeof DB_RETENTION_TABLES)[number];
40
+ type RetentionMetricsKey = RetentionTable["metricsKey"];
41
+
42
+ export type DbRetentionTableStats = {
43
+ at: string;
44
+ /** Deleted rows, or rows that would be deleted when dryRun is true. */
45
+ rowsDeleted: number;
46
+ batches: number;
47
+ durationMs: number;
48
+ dryRun: boolean;
49
+ cumulativeRowsDeleted: number;
50
+ };
51
+
52
+ export type DbRetentionStats = Partial<Record<RetentionMetricsKey, DbRetentionTableStats>>;
53
+
54
+ /** Test-only controls; production callers always use the bounded defaults above. */
55
+ export type DbRetentionTickOptions = {
56
+ now?: Date;
57
+ batchSize?: number;
58
+ perTableBatchCap?: number;
59
+ wallClockCapMs?: number;
60
+ };
61
+
62
+ let retentionTimer: ReturnType<typeof setInterval> | null = null;
63
+ let retentionTickPromise: Promise<void> | null = null;
64
+ let retentionAbortController: AbortController | null = null;
65
+ let retentionStats: DbRetentionStats = {};
66
+ let cumulativeRowsDeleted: Partial<Record<RetentionMetricsKey, number>> = {};
67
+
68
+ // Timers must not inherit an open database transaction from their caller.
69
+ const scheduleContextFree = AsyncLocalStorage.snapshot();
70
+
71
+ function readPositiveIntEnv(key: string, env: NodeJS.ProcessEnv = process.env): number | null {
72
+ const raw = env[key]?.trim();
73
+ if (!raw || !/^\d+$/.test(raw)) return null;
74
+ const value = Number(raw);
75
+ return Number.isSafeInteger(value) && value >= 1 && value <= MAX_DB_RETENTION_DAYS ? value : null;
76
+ }
77
+
78
+ function yieldTick(): Promise<void> {
79
+ return new Promise((resolve) => setTimeout(resolve, YIELD_MS));
80
+ }
81
+
82
+ function retentionDays(table: RetentionTable): number | null {
83
+ return readPositiveIntEnv(table.envKey);
84
+ }
85
+
86
+ function dryRunEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
87
+ const raw = env.DB_RETENTION_DRY_RUN;
88
+ if (raw === undefined || raw.trim() === "") return false;
89
+ return isEnvFlagEnabled("DB_RETENTION_DRY_RUN", true, env);
90
+ }
91
+
92
+ async function sweepTable(
93
+ table: RetentionTable,
94
+ cutoff: string,
95
+ dryRun: boolean,
96
+ deadline: number,
97
+ batchSize: number,
98
+ perTableBatchCap: number,
99
+ signal: AbortSignal,
100
+ ): Promise<{ rowsDeleted: number; batches: number; complete: boolean }> {
101
+ const client = getDbClient();
102
+ let rowsDeleted = 0;
103
+ let batches = 0;
104
+ let pagesFetched = 0;
105
+ let cursor: string | null = null;
106
+ let complete = true;
107
+ let exhausted = false;
108
+ // Bounded on pages fetched, not on delete batches: a sparse or no-match
109
+ // table would otherwise walk every primary-key page until the wall-clock
110
+ // deadline, since batches only increments when a page has expired rows.
111
+ const pageLimit = dryRun ? Number.POSITIVE_INFINITY : perTableBatchCap;
112
+ while (!signal.aborted && pagesFetched < pageLimit && Date.now() < deadline) {
113
+ // Page by the primary key, not by the retention predicate. A query that
114
+ // filters on createdAt while ordering by id can scan the whole PK index
115
+ // before finding LIMIT matches when eligible rows are sparse. This query
116
+ // visits at most batchSize rows per statement; cutoff filtering happens in
117
+ // memory and deletes are restricted to the IDs in that bounded page.
118
+ const page: Array<{ id: string; createdAt: string }> = await client.query<{
119
+ id: string;
120
+ createdAt: string;
121
+ }>(
122
+ `SELECT id, ${table.timeColumn} AS createdAt
123
+ FROM ${table.table}
124
+ ${cursor === null ? "" : "WHERE id > ?"}
125
+ ORDER BY id
126
+ LIMIT ?`,
127
+ cursor === null ? [batchSize] : [cursor, batchSize],
128
+ );
129
+ pagesFetched += 1;
130
+ if (Date.now() >= deadline || signal.aborted) {
131
+ complete = false;
132
+ break;
133
+ }
134
+ if (page.length === 0) {
135
+ exhausted = true;
136
+ break;
137
+ }
138
+
139
+ cursor = page[page.length - 1]?.id ?? null;
140
+ const expiredIds = page.filter((row) => row.createdAt < cutoff).map((row) => row.id);
141
+ if (expiredIds.length > 0) {
142
+ if (dryRun) {
143
+ rowsDeleted += expiredIds.length;
144
+ } else {
145
+ // IDs came directly from the bounded page and are placeholders, so
146
+ // this statement is bounded by the page size and remains retryable.
147
+ const placeholders = expiredIds.map(() => "?").join(", ");
148
+ const result = await client.run(
149
+ `DELETE FROM ${table.table} WHERE id IN (${placeholders})`,
150
+ expiredIds,
151
+ );
152
+ rowsDeleted += result.changes;
153
+ }
154
+ batches += 1;
155
+ }
156
+ if (page.length < batchSize) {
157
+ exhausted = true;
158
+ break;
159
+ }
160
+ await yieldTick();
161
+ }
162
+ // A dry-run count is only useful if the full table was scanned. Never
163
+ // publish a partial count when the wall-clock budget or shutdown interrupted
164
+ // the keyset scan.
165
+ return { rowsDeleted, batches, complete: !dryRun || (complete && exhausted) };
166
+ }
167
+
168
+ /** Run one bounded sweep over every enabled table. Failures are isolated per table. */
169
+ export function runDbRetentionTick(options: DbRetentionTickOptions = {}): Promise<void> {
170
+ if (retentionTickPromise) return retentionTickPromise;
171
+
172
+ const abortController = new AbortController();
173
+ retentionAbortController = abortController;
174
+ const promise = Promise.resolve().then(async () => {
175
+ try {
176
+ const tickStartedAt = Date.now();
177
+ const cutoffBase = options.now ?? new Date(tickStartedAt);
178
+ const dryRun = dryRunEnabled();
179
+ const batchSize = options.batchSize ?? BATCH_SIZE;
180
+ const perTableBatchCap = options.perTableBatchCap ?? PER_TABLE_BATCH_CAP;
181
+ const deadline = tickStartedAt + (options.wallClockCapMs ?? WALL_CLOCK_CAP_MS);
182
+ let deletedAny = false;
183
+
184
+ for (const table of DB_RETENTION_TABLES) {
185
+ if (abortController.signal.aborted || Date.now() >= deadline) break;
186
+ const days = retentionDays(table);
187
+ if (days === null) continue;
188
+
189
+ const startedAt = Date.now();
190
+ try {
191
+ const cutoff = new Date(cutoffBase.getTime() - days * DAY_MS).toISOString();
192
+ const result = await sweepTable(
193
+ table,
194
+ cutoff,
195
+ dryRun,
196
+ deadline,
197
+ batchSize,
198
+ perTableBatchCap,
199
+ abortController.signal,
200
+ );
201
+ if (dryRun && !result.complete) {
202
+ throw new Error("dry-run count stopped before completion");
203
+ }
204
+ deletedAny ||= !dryRun && result.rowsDeleted > 0;
205
+ const cumulative =
206
+ (cumulativeRowsDeleted[table.metricsKey] ?? 0) + (dryRun ? 0 : result.rowsDeleted);
207
+ cumulativeRowsDeleted[table.metricsKey] = cumulative;
208
+ retentionStats[table.metricsKey] = {
209
+ at: new Date().toISOString(),
210
+ rowsDeleted: result.rowsDeleted,
211
+ batches: result.batches,
212
+ durationMs: Date.now() - startedAt,
213
+ dryRun,
214
+ cumulativeRowsDeleted: cumulative,
215
+ };
216
+ if (result.rowsDeleted > 0) {
217
+ console.log(
218
+ `[db-retention] ${table.table}: ${dryRun ? "would delete" : "deleted"} ${result.rowsDeleted} row(s) in ${result.batches} batch(es) after ${Date.now() - startedAt}ms`,
219
+ );
220
+ }
221
+ } catch (err) {
222
+ console.error(`[db-retention] ${table.table} sweep failed:`, (err as Error).message);
223
+ }
224
+ }
225
+
226
+ if (deletedAny && !abortController.signal.aborted) {
227
+ try {
228
+ // Harmless when auto_vacuum is not INCREMENTAL; never run a blocking VACUUM here.
229
+ await getDbClient().run("PRAGMA incremental_vacuum(2000)");
230
+ } catch (err) {
231
+ console.error("[db-retention] incremental vacuum failed:", (err as Error).message);
232
+ }
233
+ }
234
+ } finally {
235
+ if (retentionAbortController === abortController) retentionAbortController = null;
236
+ retentionTickPromise = null;
237
+ }
238
+ });
239
+ retentionTickPromise = promise;
240
+ return promise;
241
+ }
242
+
243
+ export function getDbRetentionStats(): DbRetentionStats {
244
+ return { ...retentionStats };
245
+ }
246
+
247
+ /** Start the hourly sweep. The first tick runs immediately. */
248
+ export async function startDbRetention(intervalMs = RETENTION_INTERVAL_MS): Promise<void> {
249
+ if (retentionTimer) return;
250
+ const configured = DB_RETENTION_TABLES.map(
251
+ (table) => `${table.table}=${retentionDays(table) ?? "disabled"}`,
252
+ ).join(", ");
253
+ console.log(`[db-retention] starting (${configured}, dryRun=${dryRunEnabled()})`);
254
+ retentionTimer = scheduleContextFree(() =>
255
+ setInterval(() => void runDbRetentionTick(), intervalMs),
256
+ );
257
+ if (typeof retentionTimer.unref === "function") retentionTimer.unref();
258
+ await runDbRetentionTick();
259
+ }
260
+
261
+ export async function stopDbRetention(): Promise<void> {
262
+ if (retentionTimer) {
263
+ clearInterval(retentionTimer);
264
+ retentionTimer = null;
265
+ }
266
+ retentionAbortController?.abort();
267
+ await retentionTickPromise;
268
+ }
269
+
270
+ /** Test hook to prevent module state leaking between Bun test files. */
271
+ export async function resetDbRetentionForTests(): Promise<void> {
272
+ await stopDbRetention();
273
+ retentionStats = {};
274
+ cumulativeRowsDeleted = {};
275
+ }
package/src/be/db.ts CHANGED
@@ -4782,13 +4782,19 @@ export async function createTaskExtended(
4782
4782
  let requestedByUserIdInherited = false;
4783
4783
  const id = crypto.randomUUID();
4784
4784
  const now = new Date().toISOString();
4785
- const status: AgentTaskStatus = options?.offeredTo
4786
- ? "offered"
4787
- : options?.agentId
4788
- ? "pending"
4789
- : options?.status === "backlog"
4790
- ? "backlog"
4791
- : "unassigned";
4785
+ // `status: "draft"` wins over offeredTo/agentId — a draft task keeps its
4786
+ // intended owner/offer for when it's promoted (#1240), but must not be
4787
+ // dispatch-eligible while attachments are still uploading.
4788
+ const status: AgentTaskStatus =
4789
+ options?.status === "draft"
4790
+ ? "draft"
4791
+ : options?.offeredTo
4792
+ ? "offered"
4793
+ : options?.agentId
4794
+ ? "pending"
4795
+ : options?.status === "backlog"
4796
+ ? "backlog"
4797
+ : "unassigned";
4792
4798
 
4793
4799
  // Inherit Slack/AgentMail metadata from parent task (unless explicitly overridden)
4794
4800
  if (options?.parentTaskId) {
@@ -5355,6 +5361,75 @@ export async function moveTaskFromBacklog(taskId: string): Promise<AgentTask | n
5355
5361
  return row ? rowToAgentTask(row) : null;
5356
5362
  }
5357
5363
 
5364
+ /**
5365
+ * Promote a task out of `draft` status into whichever status it should have
5366
+ * had at creation time — `offered` if it was offered to an agent, `pending`
5367
+ * if it has an owning agent (the common UI-composer case, which defaults to
5368
+ * Lead), otherwise `unassigned`. `draft` exists solely to keep a task
5369
+ * dispatch-ineligible while its UI-uploaded attachments are still in flight
5370
+ * (#1240); this is the promote half of that window. Called once the upload
5371
+ * batch settles — success, partial failure, or total failure all promote, a
5372
+ * draft is never left stranded by upload errors — or by
5373
+ * `promoteAbandonedDraftTasks` for drafts nobody ever promoted.
5374
+ */
5375
+ export async function promoteDraftTask(taskId: string): Promise<AgentTask | null> {
5376
+ const now = new Date().toISOString();
5377
+ const row = await getDbClient().get<AgentTaskRow>(
5378
+ `UPDATE agent_tasks
5379
+ SET status = CASE
5380
+ WHEN offeredTo IS NOT NULL THEN 'offered'
5381
+ WHEN agentId IS NOT NULL THEN 'pending'
5382
+ ELSE 'unassigned'
5383
+ END,
5384
+ lastUpdatedAt = ?
5385
+ WHERE id = ? AND status = 'draft'
5386
+ RETURNING *`,
5387
+ [now, taskId],
5388
+ );
5389
+
5390
+ if (row) {
5391
+ try {
5392
+ await createLogEntry({
5393
+ eventType: "task_status_change",
5394
+ taskId,
5395
+ agentId: row.agentId ?? undefined,
5396
+ oldValue: "draft",
5397
+ newValue: row.status,
5398
+ });
5399
+ } catch {}
5400
+ }
5401
+
5402
+ return row ? rowToAgentTask(row) : null;
5403
+ }
5404
+
5405
+ /**
5406
+ * Heartbeat sweep: promote drafts abandoned mid-upload (tab closed, network
5407
+ * dropped) instead of leaving them permanently dispatch-ineligible — the
5408
+ * composer's UI-driven promote (`promoteDraftTask`) never fires if the user
5409
+ * never comes back. Same target-status logic, applied in bulk by elapsed
5410
+ * time. Default 5 minutes is well above the tens-of-seconds worst-case
5411
+ * upload-batch duration seen against slow storage (#1226) while still
5412
+ * surfacing an abandoned session to its owner promptly.
5413
+ */
5414
+ export async function promoteAbandonedDraftTasks(timeoutMinutes: number = 5): Promise<number> {
5415
+ const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString();
5416
+ const now = new Date().toISOString();
5417
+
5418
+ const result = await getDbClient().run(
5419
+ `UPDATE agent_tasks
5420
+ SET status = CASE
5421
+ WHEN offeredTo IS NOT NULL THEN 'offered'
5422
+ WHEN agentId IS NOT NULL THEN 'pending'
5423
+ ELSE 'unassigned'
5424
+ END,
5425
+ lastUpdatedAt = ?
5426
+ WHERE status = 'draft' AND lastUpdatedAt < ?`,
5427
+ [now, cutoffTime],
5428
+ );
5429
+
5430
+ return result.changes;
5431
+ }
5432
+
5358
5433
  /**
5359
5434
  * Release tasks that have been in 'reviewing' status for too long.
5360
5435
  * Returns them to 'offered' status for retry.
@@ -39,6 +39,9 @@ type ConfigValidator = (value: unknown) => string | null;
39
39
 
40
40
  const BOOLEAN_LITERALS = ["true", "false", "1", "0"];
41
41
 
42
+ /** A conservative window that remains representable by JavaScript Date arithmetic. */
43
+ export const MAX_DB_RETENTION_DAYS = 1_000_000;
44
+
42
45
  /** Build `{ KEY: validator }` entries accepting only boolean literals. */
43
46
  function booleanValidators(keys: string[]): Record<string, ConfigValidator> {
44
47
  const message = (key: string) =>
@@ -81,6 +84,27 @@ function integerValidators(keys: string[], min: number): Record<string, ConfigVa
81
84
  );
82
85
  }
83
86
 
87
+ /** Build `{ KEY: validator }` entries accepting integers inside a closed range. */
88
+ function boundedIntegerValidators(
89
+ keys: string[],
90
+ min: number,
91
+ max: number,
92
+ ): Record<string, ConfigValidator> {
93
+ return Object.fromEntries(
94
+ keys.map((key) => [
95
+ key,
96
+ (value: unknown) => {
97
+ const str = String(value).trim();
98
+ const parsed = Number(str);
99
+ if (!/^\d+$/.test(str) || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
100
+ return `Invalid ${key} (must be an integer between ${min} and ${max})`;
101
+ }
102
+ return null;
103
+ },
104
+ ]),
105
+ );
106
+ }
107
+
84
108
  function validateFloatRange(
85
109
  key: string,
86
110
  value: unknown,
@@ -163,6 +187,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
163
187
  "ANONYMIZED_TELEMETRY",
164
188
  "SWARM_HIDE_CLOUD_PROMO",
165
189
  "DB_QUERY_BOUNDED_ENABLED",
190
+ "DB_RETENTION_DRY_RUN",
166
191
  ]),
167
192
  ...enumValidator("SLACK_THREAD_STEERING", ["lead", "all"]),
168
193
  ...enumValidator("SLACK_THREAD_STEERING_MODE", ["steer", "queue"]),
@@ -191,6 +216,11 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
191
216
  ],
192
217
  1,
193
218
  ),
219
+ ...boundedIntegerValidators(
220
+ ["SESSION_LOG_RETENTION_DAYS", "AGENT_LOG_RETENTION_DAYS", "EVENTS_RETENTION_DAYS"],
221
+ 1,
222
+ MAX_DB_RETENTION_DAYS,
223
+ ),
194
224
  // 0 is meaningful here: "auto-assign nothing this sweep".
195
225
  ...integerValidators(["HEARTBEAT_MAX_AUTO_ASSIGN"], 0),
196
226
  MEMORY_MIN_SIMILARITY: (value) =>
@@ -29,6 +29,7 @@ import {
29
29
  isAgentEligibleForTask,
30
30
  isPoolAffinityEnforcementEnabled,
31
31
  MAX_EMPTY_POLLS,
32
+ promoteAbandonedDraftTasks,
32
33
  releaseStaleMentionProcessing,
33
34
  releaseStaleOfferedTasksForOfflineAgents,
34
35
  releaseStaleProcessingInbox,
@@ -116,6 +117,16 @@ export const STEERING_STALL_GRACE_MIN = Number(process.env.HEARTBEAT_STEERING_GR
116
117
  /** Stale resource cleanup threshold (minutes) */
117
118
  const STALE_CLEANUP_THRESHOLD_MINUTES = Number(process.env.HEARTBEAT_STALE_CLEANUP_MIN) || 30;
118
119
 
120
+ /**
121
+ * Abandoned-draft promotion threshold (minutes, #1240). Deliberately much
122
+ * shorter than STALE_CLEANUP_THRESHOLD_MINUTES: a draft only exists while a
123
+ * UI-composer attachment batch uploads, which even against slow storage
124
+ * (#1226) took tens of seconds — 5 minutes is generous headroom above that
125
+ * worst case while still surfacing an abandoned session (tab closed
126
+ * mid-upload) to its owner within one coffee break, not half an hour.
127
+ */
128
+ const DRAFT_TASK_TIMEOUT_MINUTES = Number(process.env.HEARTBEAT_DRAFT_TASK_TIMEOUT_MIN) || 5;
129
+
119
130
  /** Max pool tasks to auto-assign per sweep */
120
131
  function maxAutoAssignPerSweep(): number {
121
132
  const raw = process.env.HEARTBEAT_MAX_AUTO_ASSIGN?.trim();
@@ -221,6 +232,7 @@ export interface HeartbeatFindings {
221
232
  workflowRuns: number;
222
233
  staleRuntimes: number;
223
234
  staleOfferedTasks: number;
235
+ abandonedDraftTasks: number;
224
236
  };
225
237
  }
226
238
 
@@ -296,6 +308,7 @@ export async function codeLevelTriage(): Promise<HeartbeatFindings> {
296
308
  workflowRuns: 0,
297
309
  staleRuntimes: 0,
298
310
  staleOfferedTasks: 0,
311
+ abandonedDraftTasks: 0,
299
312
  },
300
313
  };
301
314
 
@@ -1026,6 +1039,9 @@ async function cleanupStaleResources(findings: HeartbeatFindings): Promise<void>
1026
1039
  findings.staleCleanup.inboxProcessing = await releaseStaleProcessingInbox(
1027
1040
  STALE_CLEANUP_THRESHOLD_MINUTES,
1028
1041
  );
1042
+ findings.staleCleanup.abandonedDraftTasks = await promoteAbandonedDraftTasks(
1043
+ DRAFT_TASK_TIMEOUT_MINUTES,
1044
+ );
1029
1045
  // DES-523 Phase 3: escalate pinned crash-recovery resumes that were never
1030
1046
  // reclaimed within the grace window to a Lead re-delegation decision.
1031
1047
  await escalateUnreclaimedResumes(findings);
@@ -1331,6 +1347,7 @@ export async function runHeartbeatSweep(): Promise<void> {
1331
1347
  workflowRuns: 0,
1332
1348
  staleRuntimes: 0,
1333
1349
  staleOfferedTasks: 0,
1350
+ abandonedDraftTasks: 0,
1334
1351
  },
1335
1352
  };
1336
1353
  // Expiry runs even on a cleanup-only tick: an idle agent whose runtime
@@ -1396,6 +1413,7 @@ function logFindings(findings: HeartbeatFindings): void {
1396
1413
  inboxProcessing,
1397
1414
  workflowRuns,
1398
1415
  staleOfferedTasks,
1416
+ abandonedDraftTasks,
1399
1417
  } = findings.staleCleanup;
1400
1418
  const totalCleanup =
1401
1419
  sessions + reviewingTasks + mentionProcessing + inboxProcessing + workflowRuns;
@@ -1405,6 +1423,9 @@ function logFindings(findings: HeartbeatFindings): void {
1405
1423
  if (staleOfferedTasks > 0) {
1406
1424
  parts.push(`stale_offers_released=${staleOfferedTasks}`);
1407
1425
  }
1426
+ if (abandonedDraftTasks > 0) {
1427
+ parts.push(`abandoned_drafts_promoted=${abandonedDraftTasks}`);
1428
+ }
1408
1429
 
1409
1430
  if (parts.length > 0) {
1410
1431
  console.log(`[Heartbeat] Sweep complete: ${parts.join(", ")}`);
package/src/http/index.ts CHANGED
@@ -9,6 +9,7 @@ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/se
9
9
  import { getEnabledCapabilities, hasCapability } from "@/server";
10
10
  import { initAgentMail } from "../agentmail";
11
11
  import { closeDb, getSwarmConfigs, upsertSwarmConfig } from "../be/db";
12
+ import { startDbRetention, stopDbRetention } from "../be/db-retention";
12
13
  import {
13
14
  enqueueAuditRow,
14
15
  flushAuditBuffer,
@@ -107,6 +108,7 @@ import {
107
108
  parseQueryParams,
108
109
  safeRequestUrlForLog,
109
110
  setCorsHeaders,
111
+ wireHttpSpanLifecycle,
110
112
  } from "./utils";
111
113
  import { handleWebhooks } from "./webhooks";
112
114
  import { handleWorkflowEvents } from "./workflow-events";
@@ -213,7 +215,6 @@ const transportActivityUser: McpTransportActivity = globalState.__transportActiv
213
215
  const httpServer = createHttpServer(async (req, res) => {
214
216
  const startTime = performance.now();
215
217
  let statusCode = 200;
216
- let spanEnded = false;
217
218
 
218
219
  // Wrap writeHead to capture status code
219
220
  const originalWriteHead = res.writeHead.bind(res);
@@ -245,51 +246,43 @@ const httpServer = createHttpServer(async (req, res) => {
245
246
  await withRemoteContext(req.headers as Record<string, unknown>, async () => {
246
247
  const reqPath = req.url?.split("?")[0] ?? "";
247
248
  const pathSegments = getPathSegments(req.url || "");
249
+ const hasQueryString = (req.url ?? "").includes("?");
250
+ const hasTrailingSlash = reqPath.length > 1 && reqPath.endsWith("/");
248
251
  const skipSpan = reqPath === "/api/poll" && !isPollTracingEnabled();
249
252
  // Per OTel HTTP semantic conventions: span name is `{METHOD} {route-template}`
250
253
  // and `http.route` carries the bounded-cardinality template so SigNoz can
251
254
  // group/filter/aggregate by endpoint as a first-class field. `http.route` is
252
255
  // omitted (not fabricated) for unmatched core/MCP/404 paths. Raw path stays
253
256
  // on `url.path`.
254
- const { spanName, httpRoute } = describeRequestRoute(req.method, pathSegments);
257
+ const { spanName, httpRoute } = describeRequestRoute(
258
+ req.method,
259
+ pathSegments,
260
+ hasQueryString,
261
+ hasTrailingSlash,
262
+ );
255
263
  // Standard OTel HTTP server semconv attributes — host, scheme, protocol
256
264
  // version, user-agent (the method/path/route/status are set inline below).
257
265
  const semconv = httpServerSemconvAttributes(req);
258
266
  const span = skipSpan
259
267
  ? null
260
- : startSpan(spanName, {
261
- "http.request.method": req.method ?? "",
262
- "url.path": reqPath,
263
- "url.scheme": semconv["url.scheme"],
264
- "http.route": httpRoute,
265
- "server.address": semconv["server.address"],
266
- "network.protocol.version": semconv["network.protocol.version"],
267
- "user_agent.original": semconv["user_agent.original"],
268
- "agent.id": req.headers["x-agent-id"] as string | undefined,
269
- "agentswarm.component": "api",
270
- });
268
+ : startSpan(
269
+ spanName,
270
+ {
271
+ "http.request.method": req.method ?? "",
272
+ "url.path": reqPath,
273
+ "url.scheme": semconv["url.scheme"],
274
+ "http.route": httpRoute,
275
+ "server.address": semconv["server.address"],
276
+ "network.protocol.version": semconv["network.protocol.version"],
277
+ "user_agent.original": semconv["user_agent.original"],
278
+ "agent.id": req.headers["x-agent-id"] as string | undefined,
279
+ "agentswarm.component": "api",
280
+ },
281
+ { kind: "server" },
282
+ );
271
283
 
272
284
  if (span) {
273
- res.on("finish", () => {
274
- if (spanEnded) return;
275
- spanEnded = true;
276
- span.setAttributes({
277
- "http.response.status_code": statusCode,
278
- "agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
279
- });
280
- if (statusCode >= 500) {
281
- span.setStatus({ code: 2, message: `HTTP ${statusCode}` });
282
- }
283
- span.end();
284
- });
285
-
286
- res.on("error", (err) => {
287
- if (spanEnded) return;
288
- spanEnded = true;
289
- span.recordException(err);
290
- span.setStatus({ code: 2, message: err.message });
291
- span.end();
292
- });
285
+ wireHttpSpanLifecycle(res, span, () => statusCode, startTime);
293
286
  }
294
287
 
295
288
  // Run request handling inside the HTTP span's active context so any spans
@@ -449,6 +442,9 @@ async function shutdown() {
449
442
  // Stop memory expired-row garbage collector
450
443
  stopMemoryGc();
451
444
 
445
+ // Stop opt-in session, agent-log, and event retention before closing SQLite.
446
+ await stopDbRetention();
447
+
452
448
  // Stop scratch-script retention garbage collector
453
449
  stopScratchScriptGc();
454
450
 
@@ -693,6 +689,10 @@ httpServer
693
689
  // Start expired-memory garbage collector (1-hour tick, immediate first run)
694
690
  await startMemoryGc();
695
691
 
692
+ // Start the opt-in DB retention sweep after config hydration. Every key is
693
+ // read on each tick, so config reloads take effect without a restart.
694
+ await startDbRetention();
695
+
696
696
  // (RBAC audit sink is wired pre-listen — see above httpServer.listen.)
697
697
 
698
698
  // Background backfill: re-embed any agent_memory rows with wrong-dimension