@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
@@ -179,19 +179,85 @@ export interface RequestRouteDescriptor {
179
179
  * `{METHOD} {route-template}`.
180
180
  *
181
181
  * - Matched `route()` handler: `GET /api/tasks/{id}`
182
- * - Unmatched (core /health /ping /me, MCP transport, 404s): `GET /<first-segment>`
182
+ * - Static core path (see `CORE_ROUTE_TEMPLATES`): `GET /health`, `POST /mcp`
183
+ * - Otherwise unmatched (deeper MCP paths, 404s): `GET /<first-segment>`
183
184
  * - Root or empty path: bare `GET`
184
185
  */
185
186
  spanName: string;
186
187
  /**
187
188
  * Value for the `http.route` span attribute — the bounded-cardinality route
188
- * template (e.g. `/api/tasks/{id}`). Set ONLY when a `route()` handler
189
- * matched; left `undefined` for core/MCP/404 paths so callers omit the
190
- * attribute rather than fabricating a value.
189
+ * template (e.g. `/api/tasks/{id}`). Set when a `route()` handler matched,
190
+ * or when the request matches a static core path in `CORE_ROUTE_TEMPLATES`
191
+ * on a method and query-string form that handler actually accepts. Left
192
+ * `undefined` for genuinely unmatched paths so callers omit the attribute
193
+ * rather than fabricating a value.
191
194
  */
192
195
  httpRoute?: string;
193
196
  }
194
197
 
198
+ /**
199
+ * Static literal paths handled outside the `route()` factory (core.ts, mcp.ts)
200
+ * that would otherwise fall through to the low-cardinality `{METHOD}
201
+ * /{first-segment}` fallback below. Each of these is a single, fixed path —
202
+ * not a template with params — so mapping it to `http.route` is not
203
+ * fabricating a value, just naming a route that doesn't happen to go through
204
+ * `route()`. `/health` in particular is high-volume liveness-probe traffic;
205
+ * leaving it in the anonymous `GET` bucket makes that bucket useless to
206
+ * aggregate on.
207
+ *
208
+ * `methods` mirrors the method gate `handleCore` actually applies to that
209
+ * path — `undefined` means `handleCore` has no method check there (the
210
+ * handler runs for any verb). A method NOT in this list is a path
211
+ * `handleCore` 404s, so it must not carry `http.route` either — otherwise the
212
+ * attribute lies about a request that never matched a real handler.
213
+ *
214
+ * `toleratesQueryString` mirrors whether `handleCore` matches the path when
215
+ * the raw URL carries a `?`. Most of these entries compare the raw URL with
216
+ * strict equality (`req.url === "/health"` and similarly for
217
+ * `openapi.json`/`docs`/`internal/reload-config`/`mcp`/`mcp-user`), so
218
+ * `GET /health?t=123` 404s in `handleCore` even though `pathSegments` still
219
+ * resolves to `health`. `/me` and `/cancelled-tasks` explicitly accept the
220
+ * `?`-suffixed form (`req.url === "/me" || req.url?.startsWith("/me?")`), so
221
+ * those two tolerate a query string. Default is `false` (no tolerance) —
222
+ * verified against every comparison in `src/http/core.ts`, `src/http/mcp.ts`,
223
+ * and `src/http/mcp-user.ts`.
224
+ *
225
+ * `toleratesTrailingSlash` mirrors the same strict-equality problem for a
226
+ * trailing `/`: `getPathSegments("/health/")` returns `["health"]` (trailing
227
+ * empty segment is filtered), so without this gate a `GET /health/` request —
228
+ * which `handleCore` 404s on `req.url === "/health"` strict equality — would
229
+ * still get `http.route: "/health"` fabricated onto its span. `docs` is the
230
+ * one intentional exception: `handleCore` explicitly accepts
231
+ * `req.url === "/docs" || req.url === "/docs/"`. Default is `false`.
232
+ *
233
+ * Keyed by the joined path segments (no leading slash, no query string) so a
234
+ * deeper unmatched path under the same first segment — e.g.
235
+ * `/mcp/<session>/messages` — does NOT match and correctly falls through to
236
+ * the bounded first-segment fallback instead of fabricating a template.
237
+ */
238
+ const CORE_ROUTE_TEMPLATES: Record<
239
+ string,
240
+ {
241
+ path: string;
242
+ methods?: string[];
243
+ toleratesQueryString?: boolean;
244
+ toleratesTrailingSlash?: boolean;
245
+ }
246
+ > = {
247
+ health: { path: "/health" },
248
+ "openapi.json": { path: "/openapi.json" },
249
+ docs: { path: "/docs", toleratesTrailingSlash: true },
250
+ me: { path: "/me", methods: ["GET"], toleratesQueryString: true },
251
+ "cancelled-tasks": {
252
+ path: "/cancelled-tasks",
253
+ methods: ["GET"],
254
+ toleratesQueryString: true,
255
+ },
256
+ "internal/reload-config": { path: "/internal/reload-config", methods: ["POST"] },
257
+ mcp: { path: "/mcp", methods: ["GET", "POST", "DELETE"] },
258
+ "mcp-user": { path: "/mcp-user", methods: ["GET", "POST", "DELETE"] },
259
+ };
260
+
195
261
  /**
196
262
  * Describe an inbound HTTP request for OTel: a low-cardinality span name plus
197
263
  * the `http.route` attribute value (per the HTTP server semantic conventions).
@@ -199,14 +265,37 @@ export interface RequestRouteDescriptor {
199
265
  * Never embeds raw path params or query strings — the goal is one span name and
200
266
  * one `http.route` value per endpoint so SigNoz can group/filter/aggregate by
201
267
  * them. The raw path is still preserved on the `url.path` attribute.
268
+ *
269
+ * `hasQueryString` reflects whether the raw request URL carried a `?`. A
270
+ * core-route entry without `toleratesQueryString` is a path `handleCore`
271
+ * 404s once a query string is present (see `CORE_ROUTE_TEMPLATES`), so that
272
+ * combination must not carry `http.route` either — same "omitted, never
273
+ * fabricated" contract as the method-mismatch case above it.
274
+ *
275
+ * `hasTrailingSlash` reflects whether the raw request path (query string
276
+ * excluded) ended in `/` for a path with more than one character — i.e. `/`
277
+ * itself is not "trailing slash". A core-route entry without
278
+ * `toleratesTrailingSlash` is a path `handleCore` 404s once a trailing slash
279
+ * is present, so that combination must not carry `http.route` either.
202
280
  */
