@desplega.ai/agent-swarm 1.87.0 → 1.89.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 (102) hide show
  1. package/README.md +5 -1
  2. package/openapi.json +53 -1
  3. package/package.json +6 -5
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +374 -9
  6. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  7. package/src/be/migrations/081_metrics.sql +39 -0
  8. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  9. package/src/be/modelsdev-cache.json +3825 -2417
  10. package/src/be/seed/registry.ts +3 -2
  11. package/src/be/seed-skills/index.ts +179 -0
  12. package/src/cli.tsx +51 -4
  13. package/src/commands/e2b-stack-wizard.tsx +394 -0
  14. package/src/commands/e2b.ts +1352 -53
  15. package/src/commands/onboard/dashboard-url.ts +29 -0
  16. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  17. package/src/commands/onboard.tsx +3 -1
  18. package/src/commands/runner.ts +154 -22
  19. package/src/commands/x.ts +118 -0
  20. package/src/e2b/dispatch.ts +234 -18
  21. package/src/github/handlers.ts +40 -1
  22. package/src/heartbeat/heartbeat.ts +26 -5
  23. package/src/http/active-sessions.ts +32 -1
  24. package/src/http/auth.ts +36 -0
  25. package/src/http/core.ts +20 -16
  26. package/src/http/db-query.ts +20 -0
  27. package/src/http/index.ts +2 -0
  28. package/src/http/memory.ts +13 -1
  29. package/src/http/metrics.ts +447 -0
  30. package/src/http/operator-actor.ts +9 -0
  31. package/src/http/poll.ts +11 -1
  32. package/src/http/skills.ts +53 -0
  33. package/src/http/tasks.ts +4 -1
  34. package/src/http/webhooks.ts +75 -0
  35. package/src/http/workflows.ts +5 -1
  36. package/src/integrations/kapso/client.ts +82 -0
  37. package/src/memory/automatic-task-gate.ts +47 -0
  38. package/src/metrics/version.ts +26 -0
  39. package/src/prompts/base-prompt.ts +24 -1
  40. package/src/prompts/session-templates.ts +74 -0
  41. package/src/providers/claude-adapter.ts +19 -0
  42. package/src/providers/codex-adapter.ts +22 -0
  43. package/src/providers/ctx-mode-env.ts +10 -0
  44. package/src/providers/opencode-adapter.ts +72 -7
  45. package/src/server.ts +10 -1
  46. package/src/slack/blocks.ts +12 -4
  47. package/src/slack/watcher.ts +3 -3
  48. package/src/telemetry.ts +14 -1
  49. package/src/templates.d.ts +4 -0
  50. package/src/tests/base-prompt.test.ts +76 -0
  51. package/src/tests/budget-claim-gate.test.ts +26 -0
  52. package/src/tests/claude-adapter.test.ts +86 -1
  53. package/src/tests/codex-adapter.test.ts +89 -0
  54. package/src/tests/core-auth.test.ts +8 -1
  55. package/src/tests/e2b-dispatch.test.ts +603 -11
  56. package/src/tests/events-http.test.ts +6 -2
  57. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  58. package/src/tests/heartbeat.test.ts +84 -3
  59. package/src/tests/http-api-integration.test.ts +116 -1
  60. package/src/tests/kapso-client.test.ts +74 -1
  61. package/src/tests/kapso-inbound.test.ts +60 -2
  62. package/src/tests/metrics-http.test.ts +247 -0
  63. package/src/tests/opencode-adapter.test.ts +185 -30
  64. package/src/tests/prompt-template-session.test.ts +4 -2
  65. package/src/tests/runner-repo-autostash.test.ts +117 -0
  66. package/src/tests/runner-requester-profile.test.ts +25 -0
  67. package/src/tests/runner-skills-refresh.test.ts +1 -1
  68. package/src/tests/self-improvement.test.ts +89 -0
  69. package/src/tests/skill-update-scope.test.ts +88 -1
  70. package/src/tests/slack-blocks.test.ts +15 -0
  71. package/src/tests/swarm-x-tool.test.ts +90 -0
  72. package/src/tests/system-default-skills.test.ts +122 -0
  73. package/src/tests/telemetry-init.test.ts +86 -0
  74. package/src/tests/ui-logs-parser.test.ts +271 -0
  75. package/src/tests/user-token-rest-auth.test.ts +129 -0
  76. package/src/tests/workflow-async-v2.test.ts +23 -0
  77. package/src/tests/x-composio.test.ts +122 -0
  78. package/src/tools/create-metric.ts +191 -0
  79. package/src/tools/skills/skill-delete.ts +14 -0
  80. package/src/tools/skills/skill-update.ts +14 -0
  81. package/src/tools/store-progress.ts +19 -5
  82. package/src/tools/swarm-x.ts +116 -0
  83. package/src/tools/tool-config.ts +6 -0
  84. package/src/types.ts +121 -0
  85. package/src/utils/request-auth-context.ts +28 -0
  86. package/src/utils/skills-refresh.ts +2 -2
  87. package/src/workflows/engine.ts +24 -2
  88. package/src/workflows/executors/agent-task.ts +2 -0
  89. package/src/x/composio.ts +295 -0
  90. package/templates/skills/artifacts/config.json +1 -0
  91. package/templates/skills/attio-interaction/SKILL.md +279 -0
  92. package/templates/skills/attio-interaction/config.json +14 -0
  93. package/templates/skills/attio-interaction/content.md +272 -0
  94. package/templates/skills/kv-storage/config.json +1 -0
  95. package/templates/skills/pages/config.json +1 -0
  96. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  97. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  98. package/templates/skills/swarm-scripts/config.json +14 -0
  99. package/templates/skills/swarm-scripts/content.md +86 -0
  100. package/templates/skills/workflow-iterate/config.json +1 -0
  101. package/templates/skills/workflow-structured-output/config.json +1 -0
  102. package/tsconfig.json +2 -1