203
281
  export function describeRequestRoute(
204
282
  method: string | undefined,
205
283
  pathSegments: string[],
284
+ hasQueryString = false,
285
+ hasTrailingSlash = false,
206
286
  ): RequestRouteDescriptor {
207
287
  const m = (method ?? "").toUpperCase() || "UNKNOWN";
208
288
  const matched = findRoute(method, pathSegments);
209
289
  if (matched) return { spanName: `${m} ${matched.path}`, httpRoute: matched.path };
290
+ const coreTemplate = CORE_ROUTE_TEMPLATES[pathSegments.join("/")];
291
+ if (
292
+ coreTemplate &&
293
+ (!coreTemplate.methods || coreTemplate.methods.includes(m)) &&
294
+ (!hasQueryString || coreTemplate.toleratesQueryString) &&
295
+ (!hasTrailingSlash || coreTemplate.toleratesTrailingSlash)
296
+ ) {
297
+ return { spanName: `${m} ${coreTemplate.path}`, httpRoute: coreTemplate.path };
298
+ }
210
299
  const first = pathSegments[0];
211
300
  return { spanName: first ? `${m} /${first}` : m };
212
301
  }
package/src/http/stats.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  getTaskStats,
12
12
  withFavoriteFlags,
13
13
  } from "../be/db";
14
+ import { getDbRetentionStats } from "../be/db-retention";
14
15
  import { isSteeringEnabled } from "../be/steering";
15
16
  import type { AgentLog } from "../types";
16
17
  import { AgentLogSchema, ScheduledTaskSchema, ServiceSchema } from "../types";