@@ -14,6 +14,26 @@ export type E2BSandboxInfo = {
14
14
  startedAt?: string;
15
15
  endAt?: string;
16
16
  metadata?: Record<string, string>;
17
+ // Client-side fallback for the sandbox expiry. The raw `POST /sandboxes`
18
+ // create response uses E2B's `Sandbox` schema, which (unlike `ListedSandbox`
19
+ // / `SandboxDetail`) does NOT include `endAt`. We populate this from
20
+ // `now + timeoutSec*1000` at create time so `ttlRemaining` can report expiry
21
+ // immediately after a launch without an extra round-trip. `endAt` (when
22
+ // present, e.g. from `listSandboxes`) is always authoritative over this.
23
+ expiresAt?: string;
24
+ };
25
+
26
+ export type TtlRemaining = {
27
+ expiresAt?: string;
28
+ secondsLeft?: number;
29
+ };
30
+
31
+ export type SetSandboxTimeoutOptions = {
32
+ sandboxId: string;
33
+ apiKey: string;
34
+ apiBase?: string;
35
+ e2bEnv?: EnvMap;
36
+ timeoutMs: number;
17
37
  };
18
38
 
19
39
  export type E2BCommandResult = {
@@ -80,6 +100,25 @@ export type StartDetachedOptions = {
80
100
  cwd?: string;
81
101
  };
82
102
 
103
+ export type StreamSandboxLogOptions = {
104
+ sandboxId: string;
105
+ role: E2BRole;
106
+ apiKey: string;
107
+ apiBase?: string;
108
+ e2bEnv?: EnvMap;
109
+ /** Number of trailing history lines to emit before following (default 200). */
110
+ tailLines?: number;
111
+ /** When true, keep streaming new output (`tail -f`) until the caller aborts. */
112
+ follow?: boolean;
113
+ /**
114
+ * Egress sink for each chunk. The caller MUST scrub here — log output is
115
+ * untrusted entrypoint stdout and can embed tokens/secrets.
116
+ */
117
+ onChunk: (chunk: string) => void;
118
+ /** Abort signal to stop a `--follow` stream (e.g. on SIGINT). */
119
+ signal?: AbortSignal;
120
+ };
121
+
83
122
  type E2BSdkConnectionOptions = {
84
123
  apiKey: string;
85
124
  apiUrl?: string;
@@ -98,15 +137,24 @@ function e2bHeaders(apiKey: string): Record<string, string> {
98
137
  };
99
138
  }
100
139
 
101
- export function buildDetachedShell(command: string, logPath: string, pidPath: string): string {
102
- return [
103
- "set -e",
104
- `nohup ${command} >${logPath} 2>&1 </dev/null & pid=$!`,
105
- "sleep 2",
106
- `if ! kill -0 "$pid" 2>/dev/null; then cat ${logPath} >&2; exit 1; fi`,
107
- `echo "$pid" > ${pidPath}`,
108
- 'echo "$pid"',
109
- ].join("; ");
140
+ /**
141
+ * Build the shell payload for the envd-tracked entrypoint launch. Phase 5: the
142
+ * entrypoint is no longer detached via `nohup … >file &` (a grandchild envd
143
+ * never sees). Instead it runs as the SDK background command itself (envd owns
144
+ * and streams it; it survives client disconnect). We still `tee` to a
145
+ * deterministic file so `swarms logs` can retrieve FULL history later: the SDK's
146
+ * `commands.connect(pid)` only streams output going forward from the connect
147
+ * instant — it does NOT replay stdout produced while disconnected (verified
148
+ * against the e2b SDK types + docs) — so the file copy is the only reliable
149
+ * full-history source.
150
+ *
151
+ * `set -o pipefail` makes the pipeline's exit code reflect the ENTRYPOINT rather
152
+ * than `tee` (tee exits 0 on EOF even if the entrypoint crashed), so the early
153
+ * `exitCode` poll in {@link startDetachedProcess} can detect a launch failure.
154
+ * Invoked via `bash -lc` (both the api + worker images ship bash) for pipefail.
155
+ */
156
+ export function buildTrackedShell(command: string, logPath: string): string {
157
+ return `set -o pipefail; ${command} 2>&1 | tee ${logPath}`;
110
158
  }
111
159
 
112
160
  export function e2bSdkConnectionOptions(
@@ -184,7 +232,10 @@ export async function e2bFetchJson<T>(
184
232
  }
185
233
 
186
234
  export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSandboxInfo> {
187
- return e2bFetchJson<E2BSandboxInfo>(
235
+ // Capture the wall-clock create instant BEFORE the request so the client-side
236
+ // expiry fallback reflects when the TTL countdown begins.
237
+ const createdAt = Date.now();
238
+ const sandbox = await e2bFetchJson<E2BSandboxInfo>(
188
239
  "/sandboxes",
189
240
  opts.apiKey,
190
241
  {
@@ -200,6 +251,61 @@ export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSand
200
251
  },
201
252
  opts.apiBase,
202
253
  );
254
+ // Pre-flight check (resolved against node_modules/e2b types): the create
255
+ // response is E2B's `Sandbox` schema, which omits `endAt`. Compute a
256
+ // client-side expiry fallback so `ttlRemaining` works right after launch.
257
+ if (!sandbox.endAt && !sandbox.expiresAt) {
258
+ sandbox.expiresAt = new Date(createdAt + opts.timeoutSec * 1000).toISOString();
259
+ }
260
+ return sandbox;
261
+ }
262
+
263
+ /**
264
+ * Compute the remaining time-to-live for a sandbox. Prefers the authoritative
265
+ * `endAt` (present on listed/detail responses); falls back to the client-side
266
+ * `expiresAt` stamped by `createSandbox`. Returns an empty object when neither
267
+ * is available (e.g. a dry-run fake sandbox). `secondsLeft` is clamped at 0 so
268
+ * an already-expired sandbox never reports negative time.
269
+ */
270
+ export function ttlRemaining(sandbox: E2BSandboxInfo): TtlRemaining {
271
+ const expiresAt = sandbox.endAt ?? sandbox.expiresAt;
272
+ if (!expiresAt) return {};
273
+ const expiryMs = Date.parse(expiresAt);
274
+ if (Number.isNaN(expiryMs)) return {};
275
+ const secondsLeft = Math.max(0, Math.round((expiryMs - Date.now()) / 1000));
276
+ return { expiresAt, secondsLeft };
277
+ }
278
+
279
+ /**
280
+ * Extend (or reduce) a live sandbox's TTL via the SDK and read back the actual
281
+ * `endAt` E2B applied (the server clamps to the tier max, so the requested
282
+ * timeout is not always honored verbatim). Connecting to a dead/expired sandbox
283
+ * throws; we translate that into a redacted "not found / already expired"
284
+ * error so a stale sandbox ID never leaks the controller key into logs.
285
+ */
286
+ export async function setSandboxTimeout(opts: SetSandboxTimeoutOptions): Promise<TtlRemaining> {
287
+ const { Sandbox } = await import("e2b");
288
+ let sandbox: Awaited<ReturnType<typeof Sandbox.connect>>;
289
+ try {
290
+ sandbox = await Sandbox.connect(
291
+ opts.sandboxId,
292
+ e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
293
+ );
294
+ } catch {
295
+ // Do not surface the underlying error verbatim — it can embed the
296
+ // controller API key / connection URL. Emit a fixed redacted message.
297
+ throw new Error(`sandbox ${opts.sandboxId} not found / already expired`);
298
+ }
299
+
300
+ await sandbox.setTimeout(opts.timeoutMs);
301
+ // `setTimeout` returns void; re-read the info to learn the clamped expiry.
302
+ const info = await sandbox.getInfo();
303
+ const expiresAt = info.endAt instanceof Date ? info.endAt.toISOString() : String(info.endAt);
304
+ return ttlRemaining({
305
+ sandboxID: opts.sandboxId,
306
+ templateID: info.templateId,
307
+ endAt: expiresAt,
308
+ });
203
309
  }
204
310
 
205
311
  export async function killSandbox(
@@ -222,27 +328,137 @@ export async function listSandboxes(
222
328
  return e2bFetchJson<E2BSandboxInfo[]>("/sandboxes", apiKey, {}, apiBase);
223
329
  }
224
330
 
331
+ /**
332
+ * The deterministic per-role log path the entrypoint tees to. `swarms logs`
333
+ * recomputes it from the role alone (no PID bookkeeping needed) to `tail`/`cat`
334
+ * full history or `tail -f` for `--follow`.
335
+ */
336
+ export function sandboxLogPath(role: E2BRole): string {
337
+ return `/tmp/agent-swarm-e2b-${role}.log`;
338
+ }
339
+
340
+ /**
341
+ * Launch the entrypoint as an envd-tracked BACKGROUND command (Phase 5). Returns
342
+ * the PID immediately. Replaces the old `nohup … >file & sleep 2; kill -0` hack:
343
+ * the SDK's background handle exposes `exitCode` (undefined while running), so we
344
+ * poll it once after a short grace period — a non-zero exit by then means the
345
+ * entrypoint died at launch, which we surface as a launch failure (reading the
346
+ * tee'd log for context). The `tee` preserves a file copy for full-history
347
+ * retrieval regardless of envd stdout-replay semantics.
348
+ */
225
349
  export async function startDetachedProcess(opts: StartDetachedOptions): Promise<string> {
226
- const logPath = `/tmp/agent-swarm-e2b-${opts.role}.log`;
227
- const pidPath = `/tmp/agent-swarm-e2b-${opts.role}.pid`;
228
- const shell = buildDetachedShell(opts.command, logPath, pidPath);
350
+ const logPath = sandboxLogPath(opts.role);
351
+ const shell = buildTrackedShell(opts.command, logPath);
229
352
 
230
353
  const { Sandbox } = await import("e2b");
231
354
  const sandbox = await Sandbox.connect(
232
355
  opts.sandbox.sandboxID,
233
356
  e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
234
357
  );
235
- const result = await sandbox.commands.run(shell, {
358
+ // `bash -lc` (not `sh`) so `set -o pipefail` is honored on both images.
359
+ const handle = await sandbox.commands.run(`bash -lc ${shellQuote(shell)}`, {
236
360
  user: opts.user ?? "root",
237
361
  cwd: opts.cwd ?? "/",
238
362
  envs: opts.env,
239
- timeoutMs: 30_000,
363
+ background: true,
240
364
  });
241
365
 
242
- if (result.exitCode !== 0) {
243
- throw new Error(`E2B start command failed: ${redactWithEnv(result.stderr, opts.env)}`);
366
+ // Early liveness poll: give the entrypoint a moment to fault, then check the
367
+ // handle's exit code. `undefined` = still running (the expected happy path).
368
+ await Bun.sleep(2_000);
369
+ if (typeof handle.exitCode === "number" && handle.exitCode !== 0) {
370
+ // The pipeline already exited non-zero — surface stderr/stdout (redacted, as
371
+ // entrypoint output can embed tokens) as a launch failure.
372
+ const detail = redactWithEnv(`${handle.stdout}\n${handle.stderr}`.trim(), opts.env);
373
+ throw new Error(`E2B start command exited ${handle.exitCode} at launch: ${detail}`);
374
+ }
375
+
376
+ return String(handle.pid);
377
+ }
378
+
379
+ /** Single-quote a string for safe embedding in a `bash -lc '<...>'` invocation. */
380
+ function shellQuote(value: string): string {
381
+ return `'${value.split("'").join(`'\\''`)}'`;
382
+ }
383
+
384
+ /**
385
+ * Stream a sandbox's tee'd entrypoint log to the caller's `onChunk` sink.
386
+ *
387
+ * Design (Phase 5): we read from the deterministic per-role {@link sandboxLogPath}
388
+ * the entrypoint tees to — NOT from a tracked PID — so no PID bookkeeping is
389
+ * needed and history survives reconnect / a fresh CLI process. The SDK's
390
+ * `commands.connect(pid)` only streams forward from connect (no historical
391
+ * replay, verified against the SDK), so the file is the source of truth for
392
+ * full history.
393
+ *
394
+ * - History (no `--follow`): `tail -n <N> <logPath>` once (a CommandResult).
395
+ * - Follow: `tail -n <N> -F <logPath>` as a BACKGROUND command, piping each
396
+ * `onStdout`/`onStderr` chunk to `onChunk` until the abort signal fires
397
+ * (`-F` keeps following across truncation/rotation; tolerates a not-yet-created
398
+ * file). The caller scrubs inside `onChunk`.
399
+ *
400
+ * Output is emitted RAW here; the caller is responsible for scrubbing in
401
+ * `onChunk` (it sees both this function's stdout and stderr).
402
+ */
403
+ export async function streamSandboxLog(opts: StreamSandboxLogOptions): Promise<void> {
404
+ const logPath = sandboxLogPath(opts.role);
405
+ const tailLines = opts.tailLines ?? 200;
406
+
407
+ const { Sandbox } = await import("e2b");
408
+ const sandbox = await Sandbox.connect(
409
+ opts.sandboxId,
410
+ e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
411
+ );
412
+
413
+ if (!opts.follow) {
414
+ // History only: a single `tail`. If the file does not exist yet (entrypoint
415
+ // hasn't written), `tail` exits non-zero with a message on stderr — we emit
416
+ // that to the sink rather than throwing, so a freshly-launched swarm reads as
417
+ // "no logs yet" instead of a hard error.
418
+ const result = await sandbox.commands.run(
419
+ `bash -lc ${shellQuote(`tail -n ${tailLines} ${logPath} 2>&1 || true`)}`,
420
+ { user: "root", timeoutMs: 30_000 },
421
+ );
422
+ if (result.stdout) opts.onChunk(result.stdout);
423
+ if (result.stderr) opts.onChunk(result.stderr);
424
+ return;
425
+ }
426
+
427
+ // Follow: background `tail -F` streaming forward. `-F` (vs `-f`) re-opens the
428
+ // file if it is rotated/recreated and waits for a not-yet-existing file.
429
+ const handle = await sandbox.commands.run(
430
+ `bash -lc ${shellQuote(`tail -n ${tailLines} -F ${logPath}`)}`,
431
+ {
432
+ user: "root",
433
+ background: true,
434
+ onStdout: (data) => opts.onChunk(data),
435
+ onStderr: (data) => opts.onChunk(data),
436
+ },
437
+ );
438
+
439
+ const stop = async () => {
440
+ try {
441
+ await handle.kill();
442
+ } catch {
443
+ // The command may already be gone (sandbox killed/expired); ignore.
444
+ }
445
+ };
446
+ if (opts.signal) {
447
+ if (opts.signal.aborted) {
448
+ await stop();
449
+ return;
450
+ }
451
+ opts.signal.addEventListener("abort", stop, { once: true });
452
+ }
453
+
454
+ try {
455
+ // `wait()` resolves when the stream ends (sandbox death) or the handle is
456
+ // killed by the abort listener above. `tail -F` otherwise runs indefinitely.
457
+ await handle.wait();
458
+ } catch {
459
+ // A kill / disconnect surfaces as a rejected wait — that is the expected exit
460
+ // path for `--follow`, not an error to propagate.
244
461
  }
245
- return result.stdout.trim();
246
462
  }
247
463
 
248
464
  export async function waitForAgentRegistration(
@@ -1,4 +1,4 @@
1
- import { failTask, findTaskByVcs, getAllAgents, incrKv, upsertKv } from "../be/db";
1
+ import { failTask, findTaskByVcs, getAllAgents, getSwarmConfigs, incrKv, upsertKv } from "../be/db";
2
2
  import { findUserByExternalId } from "../be/users";
3
3
  import { resolveTemplate } from "../prompts/resolver";
4
4
  import { githubContextKey } from "../tasks/context-key";
@@ -46,6 +46,19 @@ function buildGithubContextKey(
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * Runtime-config guards for cancel-on-unassign and cancel-on-review-request-removed.
51
+ * Absent key / any value other than "false" → true (cancel, current behavior).
52
+ * Value "false" → false (skip cancel, leave task untouched).
53
+ */
54
+ function cancelFlagEnabled(key: string): boolean {
55
+ const row = getSwarmConfigs({ scope: "global", key })[0];
56
+ return row?.value !== "false";
57
+ }
58
+ const cancelOnUnassignEnabled = () => cancelFlagEnabled("github.cancelOnUnassign");
59
+ const cancelOnReviewRequestRemovedEnabled = () =>
60
+ cancelFlagEnabled("github.cancelOnReviewRequestRemoved");
61
+
49
62
  /**
50
63
  * Get review state emoji and label
51
64
  */
@@ -278,6 +291,14 @@ export async function handlePullRequest(
278
291
  return { created: false };
279
292
  }
280
293
 
294
+ // Config gate: skip cancel if disabled
295
+ if (!cancelOnUnassignEnabled()) {
296
+ console.log(
297
+ `[GitHub] unassign cancel disabled by config — leaving task untouched (PR #${pr.number})`,
298
+ );
299
+ return { created: false };
300
+ }
301
+
281
302
  // Find the related task
282
303
  const task = findTaskByVcs(repository.full_name, pr.number);
283
304
  if (!task) {
@@ -378,6 +399,14 @@ export async function handlePullRequest(
378
399
  return { created: false };
379
400
  }
380
401
 
402
+ // Config gate: skip cancel if disabled
403
+ if (!cancelOnReviewRequestRemovedEnabled()) {
404
+ console.log(
405
+ `[GitHub] review-request-removed cancel disabled by config — leaving task untouched (PR #${pr.number})`,
406
+ );
407
+ return { created: false };
408
+ }
409
+
381
410
  // Find the related task
382
411
  const task = findTaskByVcs(repository.full_name, pr.number);
383
412
  if (!task) {
@@ -533,6 +562,7 @@ export async function handlePullRequest(
533
562
  vcsUrl: pr.html_url,
534
563
  vcsInstallationId: installation?.id,
535
564
  contextKey: buildGithubContextKey(repository.full_name, "pr", pr.number),
565
+ requestedByUserId,
536
566
  });
537
567
 
538
568
  if (lead) {
@@ -638,6 +668,14 @@ export async function handleIssue(
638
668
  return { created: false };
639
669
  }
640
670
 
671
+ // Config gate: skip cancel if disabled
672
+ if (!cancelOnUnassignEnabled()) {
673
+ console.log(
674
+ `[GitHub] unassign cancel disabled by config — leaving task untouched (issue #${issue.number})`,
675
+ );
676
+ return { created: false };
677
+ }
678
+
641
679
  // Find the related task
642
680
  const task = findTaskByVcs(repository.full_name, issue.number);
643
681
  if (!task) {
@@ -771,6 +809,7 @@ export async function handleIssue(
771
809
  vcsUrl: issue.html_url,
772
810
  vcsInstallationId: installation?.id,
773
811
  contextKey: buildGithubContextKey(repository.full_name, "issue", issue.number),
812
+ requestedByUserId,
774
813
  });
775
814
 
776
815
  if (lead) {
@@ -1,5 +1,5 @@
1
1
  import {
2
- claimTask,
2
+ assignUnassignedTaskPending,
3
3
  cleanupStaleSessions,
4
4
  createTaskExtended,
5
5
  deleteActiveSession,
@@ -461,7 +461,7 @@ function checkWorkerHealth(findings: HeartbeatFindings): void {
461
461
 
462
462
  /**
463
463
  * Auto-assign unassigned pool tasks to idle workers with capacity.
464
- * Uses atomic claimTask() to prevent races.
464
+ * Leaves tasks pending so the assigned worker's normal poll dispatches them.
465
465
  */
466
466
  function autoAssignPoolTasks(findings: HeartbeatFindings): void {
467
467
  getDb().transaction(() => {
@@ -472,16 +472,37 @@ function autoAssignPoolTasks(findings: HeartbeatFindings): void {
472
472
  if (poolTasks.length === 0) return;
473
473
 
474
474
  let workerIndex = 0;
475
+ const reservedByWorker = new Map<string, number>();
476
+ const reservedForWorker = (agentId: string): number => {
477
+ const cached = reservedByWorker.get(agentId);
478
+ if (cached !== undefined) return cached;
479
+ const row = getDb()
480
+ .prepare<{ count: number }, [string]>(
481
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status IN ('pending', 'in_progress')",
482
+ )
483
+ .get(agentId);
484
+ const reserved = row?.count ?? 0;
485
+ reservedByWorker.set(agentId, reserved);
486
+ return reserved;
487
+ };
488
+
475
489
  for (const task of poolTasks) {
476
490
  if (workerIndex >= idleWorkers.length) break;
477
491
 
478
492
  const worker = idleWorkers[workerIndex]!;
479
- const claimed = claimTask(task.id, worker.id);
493
+ const maxTasks = worker.maxTasks ?? 1;
494
+ if (reservedForWorker(worker.id) >= maxTasks) {
495
+ workerIndex++;
496
+ continue;
497
+ }
498
+
499
+ const assigned = assignUnassignedTaskPending(task.id, worker.id);
480
500
 
481
- if (claimed) {
501
+ if (assigned) {
482
502
  findings.autoAssigned.push({ taskId: task.id, agentId: worker.id });
503
+ reservedByWorker.set(worker.id, reservedForWorker(worker.id) + 1);
483
504
  // Check if this worker still has capacity for more
484
- const remaining = (worker.maxTasks ?? 1) - getActiveTaskCount(worker.id);
505
+ const remaining = maxTasks - reservedForWorker(worker.id);
485
506
  if (remaining <= 0) {
486
507
  workerIndex++;
487
508
  }
@@ -8,6 +8,7 @@ import {
8
8
  getActiveSessions,
9
9
  heartbeatActiveSession,
10
10
  insertActiveSession,
11
+ resetOrphanedInProgressTasksForAgent,
11
12
  updateActiveSessionProviderSessionId,
12
13
  } from "../be/db";
13
14
  import { route } from "./route-def";
@@ -115,6 +116,21 @@ const cleanupSessions = route({
115
116
  },
116
117
  });
117
118
 
119
+ const recoverOrphanedTasks = route({
120
+ method: "post",
121
+ path: "/api/active-sessions/recover-orphaned-tasks",
122
+ pattern: ["api", "active-sessions", "recover-orphaned-tasks"],
123
+ summary: "Recover orphaned in-progress tasks for an agent",
124
+ tags: ["Active Sessions"],
125
+ body: z.object({
126
+ agentId: z.string().min(1),
127
+ minAgeSeconds: z.number().int().positive().optional(),
128
+ }),
129
+ responses: {
130
+ 200: { description: "Recovery result" },
131
+ },
132
+ });
133
+
118
134
  // ─── Handler ─────────────────────────────────────────────────────────────────
119
135
 
120
136
  export async function handleActiveSessions(
@@ -122,7 +138,7 @@ export async function handleActiveSessions(
122
138
  res: ServerResponse,
123
139
  pathSegments: string[],
124
140
  queryParams: URLSearchParams,
125
- _myAgentId: string | undefined,
141
+ myAgentId: string | undefined,
126
142
  ): Promise<boolean> {
127
143
  if (listActiveSessions.match(req.method, pathSegments)) {
128
144
  const parsed = await listActiveSessions.parse(req, res, pathSegments, queryParams);
@@ -195,5 +211,20 @@ export async function handleActiveSessions(
195
211
  return true;
196
212
  }
197
213
 
214
+ if (recoverOrphanedTasks.match(req.method, pathSegments)) {
215
+ const parsed = await recoverOrphanedTasks.parse(req, res, pathSegments, queryParams);
216
+ if (!parsed) return true;
217
+ if (!myAgentId || parsed.body.agentId !== myAgentId) {
218
+ json(res, { error: "Can only recover orphaned tasks for the calling agent" }, 403);
219
+ return true;
220
+ }
221
+ const tasks = resetOrphanedInProgressTasksForAgent(
222
+ parsed.body.agentId,
223
+ parsed.body.minAgeSeconds ?? 60,
224
+ );
225
+ json(res, { recovered: tasks.length, tasks });
226
+ return true;
227
+ }
228
+
198
229
  return false;
199
230
  }
@@ -0,0 +1,36 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import { fingerprintApiKey, resolveUserByToken } from "../be/users";
3
+ import type { User } from "../types";
4
+ import type { HttpRequestAuth } from "../utils/request-auth-context";
5
+
6
+ function extractBearer(req: IncomingMessage): string | null {
7
+ const raw = req.headers.authorization;
8
+ const header = Array.isArray(raw) ? raw[0] : raw;
9
+ if (!header?.startsWith("Bearer ")) return null;
10
+ return header.slice("Bearer ".length).trim();
11
+ }
12
+
13
+ export function resolveHttpRequestAuth(
14
+ req: IncomingMessage,
15
+ apiKey: string | undefined,
16
+ ): HttpRequestAuth | null {
17
+ const bearer = extractBearer(req);
18
+ if (!bearer) return null;
19
+
20
+ if (apiKey && bearer === apiKey) {
21
+ return { kind: "operator", fingerprint: fingerprintApiKey(bearer) };
22
+ }
23
+
24
+ if (bearer.startsWith("aswt_")) {
25
+ const user = resolveUserByToken(bearer);
26
+ if (isActiveUser(user)) {
27
+ return { kind: "user", userId: user.id, user };
28
+ }
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ function isActiveUser(user: User | null): user is User {
35
+ return !!user && user.status === "active";
36
+ }
package/src/http/core.ts CHANGED
@@ -15,7 +15,9 @@ import { initJira, resetJira } from "../jira";
15
15
  import { initLinear, resetLinear } from "../linear";
16
16
  import { startSlackApp, stopSlackApp } from "../slack";
17
17
  import type { AgentStatus } from "../types";
18
+ import { setRequestAuth } from "../utils/request-auth-context";
18
19
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
20
+ import { resolveHttpRequestAuth } from "./auth";
19
21
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
20
22
  import { isPublicRoute } from "./route-def";
21
23
  import { agentWithCapacity, getPathSegments, parseQueryParams } from "./utils";
@@ -234,25 +236,27 @@ export async function handleCore(
234
236
  return true;
235
237
  }
236
238
 
237
- // API-key authentication (if API_KEY is configured). Routes that opt out via
239
+ // API-key authentication. Routes that opt out via
238
240
  // `route({ auth: { apiKey: false } })` — webhooks, OAuth provider callbacks,
239
241
  // etc. — are skipped based on the central `routeRegistry`. Unknown paths
240
- // fall through to the bearer check (fail-closed).
241
- if (apiKey) {
242
- const pathSegments = getPathSegments(req.url || "");
243
- const isUserMcpRoute = req.url === "/mcp-user";
244
- // `/mcp-user` runs its own `aswt_`-token auth in `handleMcpUser`; the swarm
245
- // API key must not gate it.
246
- if (!isUserMcpRoute && !isPublicRoute(req.method, pathSegments)) {
247
- const authHeader = req.headers.authorization;
248
- const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
249
-
250
- if (providedKey !== apiKey) {
251
- res.writeHead(401, { "Content-Type": "application/json" });
252
- res.end(JSON.stringify({ error: "Unauthorized" }));
253
- return true;
254
- }
242
+ // fall through to the bearer check (fail-closed). Normal API calls may use
243
+ // either the global swarm key or an active user-bound `aswt_` token.
244
+ const pathSegments = getPathSegments(req.url || "");
245
+ const isUserMcpRoute = req.url === "/mcp-user";
246
+ // `/mcp-user` runs its own `aswt_`-token auth in `handleMcpUser`; the swarm
247
+ // API key must not gate it.
248
+ if (isUserMcpRoute || isPublicRoute(req.method, pathSegments)) {
249
+ setRequestAuth(req, null);
250
+ } else {
251
+ const auth = resolveHttpRequestAuth(req, apiKey);
252
+
253
+ if (!auth) {
254
+ setRequestAuth(req, null);
255
+ res.writeHead(401, { "Content-Type": "application/json" });
256
+ res.end(JSON.stringify({ error: "Unauthorized" }));
257
+ return true;
255
258
  }
259
+ setRequestAuth(req, auth);
256
260
  }
257
261
 
258
262
  // POST /internal/reload-config — re-read swarm_config into process.env and re-init integrations
@@ -11,6 +11,25 @@ export interface DbQueryResult {
11
11
  total: number;
12
12
  }
13
13
 
14
+ function stripTrailingSemicolon(sql: string): string {
15
+ return sql.trim().replace(/;\s*$/, "").trim();
16
+ }
17
+
18
+ function assertSingleStatement(sql: string): void {
19
+ const stripped = stripTrailingSemicolon(sql);
20
+ if (stripped.includes(";")) {
21
+ throw new Error("Only one SQL statement is allowed");
22
+ }
23
+ }
24
+
25
+ export function assertSelectOnlyQuery(sql: string): void {
26
+ assertSingleStatement(sql);
27
+ const normalized = stripTrailingSemicolon(sql).toLowerCase();
28
+ if (!normalized.startsWith("select ") && !normalized.startsWith("with ")) {
29
+ throw new Error("Metric queries must start with SELECT or WITH");
30
+ }
31
+ }
32
+
14
33
  /**
15
34
  * Execute a read-only SQL query against the swarm database.
16
35
  * Detects write statements via bun:sqlite's columnNames (empty for INSERT/UPDATE/DELETE/DROP).
@@ -20,6 +39,7 @@ export function executeReadOnlyQuery(
20
39
  params: unknown[] = [],
21
40
  maxRows?: number,
22
41
  ): DbQueryResult {
42
+ assertSingleStatement(sql);
23
43
  const stmt = getDb().prepare(sql);
24
44
 
25
45
  // bun:sqlite: columnNames is empty for write statements, populated for SELECT/PRAGMA/EXPLAIN
package/src/http/index.ts CHANGED
@@ -46,6 +46,7 @@ import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from ".
46
46
  import { handleMcpServers } from "./mcp-servers";
47
47
  import { handleMcpUser } from "./mcp-user";
48
48
  import { handleMemory } from "./memory";
49
+ import { handleMetrics } from "./metrics";
49
50
  import { handlePageProxy } from "./page-proxy";
50
51
  import { handlePages } from "./pages";
51
52
  import { handlePagesPublic } from "./pages-public";
@@ -229,6 +230,7 @@ const httpServer = createHttpServer(async (req, res) => {
229
230
  () => handleIntegrations(req, res, pathSegments),
230
231
  () => handlePromptTemplates(req, res, pathSegments, queryParams),
231
232
  () => handleDbQuery(req, res, pathSegments, queryParams),
233
+ () => handleMetrics(req, res, pathSegments, queryParams, myAgentId),
232
234
  () => handleRepos(req, res, pathSegments, queryParams),
233
235
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
234
236
  () => handleScripts(req, res, pathSegments, queryParams, myAgentId),