@@ -57,6 +58,38 @@ const SwarmMetricsSchema = z.object({
57
58
  pages: z.object({ total: z.number() }),
58
59
  sessions: z.object({ active: z.number() }),
59
60
  skills: z.object({ total: z.number() }),
61
+ retention: z.object({
62
+ sessionLogs: z
63
+ .object({
64
+ at: z.string(),
65
+ rowsDeleted: z.number(),
66
+ batches: z.number(),
67
+ durationMs: z.number(),
68
+ dryRun: z.boolean(),
69
+ cumulativeRowsDeleted: z.number(),
70
+ })
71
+ .optional(),
72
+ agentLog: z
73
+ .object({
74
+ at: z.string(),
75
+ rowsDeleted: z.number(),
76
+ batches: z.number(),
77
+ durationMs: z.number(),
78
+ dryRun: z.boolean(),
79
+ cumulativeRowsDeleted: z.number(),
80
+ })
81
+ .optional(),
82
+ events: z
83
+ .object({
84
+ at: z.string(),
85
+ rowsDeleted: z.number(),
86
+ batches: z.number(),
87
+ durationMs: z.number(),
88
+ dryRun: z.boolean(),
89
+ cumulativeRowsDeleted: z.number(),
90
+ })
91
+ .optional(),
92
+ }),
60
93
  });
61
94
 
62
95
  /** Mirrors `ConcurrentContext` (src/be/db.ts). */
@@ -242,7 +275,10 @@ export async function handleStats(
242
275
  }
243
276
 
244
277
  if (getMetrics.match(req.method, pathSegments)) {
245
- getMetrics.respond(res, 200, await getSwarmMetrics());
278
+ getMetrics.respond(res, 200, {
279
+ ...(await getSwarmMetrics()),
280
+ retention: getDbRetentionStats(),
281
+ });
246
282
  return true;
247
283
  }
248
284
 
package/src/http/tasks.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  markSteeringDelivered,
25
25
  markSteeringHandled,
26
26
  pauseTask,
27
+ promoteDraftTask,
27
28
  resumeTask,
28
29
  supersedeTask,
29
30
  updateAgentStatusFromCapacity,
@@ -233,6 +234,14 @@ const createTask = route({
233
234
  model: z.string().optional(),
234
235
  modelTier: ModelTierSchema.optional(),
235
236
  effort: ReasoningEffortSchema.optional(),
237
+ /**
238
+ * Create in `draft` status instead of the normal pending/unassigned/offered
239
+ * status (#1240) — the task exists and is visible to its owner, but is not
240
+ * dispatch-eligible. Used by the UI composer while attachments are still
241
+ * uploading; the caller MUST promote it via `POST /api/tasks/{id}/promote-draft`
242
+ * once the upload batch settles (or it self-promotes on a timeout).
243
+ */
244
+ draft: z.boolean().optional(),
236
245
  }),
237
246
  responses: {
238
247
  201: { description: "Task created", schema: AgentTaskSchema },
@@ -240,6 +249,23 @@ const createTask = route({
240
249
  },
241
250
  });
242
251
 
252
+ const promoteDraftTaskRoute = route({
253
+ method: "post",
254
+ path: "/api/tasks/{id}/promote-draft",
255
+ pattern: ["api", "tasks", null, "promote-draft"],
256
+ summary: "Promote a draft task out of the pre-dispatch draft state",
257
+ description:
258
+ "Transitions a `draft` task (#1240 — created with attachments still uploading) to its normal dispatch-eligible status: `offered` if it was offered to an agent, `pending` if it has an owning agent (the common case — UI-composer tasks default to Lead), otherwise `unassigned`. Called by the UI composer once its attachment upload batch settles, whether every file uploaded, some failed, or all failed — a draft must never be stranded by an upload error. Idempotent: calling it on a task that already left `draft` returns the current task unchanged rather than erroring, so a retried request is safe.",
259
+ tags: ["Tasks"],
260
+ params: z.object({ id: z.string() }),
261
+ rbac: { permission: "task.action.own" },
262
+ responses: {
263
+ 200: { description: "Task promoted (or already out of draft)", schema: AgentTaskSchema },
264
+ 403: { description: "Caller does not own this task" },
265
+ 404: { description: "Task not found" },
266
+ },
267
+ });
268
+
243
269
  const updateSession = route({
244
270
  method: "put",
245
271
  path: "/api/tasks/{id}/session",
@@ -621,6 +647,42 @@ async function canSteerTask(
621
647
  return can({ principal, verb, resource, source: "http" }).allow;
622
648
  }
623
649
 
650
+ /**
651
+ * Ownership gate for `task.action.own` — single verb, no `.any` counterpart:
652
+ * the underlying `requester-owns-task` policy already lets any non-user
653
+ * principal (agent/operator) through, so agent-side callers never need a
654
+ * separate escalated verb. User principals must match the task's
655
+ * `requestedByUserId`. Used by `POST /api/tasks/{id}/promote-draft`.
656
+ */
657
+ async function canActOnOwnTask(
658
+ req: IncomingMessage,
659
+ myAgentId: string | undefined,
660
+ task: AgentTask,
661
+ ): Promise<boolean> {
662
+ const resource: RbacResource = {
663
+ kind: "task",
664
+ taskId: task.id,
665
+ requestedByUserId: task.requestedByUserId,
666
+ creatorAgentId: task.creatorAgentId,
667
+ agentId: task.agentId,
668
+ };
669
+ let principal: RbacPrincipal;
670
+
671
+ const auth = getRequestAuth(req);
672
+ if (auth?.kind === "operator") {
673
+ principal = { kind: "operator" };
674
+ } else if (auth?.kind === "user") {
675
+ principal = { kind: "user", userId: auth.userId };
676
+ } else {
677
+ if (!myAgentId) return false;
678
+ const agent = await getAgentById(myAgentId);
679
+ if (!agent) return false;
680
+ principal = { kind: "agent", agentId: myAgentId, isLead: agent.isLead };
681
+ }
682
+
683
+ return can({ principal, verb: "task.action.own", resource, source: "http" }).allow;
684
+ }
685
+
624
686
  // ─── Handler ─────────────────────────────────────────────────────────────────
625
687
 
626
688
  export async function handleTasks(
@@ -773,6 +835,7 @@ export async function handleTasks(
773
835
  outputSchema: parsed.body.outputSchema || undefined,
774
836
  contextKey: parsed.body.contextKey || undefined,
775
837
  requestedByUserId,
838
+ status: parsed.body.draft ? "draft" : undefined,
776
839
  ...splitLegacyModelAlias({
777
840
  model: parsed.body.model,
778
841
  modelTier: parsed.body.modelTier,
@@ -804,6 +867,36 @@ export async function handleTasks(
804
867
  return true;
805
868
  }
806
869
 
870
+ if (promoteDraftTaskRoute.match(req.method, pathSegments)) {
871
+ const parsed = await promoteDraftTaskRoute.parse(req, res, pathSegments, queryParams);
872
+ if (!parsed) return true;
873
+
874
+ const existingTask = await getTaskById(parsed.params.id);
875
+ if (!existingTask) {
876
+ jsonError(res, "Task not found", 404);
877
+ return true;
878
+ }
879
+
880
+ const allowed = await canActOnOwnTask(req, myAgentId, existingTask);
881
+ if (!allowed) {
882
+ jsonError(res, "Not authorized to act on this task", 403);
883
+ return true;
884
+ }
885
+
886
+ // Idempotent: a task already out of `draft` (already promoted, or a
887
+ // retried request) is returned as-is rather than erroring — the UI calls
888
+ // this exactly once per upload batch but must stay safe under retry.
889
+ const promoted =
890
+ existingTask.status === "draft" ? await promoteDraftTask(parsed.params.id) : existingTask;
891
+ if (!promoted) {
892
+ jsonError(res, "Task not found", 404);
893
+ return true;
894
+ }
895
+
896
+ promoteDraftTaskRoute.respond(res, 200, promoted);
897
+ return true;
898
+ }
899
+
807
900
  if (updateSession.match(req.method, pathSegments)) {
808
901
  const parsed = await updateSession.parse(req, res, pathSegments, queryParams);
809
902
  if (!parsed) return true;
package/src/http/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { getActiveTaskCount } from "../be/db";
3
+ import type { SwarmSpan } from "../otel";
3
4
  import { scrubSecrets } from "../utils/secret-scrubber";
4
5
 
5
6
  export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
@@ -47,6 +48,99 @@ export function getPathSegments(url: string): string[] {
47
48
  return path.split("/").filter(Boolean);
48
49
  }
49
50
 
51
+ /**
52
+ * `http.response.status_code` for a request span whose response connection
53
+ * closed before completion. `statusCode` initializes to `200` and is only
54
+ * ever updated by `writeHead` — on the premature-close path that never ran,
55
+ * so reporting it verbatim would claim a 200 response was sent when none was.
56
+ * Omitted (not fabricated) when `headersSent` is false.
57
+ *
58
+ * Node emits `close` for BOTH a completed response and a connection
59
+ * terminated early, so this path proves the response did not complete — not
60
+ * that the client was the actor.
61
+ */
62
+ export function abortedStatusCodeAttribute(
63
+ headersSent: boolean,
64
+ statusCode: number,
65
+ ): number | undefined {
66
+ return headersSent ? statusCode : undefined;
67
+ }
68
+
69
+ /**
70
+ * Minimal `ServerResponse` surface `wireHttpSpanLifecycle` needs — narrowed so
71
+ * tests can pass a plain mock instead of a real Node response.
72
+ */
73
+ export interface SpanLifecycleResponse {
74
+ readonly headersSent: boolean;
75
+ on(event: "finish" | "error" | "close", listener: (err?: Error) => void): unknown;
76
+ }
77
+
78
+ /**
79
+ * Wires a request span to its response's terminal events so the span always
80
+ * ends exactly once, regardless of which event fires first.
81
+ *
82
+ * `finish` and `error` are the two ways Node signals a completed response;
83
+ * `close` additionally fires on a connection that terminates early —
84
+ * including AFTER `finish` on the happy path, which is why every branch
85
+ * guards on `spanEnded` before touching the span. Extracted out of
86
+ * `src/http/index.ts` (which binds a port at module scope and so cannot be
87
+ * imported directly in a test) so the close-before-finish ordering and the
88
+ * end-exactly-once guarantee can be exercised with a mocked response/span.
89
+ */
90
+ export function wireHttpSpanLifecycle(
91
+ res: SpanLifecycleResponse,
92
+ span: SwarmSpan,
93
+ getStatusCode: () => number,
94
+ startTime: number,
95
+ ): void {
96
+ let spanEnded = false;
97
+
98
+ res.on("finish", () => {
99
+ if (spanEnded) return;
100
+ spanEnded = true;
101
+ const statusCode = getStatusCode();
102
+ span.setAttributes({
103
+ "http.response.status_code": statusCode,
104
+ "agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
105
+ });
106
+ if (statusCode >= 500) {
107
+ span.setStatus({ code: 2, message: `HTTP ${statusCode}` });
108
+ }
109
+ span.end();
110
+ });
111
+
112
+ res.on("error", (err) => {
113
+ if (spanEnded) return;
114
+ spanEnded = true;
115
+ if (err) span.recordException(err);
116
+ span.setStatus({ code: 2, message: err?.message });
117
+ span.end();
118
+ });
119
+
120
+ // `close` fires after `finish` on the happy path (the `spanEnded` guard
121
+ // already covers that), but a response connection that terminates early
122
+ // fires neither `finish` nor `error` — without this, the span stays open
123
+ // and is never exported. Verified against Bun 1.4.0 (pinned): `close`
124
+ // fires on a premature close even with `headersSent === false`; Bun
125
+ // 1.3.14 never fires it.
126
+ res.on("close", () => {
127
+ if (spanEnded) return;
128
+ spanEnded = true;
129
+ span.setAttributes({
130
+ "http.response.status_code": abortedStatusCodeAttribute(res.headersSent, getStatusCode()),
131
+ "agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
132
+ "agentswarm.http.aborted": true,
133
+ });
134
+ // Status left Unset. `close` cannot distinguish an intentional client
135
+ // cancellation from a response-write failure, and the dominant case
136
+ // here is the former: a normal SSE teardown on /mcp or /mcp-user.
137
+ // Marking every premature close ERROR would put that routine traffic
138
+ // in the service error rate. `agentswarm.http.aborted` carries the
139
+ // signal instead. Revisit if a discriminator becomes available.
140
+ span.end();
141
+ });
142
+ }
143
+
50
144
  export function safeRequestUrlForLog(rawUrl: string | undefined): string {
51
145
  if (!rawUrl) return "";
52
146
 
package/src/otel-impl.ts CHANGED
@@ -5,7 +5,9 @@ import {
5
5
  propagation,
6
6
  ROOT_CONTEXT,
7
7
  type Span,
8
+ SpanKind,
8
9
  SpanStatusCode,
10
+ type Tracer,
9
11
  trace,
10
12
  } from "@opentelemetry/api";
11
13
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
@@ -20,7 +22,7 @@ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
20
22
  import { NodeSDK } from "@opentelemetry/sdk-node";
21
23
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
22
24
  import pkg from "../package.json";
23
- import type { SwarmSpan } from "./otel";
25
+ import type { SpanOptions, SwarmSpan, SwarmSpanKind } from "./otel";
24
26
  import { scrubSecrets } from "./utils/secret-scrubber";
25
27
 
26
28
  type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
@@ -219,9 +221,21 @@ export async function withSpan<T>(
219
221
  });
220
222
  }
221
223
 
222
- export function startSpan(name: string, attributes?: Attributes): SwarmSpan {
223
- const span = trace.getTracer(TRACER_NAME).startSpan(name, {
224
+ const SPAN_KIND_MAP: Record<SwarmSpanKind, SpanKind> = {
225
+ internal: SpanKind.INTERNAL,
226
+ server: SpanKind.SERVER,
227
+ client: SpanKind.CLIENT,
228
+ producer: SpanKind.PRODUCER,
229
+ consumer: SpanKind.CONSUMER,
230
+ };
231
+
232
+ let tracerOverrideForTests: Tracer | undefined;
233
+
234
+ export function startSpan(name: string, attributes?: Attributes, options?: SpanOptions): SwarmSpan {
235
+ const tracer = tracerOverrideForTests ?? trace.getTracer(TRACER_NAME);
236
+ const span = tracer.startSpan(name, {
224
237
  attributes: cleanAttributes(attributes),
238
+ kind: options?.kind ? SPAN_KIND_MAP[options.kind] : undefined,
225
239
  });
226
240
  return spanAdapter(span);
227
241
  }
@@ -326,3 +340,7 @@ export function _injectCountersForTests(
326
340
  tokenCounter = token;
327
341
  costDriftCounter = drift;
328
342
  }
343
+
344
+ export function _injectTracerForTests(tracer: Tracer | undefined): void {
345
+ tracerOverrideForTests = tracer;
346
+ }
package/src/otel.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
2
2
  export type Attributes = Record<string, AttributeValue | undefined>;
3
3
 
4
- type SpanStatus = {
4
+ export type SpanStatus = {
5
5
  code: number;
6
6
  message?: string;
7
7
  };
@@ -15,6 +15,15 @@ export type SwarmSpan = {
15
15
  end: () => void;
16
16
  };
17
17
 
18
+ // Backend-agnostic mirror of OTel's `SpanKind`. Kept as a string union (not a
19
+ // value import of `@opentelemetry/api`) so this dependency-free module, loaded
20
+ // by every worker process, never eagerly pulls in the OTel API. `otel-impl.ts`
21
+ // maps this onto the real `SpanKind` only when OTLP is configured.
22
+ export type SwarmSpanKind = "internal" | "server" | "client" | "producer" | "consumer";
23
+ export interface SpanOptions {
24
+ kind?: SwarmSpanKind;
25
+ }
26
+
18
27
  // eslint-disable-next-line import/no-duplicates -- type-only import, no side-effects
19
28
  import type { SessionCostMetric } from "./otel-impl";
20
29
  export type { SessionCostMetric };
@@ -51,7 +60,9 @@ let realWithSpan:
51
60
  attributes?: Attributes,
52
61
  ) => Promise<T>)
53
62
  | undefined;
54
- let realStartSpan: ((name: string, attributes?: Attributes) => SwarmSpan) | undefined;
63
+ let realStartSpan:
64
+ | ((name: string, attributes?: Attributes, options?: SpanOptions) => SwarmSpan)
65
+ | undefined;
55
66
  let realWithRemoteContext:
56
67
  | (<T>(carrier: Record<string, unknown>, fn: () => Promise<T> | T) => Promise<T>)
57
68
  | undefined;
@@ -102,11 +113,11 @@ export async function withSpan<T>(
102
113
  return realWithSpan(name, fn, attributes);
103
114
  }
104
115
 
105
- export function startSpan(name: string, attributes?: Attributes): SwarmSpan {
116
+ export function startSpan(name: string, attributes?: Attributes, options?: SpanOptions): SwarmSpan {
106
117
  if (!otelConfigured() || !realStartSpan) {
107
118
  return NOOP_SPAN;
108
119
  }
109
- return realStartSpan(name, attributes);
120
+ return realStartSpan(name, attributes, options);
110
121
  }
111
122
 
112
123
  export function withSpanContext<T>(span: SwarmSpan, fn: () => T): T {
@@ -375,6 +375,7 @@ export function buildBufferFlushBlocks(opts: {
375
375
  type TreeStatusIcon = TreeNode["status"] | "superseded";
376
376
 
377
377
  const STATUS_ICON: Record<TreeStatusIcon, string> = {
378
+ draft: "📎",
378
379
  backlog: "🗂️",
379
380
  unassigned: "📭",
380
381
  offered: "📨",
@@ -196,6 +196,13 @@ let cachedBotId: string | null = null;
196
196
  const swarmThreadRootCache = new Map<string, boolean>();
197
197
  const SWARM_THREAD_ROOT_CACHE_MAX = 1000;
198
198
 
199
+ /** Reset Slack handler caches between tests that exercise production handlers. */
200
+ export function resetSlackHandlerCachesForTesting(): void {
201
+ cachedBotUserId = null;
202
+ cachedBotId = null;
203
+ swarmThreadRootCache.clear();
204
+ }
205
+
199
206
  /**
200
207
  * Pure check: does the given thread-root message belong to our own swarm bot?
201
208
  * Exported for testing.
@@ -58,6 +58,7 @@ async function toSiblingTaskInfo(task: AgentTask): Promise<SiblingTaskInfo> {
58
58
  agentName: agent?.name ?? null,
59
59
  description: stripSiblingBlock(task.task),
60
60
  updatedAt: task.lastUpdatedAt,
61
+ createdAt: task.createdAt,
61
62
  };
62
63
  }
63
64
 
@@ -18,9 +18,15 @@ export type SiblingTaskInfo = {
18
18
  agentId: string | null;
19
19
  agentName: string | null;
20
20
  description: string;
21
- // Most recent change timestamp (ISO string). Used for relative-time render
22
- // and for picking the "best" sibling to wire as parent.
21
+ // Most recent change timestamp (ISO string). Used for the "last active"
22
+ // relative-time render and for picking the "best" sibling to wire as parent.
23
+ // NOTE: this ticks on every progress event, so it is NOT a proxy for when
24
+ // the sibling (or the user input behind it) started.
23
25
  updatedAt: string;
26
+ // Creation timestamp (ISO string) — when the user input behind this sibling
27
+ // actually arrived. Optional so existing callers keep compiling; when it is
28
+ // absent we omit the "started" clause rather than guess from `updatedAt`.
29
+ createdAt?: string;
24
30
  };
25
31
 
26
32
  const DESCRIPTION_TRUNCATE = 200;
@@ -100,8 +106,15 @@ export function renderSiblingBlock(
100
106
  : s.agentId
101
107
  ? `agent:${s.agentId}`
102
108
  : "agent:unassigned";
103
- const rel = formatRelativeTime(s.updatedAt, now);
104
- lines.push(`- [${s.status}] task:${s.id} — ${agentLabel} — started ${rel} ago`);
109
+ // `updatedAt` advances on every progress event, so rendering it as
110
+ // "started" made a minutes-old sibling look seconds-old and invited the
111
+ // reader to conclude two unrelated user inputs were a double-submit.
112
+ // Report each timestamp under its own name instead.
113
+ const lastActive = `last active ${formatRelativeTime(s.updatedAt, now)} ago`;
114
+ const timing = s.createdAt
115
+ ? `started ${formatRelativeTime(s.createdAt, now)} ago, ${lastActive}`
116
+ : lastActive;
117
+ lines.push(`- [${s.status}] task:${s.id} — ${agentLabel} — ${timing}`);
105
118
  lines.push(` "${truncateForBlock(s.description)}"`);
106
119
  }
107
120
  lines.push("</sibling_tasks_in_progress>");
@@ -9,6 +9,11 @@ import {
9
9
  } from "../artifact-sdk/server";
10
10
  import { getBasePrompt } from "../prompts/base-prompt";
11
11
 
12
+ function restoreEnvValue(key: string, value: string | undefined): void {
13
+ if (value === undefined) delete process.env[key];
14
+ else process.env[key] = value;
15
+ }
16
+
12
17
  // ─── Port allocation tests ──────────────────────────────────────────────
13
18
 
14
19
  describe("getAvailablePort", () => {
@@ -132,9 +137,9 @@ describe("createArtifactServer", () => {
132
137
  });
133
138
 
134
139
  afterAll(() => {
135
- process.env.AGENT_ID = originalEnv.AGENT_ID;
136
- process.env.API_KEY = originalEnv.API_KEY;
137
- process.env.MCP_BASE_URL = originalEnv.MCP_BASE_URL;
140
+ restoreEnvValue("AGENT_ID", originalEnv.AGENT_ID);
141
+ restoreEnvValue("API_KEY", originalEnv.API_KEY);
142
+ restoreEnvValue("MCP_BASE_URL", originalEnv.MCP_BASE_URL);
138
143
  });
139
144
 
140
145
  describe("factory return shape", () => {
@@ -596,9 +601,9 @@ describe("artifact CLI command", () => {
596
601
  expect(output).toContain("https://test.lt.example.com");
597
602
  } finally {
598
603
  console.log = origLog;
599
- process.env.MCP_BASE_URL = origEnv.MCP_BASE_URL;
600
- process.env.API_KEY = origEnv.API_KEY;
601
- process.env.AGENT_ID = origEnv.AGENT_ID;
604
+ restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
605
+ restoreEnvValue("API_KEY", origEnv.API_KEY);
606
+ restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
602
607
  mockServer.stop();
603
608
  }
604
609
  });
@@ -627,9 +632,9 @@ describe("artifact CLI command", () => {
627
632
  expect(output).toContain("No active artifacts");
628
633
  } finally {
629
634
  console.log = origLog;
630
- process.env.MCP_BASE_URL = origEnv.MCP_BASE_URL;
631
- process.env.API_KEY = origEnv.API_KEY;
632
- process.env.AGENT_ID = origEnv.AGENT_ID;
635
+ restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
636
+ restoreEnvValue("API_KEY", origEnv.API_KEY);
637
+ restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
633
638
  mockServer.stop();
634
639
  }
635
640
  });
@@ -679,9 +684,9 @@ describe("artifact CLI command", () => {
679
684
  expect(output).toContain("No active artifacts");
680
685
  } finally {
681
686
  console.log = origLog;
682
- process.env.MCP_BASE_URL = origEnv.MCP_BASE_URL;
683
- process.env.API_KEY = origEnv.API_KEY;
684
- process.env.AGENT_ID = origEnv.AGENT_ID;
687
+ restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
688
+ restoreEnvValue("API_KEY", origEnv.API_KEY);
689
+ restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
685
690
  mockServer.stop();
686
691
  }
687
692
  });
@@ -736,9 +741,9 @@ describe("artifact CLI command", () => {
736
741
  expect(output).toContain("stopped");
737
742
  } finally {
738
743
  console.log = origLog;
739
- process.env.MCP_BASE_URL = origEnv.MCP_BASE_URL;
740
- process.env.API_KEY = origEnv.API_KEY;
741
- process.env.AGENT_ID = origEnv.AGENT_ID;
744
+ restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
745
+ restoreEnvValue("API_KEY", origEnv.API_KEY);
746
+ restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
742
747
  mockServer.stop();
743
748
  }
744
749
  });