@desplega.ai/agent-swarm 1.115.0 → 1.117.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 (64) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-17ya5wde.js → actions-7bnvhcgs.js} +10 -8
  3. package/dist/{app-4f718yk0.js → app-976fykvr.js} +3 -3
  4. package/dist/{assistant-ah7csnjz.js → assistant-5drs03t0.js} +13 -11
  5. package/dist/{boot-reembed-4jpxhb25.js → boot-reembed-0zhftca3.js} +4 -4
  6. package/dist/{boot-reembed-qata34xa.js → boot-reembed-wce58xfv.js} +3 -3
  7. package/dist/{boot-scrub-logs-tv48nn3z.js → boot-scrub-logs-vkd2vvxp.js} +2 -2
  8. package/dist/{cli-zsrdty74.js → cli-09j7m5v3.js} +4 -4
  9. package/dist/{cli-beea4a3y.js → cli-1987vgjc.js} +3 -3
  10. package/dist/{cli-k3y4z3h5.js → cli-5ekf4gcj.js} +2 -76
  11. package/dist/{cli-cnwssmsr.js → cli-6058t3w0.js} +1 -1
  12. package/dist/{cli-tffvrh6j.js → cli-9kk0xa5w.js} +1 -1
  13. package/dist/{cli-q1rx7g0d.js → cli-e0xv4nd4.js} +11 -9
  14. package/dist/{cli-12vb0xg6.js → cli-f5cx2j92.js} +2 -2
  15. package/dist/{cli-3samkyap.js → cli-fep22bhf.js} +2 -2
  16. package/dist/{cli-t0vqevnm.js → cli-hmw1vk30.js} +1 -1
  17. package/dist/{cli-pet3aev6.js → cli-jkp1gndy.js} +5 -5
  18. package/dist/{cli-52fg7s7x.js → cli-kfjs4fjw.js} +1 -1
  19. package/dist/{cli-65ekje01.js → cli-m4p6c600.js} +10 -8
  20. package/dist/{cli-cchksf5q.js → cli-ndsyqqqk.js} +2 -2
  21. package/dist/{cli-tece6zgm.js → cli-q0yn57de.js} +1 -1
  22. package/dist/{cli-hrhye2rm.js → cli-r06ed00c.js} +70 -24
  23. package/dist/{cli-7xje5amz.js → cli-rymspw51.js} +1 -1
  24. package/dist/{cli-15y7qhzp.js → cli-s5q026mr.js} +295 -4
  25. package/dist/{cli-shvaxw27.js → cli-t6n84nqv.js} +8 -6
  26. package/dist/{cli-r9f6ngrf.js → cli-vwhg6sbn.js} +2 -2
  27. package/dist/{cli-6aq8xhvm.js → cli-w5axeg22.js} +1 -1
  28. package/dist/{cli-b23axgz4.js → cli-xcp5dmts.js} +2 -2
  29. package/dist/{cli-dcca6j8m.js → cli-yarjrgd5.js} +1 -1
  30. package/dist/{cli-cmw9rzy5.js → cli-z824vphk.js} +2 -2
  31. package/dist/cli.js +10 -10
  32. package/dist/{commands-hch4e2k1.js → commands-b3p0fjzd.js} +2 -2
  33. package/dist/{db-p3h2j06x.js → db-6xw82vz3.js} +2 -2
  34. package/dist/{handlers-9er4zx9q.js → handlers-kwrk085v.js} +13 -11
  35. package/dist/{hook-4axs756z.js → hook-e4wdztg5.js} +1 -1
  36. package/dist/{http-809fvtz2.js → http-x7svk8q5.js} +41 -37
  37. package/dist/{index-rm85cfdx.js → index-35tw5g2v.js} +6 -6
  38. package/dist/{index-47em3d2n.js → index-aadhkkde.js} +10 -10
  39. package/dist/{index-9fqaktjr.js → index-qb6nfqmy.js} +8 -8
  40. package/dist/{index-9ht8gr56.js → index-zj38e3q2.js} +9 -9
  41. package/dist/{keepalive-3cps6e3e.js → keepalive-enehyqc8.js} +4 -4
  42. package/dist/{lead-fkk95wgq.js → lead-nmzd0ykq.js} +18 -18
  43. package/dist/{maintenance-fweqh0yj.js → maintenance-staxb3xt.js} +4 -4
  44. package/dist/{oauth-refresh-sweep-sycqtbph.js → oauth-refresh-sweep-fqczcwxn.js} +3 -3
  45. package/dist/{onboard-cy0rkgvx.js → onboard-shmqj6ep.js} +2 -2
  46. package/dist/{otel-impl-wbns8hf7.js → otel-impl-726bmdyc.js} +1 -1
  47. package/dist/{pricing-refresh-0hvazmkf.js → pricing-refresh-rzwka8rf.js} +4 -4
  48. package/dist/{rbac-roles-kfesjy9y.js → rbac-roles-chkhakmf.js} +3 -3
  49. package/dist/{rbac-roles-mwcwhbxc.js → rbac-roles-j2k0h8sx.js} +2 -2
  50. package/dist/{seed-pricing-h2kdq4cq.js → seed-pricing-vta6a1q3.js} +3 -3
  51. package/dist/{setup-8506q97w.js → setup-yv9ctywz.js} +2 -2
  52. package/dist/{worker-4r5c6zyb.js → worker-7w6571j0.js} +18 -18
  53. package/openapi.json +1 -1
  54. package/package.json +2 -2
  55. package/src/be/db.ts +89 -3
  56. package/src/server-user.ts +1 -0
  57. package/src/tasks/slack-routing.ts +147 -0
  58. package/src/tests/approval-requests.test.ts +36 -0
  59. package/src/tests/http-api-integration.test.ts +38 -0
  60. package/src/tests/pages-complete-fetch.test.ts +54 -0
  61. package/src/tests/send-task-slack-routing-guard.test.ts +355 -0
  62. package/src/tests/slack-metadata-inheritance.test.ts +279 -1
  63. package/src/tests/slack-routing-guard.test.ts +164 -0
  64. package/src/tools/send-task.ts +145 -82
@@ -7,13 +7,13 @@ import {
7
7
  getUserGrant,
8
8
  listUserRoles,
9
9
  runRbacCliCommand
10
- } from "./cli-52fg7s7x.js";
10
+ } from "./cli-kfjs4fjw.js";
11
11
  import"./cli-86fdnerz.js";
12
- import"./cli-15y7qhzp.js";
12
+ import"./cli-s5q026mr.js";
13
13
  import"./cli-z2zcxes1.js";
14
14
  import"./cli-bw80ck94.js";
15
15
  import"./cli-xz9aq0rf.js";
16
- import"./cli-b23axgz4.js";
16
+ import"./cli-xcp5dmts.js";
17
17
  import"./cli-4j2mrzag.js";
18
18
  import"./cli-p9swy5t3.js";
19
19
  export {
@@ -4,11 +4,11 @@ import {
4
4
  import {
5
5
  getDb,
6
6
  init_db
7
- } from "./cli-15y7qhzp.js";
7
+ } from "./cli-s5q026mr.js";
8
8
  import"./cli-z2zcxes1.js";
9
9
  import"./cli-bw80ck94.js";
10
10
  import"./cli-xz9aq0rf.js";
11
- import"./cli-b23axgz4.js";
11
+ import"./cli-xcp5dmts.js";
12
12
  import"./cli-4j2mrzag.js";
13
13
  import"./cli-p9swy5t3.js";
14
14
 
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  buildModelsDevSeedRows,
3
3
  seedPricingFromModelsDev
4
- } from "./cli-tece6zgm.js";
5
- import"./cli-15y7qhzp.js";
4
+ } from "./cli-q0yn57de.js";
5
+ import"./cli-s5q026mr.js";
6
6
  import"./cli-z2zcxes1.js";
7
7
  import"./cli-bw80ck94.js";
8
8
  import"./cli-xz9aq0rf.js";
9
- import"./cli-b23axgz4.js";
9
+ import"./cli-xcp5dmts.js";
10
10
  import"./cli-4j2mrzag.js";
11
11
  import"./cli-p9swy5t3.js";
12
12
  export {
@@ -4,14 +4,14 @@ import {
4
4
  createDefaultMcpJson,
5
5
  createDefaultSettingsLocal,
6
6
  createHooksConfig
7
- } from "./cli-cnwssmsr.js";
7
+ } from "./cli-6058t3w0.js";
8
8
  import {
9
9
  require_jsx_dev_runtime
10
10
  } from "./cli-wx82tw1k.js";
11
11
  import {
12
12
  getApiKey
13
13
  } from "./cli-f14fvzag.js";
14
- import"./cli-b23axgz4.js";
14
+ import"./cli-xcp5dmts.js";
15
15
  import {
16
16
  Box_default,
17
17
  Spinner,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runAgent
3
- } from "./cli-zsrdty74.js";
3
+ } from "./cli-09j7m5v3.js";
4
4
  import"./cli-tyfxrrdd.js";
5
5
  import"./cli-bc89my0z.js";
6
6
  import"./cli-yazx6hap.js";
@@ -14,7 +14,7 @@ import"./cli-d6n0thxw.js";
14
14
  import"./cli-yv9dttkh.js";
15
15
  import {
16
16
  getEnabledCapabilities
17
- } from "./cli-hrhye2rm.js";
17
+ } from "./cli-r06ed00c.js";
18
18
  import"./cli-1z7048dy.js";
19
19
  import"./cli-ftyf58xj.js";
20
20
  import"./cli-tk9vyqzh.js";
@@ -25,36 +25,36 @@ import"./cli-psrqtw2m.js";
25
25
  import"./cli-zm7njwzd.js";
26
26
  import"./cli-tghre7g6.js";
27
27
  import"./cli-e61fs2hp.js";
28
- import"./cli-3samkyap.js";
29
- import"./cli-tece6zgm.js";
30
- import"./cli-52fg7s7x.js";
28
+ import"./cli-fep22bhf.js";
29
+ import"./cli-q0yn57de.js";
30
+ import"./cli-kfjs4fjw.js";
31
31
  import"./cli-86fdnerz.js";
32
- import"./cli-dcca6j8m.js";
32
+ import"./cli-yarjrgd5.js";
33
33
  import"./cli-fad8m16k.js";
34
- import"./cli-shvaxw27.js";
35
- import"./cli-q1rx7g0d.js";
34
+ import"./cli-t6n84nqv.js";
35
+ import"./cli-e0xv4nd4.js";
36
36
  import"./cli-f14fvzag.js";
37
- import"./cli-r9f6ngrf.js";
37
+ import"./cli-vwhg6sbn.js";
38
38
  import"./cli-jkhwmmgg.js";
39
- import"./cli-cmw9rzy5.js";
40
- import"./cli-7xje5amz.js";
39
+ import"./cli-z824vphk.js";
40
+ import"./cli-rymspw51.js";
41
41
  import"./cli-q25ejbyn.js";
42
- import"./cli-t0vqevnm.js";
42
+ import"./cli-hmw1vk30.js";
43
43
  import"./cli-0pygq9r1.js";
44
- import"./cli-tffvrh6j.js";
45
- import"./cli-pet3aev6.js";
44
+ import"./cli-9kk0xa5w.js";
45
+ import"./cli-jkp1gndy.js";
46
46
  import"./cli-rkndnn90.js";
47
47
  import"./cli-b0p7rfnd.js";
48
- import"./cli-6aq8xhvm.js";
48
+ import"./cli-w5axeg22.js";
49
49
  import"./cli-ma04faw5.js";
50
50
  import"./cli-wspgs9bt.js";
51
- import"./cli-k3y4z3h5.js";
51
+ import"./cli-5ekf4gcj.js";
52
52
  import"./cli-dh55d5fg.js";
53
- import"./cli-15y7qhzp.js";
53
+ import"./cli-s5q026mr.js";
54
54
  import"./cli-z2zcxes1.js";
55
55
  import"./cli-bw80ck94.js";
56
56
  import"./cli-xz9aq0rf.js";
57
- import"./cli-b23axgz4.js";
57
+ import"./cli-xcp5dmts.js";
58
58
  import"./cli-4j2mrzag.js";
59
59
  import"./cli-e15jhafb.js";
60
60
  import"./cli-9aryf9tv.js";
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.115.0",
5
+ "version": "1.117.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.115.0",
3
+ "version": "1.117.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -146,7 +146,7 @@
146
146
  "@inkjs/ui": "^2.0.0",
147
147
  "@linear/sdk": "^77.0.0",
148
148
  "@modelcontextprotocol/sdk": "^1.25.1",
149
- "@openai/codex-sdk": "^0.143.0",
149
+ "@openai/codex-sdk": "^0.144.1",
150
150
  "@opencode-ai/sdk": "^1.17.15",
151
151
  "@openfort/openfort-node": "^0.9.1",
152
152
  "@opentelemetry/api": "^1.9.1",
package/src/be/db.ts CHANGED
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { parseProviderMeta } from "@/utils/provider-metadata.ts";
3
3
  import pkg from "../../package.json";
4
4
  import { configureDbResolver } from "../prompts/resolver";
5
+ import { slackChannelFromContextKey } from "../tasks/slack-routing";
5
6
  import { telemetry } from "../telemetry";
6
7
  import type {
7
8
  ActiveSession,
@@ -3338,6 +3339,16 @@ export interface CreateTaskOptions {
3338
3339
  slackChannelId?: string;
3339
3340
  slackThreadTs?: string;
3340
3341
  slackUserId?: string;
3342
+ /**
3343
+ * Opt out of the residual Slack/contextKey normalization below (see the
3344
+ * "Residual-mismatch guard" comment near the INSERT): a deliberate
3345
+ * cross-channel/thread dispatch (e.g. `send-task`'s
3346
+ * `overrideSlackContext: true`) sets this so its explicit slackChannelId/
3347
+ * slackThreadTs survive even when they disagree with a slack-family
3348
+ * `contextKey`/parent. Trusted callers that don't set this get normalized —
3349
+ * this boundary must not let a caller silently persist a mismatch.
3350
+ */
3351
+ overrideSlackContext?: boolean;
3341
3352
  vcsProvider?: "github" | "gitlab";
3342
3353
  vcsRepo?: string;
3343
3354
  vcsEventType?: string;
@@ -3450,14 +3461,24 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
3450
3461
  // is the single source of truth — `createResumeFollowUp` and the other
3451
3462
  // follow-up creators rely on this block instead of re-listing fields.
3452
3463
 
3453
- // Slack context
3464
+ // Slack context — inherited as an atomic unit. A foreign channelId's
3465
+ // thread ts is meaningless to Slack's API, so an explicit slackChannelId
3466
+ // that DIFFERS from the parent's must not pull in the parent's
3467
+ // slackThreadTs/slackUserId (that combination misroutes/fails to thread —
3468
+ // see swarm memory dispatch-slack-channel-must-match-parent-context-2026-07-10).
3469
+ // When the explicit channel matches the parent's (or is unset), per-field
3470
+ // fill-in proceeds as before.
3471
+ const explicitForeignChannel =
3472
+ !!options.slackChannelId &&
3473
+ !!parent.slackChannelId &&
3474
+ options.slackChannelId !== parent.slackChannelId;
3454
3475
  if (parent.slackChannelId && !options.slackChannelId) {
3455
3476
  options.slackChannelId = parent.slackChannelId;
3456
3477
  }
3457
- if (parent.slackThreadTs && !options.slackThreadTs) {
3478
+ if (parent.slackThreadTs && !options.slackThreadTs && !explicitForeignChannel) {
3458
3479
  options.slackThreadTs = parent.slackThreadTs;
3459
3480
  }
3460
- if (parent.slackUserId && !options.slackUserId) {
3481
+ if (parent.slackUserId && !options.slackUserId && !explicitForeignChannel) {
3461
3482
  options.slackUserId = parent.slackUserId;
3462
3483
  }
3463
3484
 
@@ -3585,6 +3606,71 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
3585
3606
  }
3586
3607
  }
3587
3608
 
3609
+ // contextKey → Slack-fields backfill: a slack-family contextKey is the
3610
+ // durable record of where this task belongs, but delivery code
3611
+ // (src/slack/responses.ts, src/slack/watcher.ts, src/tools/slack-reply.ts)
3612
+ // reads slackChannelId/slackThreadTs exclusively. Without this, a task that
3613
+ // inherited only a slack contextKey (no Slack fields) would silently never
3614
+ // deliver. Deliberately does NOT backfill slackUserId — the key doesn't
3615
+ // encode it.
3616
+ if (!options?.slackChannelId) {
3617
+ const backfill = slackChannelFromContextKey(options?.contextKey);
3618
+ if (backfill && options) {
3619
+ options.slackChannelId = backfill.channelId;
3620
+ options.slackThreadTs = backfill.threadTs;
3621
+ }
3622
+ } else if (!options.slackThreadTs) {
3623
+ // Channel present but no thread: a trusted caller supplied slackChannelId
3624
+ // without slackThreadTs. If it matches the slack-family contextKey's
3625
+ // channel, fill the thread from there too — otherwise delivery (which
3626
+ // reads slackThreadTs directly) silently fails to thread.
3627
+ const backfill = slackChannelFromContextKey(options.contextKey);
3628
+ if (backfill && backfill.channelId === options.slackChannelId) {
3629
+ options.slackThreadTs = backfill.threadTs;
3630
+ }
3631
+ }
3632
+
3633
+ // Residual-mismatch guard: after all inheritance/backfill above, a final
3634
+ // slackChannelId/slackThreadTs that still disagrees with a slack-family
3635
+ // contextKey would misroute delivery — src/slack/responses.ts,
3636
+ // src/slack/watcher.ts, and src/tools/slack-reply.ts all read these fields
3637
+ // directly, so logging a warning and inserting the mismatch unchanged does
3638
+ // NOT prevent misdelivery. This boundary is reachable by untrusted-ish
3639
+ // callers too (e.g. POST /api/tasks accepts client-supplied parentTaskId +
3640
+ // contextKey), so a non-override caller must not be able to persist a
3641
+ // mismatch: the durable contextKey wins and the Slack fields are
3642
+ // normalized to match it. `overrideSlackContext` (set by `send-task` when
3643
+ // the caller passed `overrideSlackContext: true`) opts out, preserving the
3644
+ // deliberately divergent lineage. Never throw here — this also runs on
3645
+ // trusted ingress hot paths that must not fail task creation.
3646
+ const finalSlackContext = slackChannelFromContextKey(options?.contextKey);
3647
+ if (finalSlackContext && options?.slackChannelId) {
3648
+ if (options.slackChannelId !== finalSlackContext.channelId) {
3649
+ if (options.overrideSlackContext) {
3650
+ console.log(
3651
+ `[slack-routing] override: keeping slackChannelId="${options.slackChannelId}" despite disagreeing with contextKey channel "${finalSlackContext.channelId}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
3652
+ );
3653
+ } else {
3654
+ console.warn(
3655
+ `[slack-routing] MISMATCH task creation: normalizing slackChannelId="${options.slackChannelId}" to contextKey channel "${finalSlackContext.channelId}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
3656
+ );
3657
+ options.slackChannelId = finalSlackContext.channelId;
3658
+ options.slackThreadTs = finalSlackContext.threadTs;
3659
+ }
3660
+ } else if (options.slackThreadTs && options.slackThreadTs !== finalSlackContext.threadTs) {
3661
+ if (options.overrideSlackContext) {
3662
+ console.log(
3663
+ `[slack-routing] override: keeping slackThreadTs="${options.slackThreadTs}" despite disagreeing with contextKey thread "${finalSlackContext.threadTs}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
3664
+ );
3665
+ } else {
3666
+ console.warn(
3667
+ `[slack-routing] MISMATCH task creation: normalizing slackThreadTs="${options.slackThreadTs}" to contextKey thread "${finalSlackContext.threadTs}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
3668
+ );
3669
+ options.slackThreadTs = finalSlackContext.threadTs;
3670
+ }
3671
+ }
3672
+ }
3673
+
3588
3674
  const auditUserId = getCurrentRequestUserId() ?? null;
3589
3675
  const row = getDb()
3590
3676
  .prepare<AgentTaskRow, (string | number | null)[]>(
@@ -116,6 +116,7 @@ export function createUserServer(user: User): McpServer {
116
116
  return sendTaskHandler(userCtx(user, info.sessionId), {
117
117
  offerMode: false,
118
118
  allowDuplicate: false,
119
+ overrideSlackContext: false,
119
120
  ...args,
120
121
  });
121
122
  });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Slack-routing coherence guard.
3
+ *
4
+ * A task's Slack destination is carried by three fields — `slackChannelId`,
5
+ * `slackThreadTs`, `slackUserId` — which delivery code (`src/slack/responses.ts`,
6
+ * `src/slack/watcher.ts`, `src/tools/slack-reply.ts`) reads directly, alongside
7
+ * the durable, immutable `contextKey` that records where a task's ingress
8
+ * entity actually lives. Nothing upstream compared the two before creating a
9
+ * task, so a caller could hand-type a `slackChannelId` that disagreed with
10
+ * both the parent task and the inherited `contextKey`, silently misrouting a
11
+ * worker's completion summary into the wrong human's DM (see swarm memory
12
+ * `dispatch-slack-channel-must-match-parent-context-2026-07-10`).
13
+ *
14
+ * This module is pure and dependency-light (only the `AgentTask` type +
15
+ * `parseContextKey`) so it can be imported from both the agent-facing tool
16
+ * boundary (`src/tools/send-task.ts`, strict-reject) and the DB boundary
17
+ * (`src/be/db.ts` `createTaskExtended`, normalize-and-warn).
18
+ */
19
+
20
+ import type { AgentTask } from "../types";
21
+ import { parseContextKey } from "./context-key";
22
+
23
+ export type SlackUnit = {
24
+ channelId?: string | null;
25
+ threadTs?: string | null;
26
+ userId?: string | null;
27
+ };
28
+
29
+ export type SlackRoutingVerdict =
30
+ | { verdict: "ok" }
31
+ | { verdict: "partial-unit"; detail: string }
32
+ | {
33
+ verdict: "mismatch";
34
+ field: "slackChannelId" | "slackThreadTs";
35
+ expected: string;
36
+ expectedSource: "parent" | "contextKey";
37
+ got: string;
38
+ detail: string;
39
+ };
40
+
41
+ /**
42
+ * Parse the Slack channel/thread encoded in a `contextKey`, iff it is
43
+ * slack-family. Returns `null` for non-slack families and for malformed keys
44
+ * — a parse failure here must never throw, since this runs on hot creation
45
+ * paths that also handle non-Slack ingress.
46
+ */
47
+ export function slackChannelFromContextKey(
48
+ key: string | null | undefined,
49
+ ): { channelId: string; threadTs: string } | null {
50
+ if (!key) return null;
51
+ try {
52
+ const parsed = parseContextKey(key);
53
+ if (parsed.family !== "slack") return null;
54
+ return { channelId: parsed.parts.channelId, threadTs: parsed.parts.threadTs };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Check whether an explicit Slack unit is coherent with the parent task and
62
+ * the contextKey the new task will carry. `channelId`/`threadTs` must be
63
+ * both-or-neither (delivery requires both); when a channel is present it
64
+ * must agree with the parent's `slackChannelId` AND `slackThreadTs` (if set)
65
+ * and with the channel/thread encoded in a slack-family `inheritedContextKey`
66
+ * (if any). `userId` is attribution only and is never checked — it is
67
+ * harmless on its own. Callers that want a deliberate cross-channel/thread
68
+ * dispatch skip this check entirely (see `overrideSlackContext` at the
69
+ * `send-task` boundary) rather than passing a bypass flag into this helper.
70
+ */
71
+ export function checkSlackRoutingCoherence(input: {
72
+ explicit: SlackUnit;
73
+ parent?: Pick<AgentTask, "slackChannelId" | "slackThreadTs" | "contextKey"> | null;
74
+ inheritedContextKey?: string | null;
75
+ }): SlackRoutingVerdict {
76
+ const { explicit, parent, inheritedContextKey } = input;
77
+ const hasChannel = !!explicit.channelId;
78
+ const hasThread = !!explicit.threadTs;
79
+
80
+ if (hasChannel !== hasThread) {
81
+ return {
82
+ verdict: "partial-unit",
83
+ detail: hasChannel
84
+ ? `slackChannelId "${explicit.channelId}" was passed without slackThreadTs — both are required together for Slack delivery to work.`
85
+ : `slackThreadTs "${explicit.threadTs}" was passed without slackChannelId — both are required together for Slack delivery to work.`,
86
+ };
87
+ }
88
+
89
+ if (!hasChannel) {
90
+ return { verdict: "ok" };
91
+ }
92
+
93
+ const channelId = explicit.channelId as string;
94
+ const threadTs = explicit.threadTs as string;
95
+
96
+ if (parent?.slackChannelId && channelId !== parent.slackChannelId) {
97
+ return {
98
+ verdict: "mismatch",
99
+ field: "slackChannelId",
100
+ expected: parent.slackChannelId,
101
+ expectedSource: "parent",
102
+ got: channelId,
103
+ detail: `explicit slackChannelId "${channelId}" does not match the parent task's slackChannelId "${parent.slackChannelId}".`,
104
+ };
105
+ }
106
+
107
+ // Same channel as the parent, but a different thread: the DB still
108
+ // preserves an explicit slackThreadTs verbatim and delivery reads it
109
+ // directly, so this would post to the wrong thread in the parent's own
110
+ // channel. Only meaningful once we know the channel agrees (above).
111
+ if (parent?.slackThreadTs && threadTs !== parent.slackThreadTs) {
112
+ return {
113
+ verdict: "mismatch",
114
+ field: "slackThreadTs",
115
+ expected: parent.slackThreadTs,
116
+ expectedSource: "parent",
117
+ got: threadTs,
118
+ detail: `explicit slackThreadTs "${threadTs}" does not match the parent task's slackThreadTs "${parent.slackThreadTs}" (same channel "${parent.slackChannelId}").`,
119
+ };
120
+ }
121
+
122
+ const contextKeySlack = slackChannelFromContextKey(inheritedContextKey);
123
+ if (contextKeySlack) {
124
+ if (channelId !== contextKeySlack.channelId) {
125
+ return {
126
+ verdict: "mismatch",
127
+ field: "slackChannelId",
128
+ expected: contextKeySlack.channelId,
129
+ expectedSource: "contextKey",
130
+ got: channelId,
131
+ detail: `explicit slackChannelId "${channelId}" does not match the channel encoded in contextKey "${inheritedContextKey}" ("${contextKeySlack.channelId}").`,
132
+ };
133
+ }
134
+ if (threadTs !== contextKeySlack.threadTs) {
135
+ return {
136
+ verdict: "mismatch",
137
+ field: "slackThreadTs",
138
+ expected: contextKeySlack.threadTs,
139
+ expectedSource: "contextKey",
140
+ got: threadTs,
141
+ detail: `explicit slackThreadTs "${threadTs}" does not match the thread encoded in contextKey "${inheritedContextKey}" ("${contextKeySlack.threadTs}").`,
142
+ };
143
+ }
144
+ }
145
+
146
+ return { verdict: "ok" };
147
+ }
@@ -10,6 +10,7 @@ import {
10
10
  getAgentCurrentTask,
11
11
  getApprovalRequestById,
12
12
  getApprovalRequestByStepId,
13
+ getDb,
13
14
  getExpiredPendingApprovals,
14
15
  initDb,
15
16
  listApprovalRequests,
@@ -441,6 +442,41 @@ describe("Approval Requests", () => {
441
442
  }
442
443
  });
443
444
 
445
+ test("applies status before limiting newer rows", async () => {
446
+ const pending = createApprovalRequest(makeApprovalData({ title: "Older pending request" }));
447
+ getDb()
448
+ .prepare("UPDATE approval_requests SET createdAt = ? WHERE id = ?")
449
+ .run("2099-01-01T00:00:00.000Z", pending.id);
450
+
451
+ for (const index of [1, 2]) {
452
+ const resolved = createApprovalRequest(
453
+ makeApprovalData({ title: `Newer resolved request ${index}` }),
454
+ );
455
+ resolveApprovalRequest(resolved.id, {
456
+ status: "approved",
457
+ responses: { q1: { approved: true } },
458
+ });
459
+ getDb()
460
+ .prepare("UPDATE approval_requests SET createdAt = ? WHERE id = ?")
461
+ .run(`2100-01-0${index}T00:00:00.000Z`, resolved.id);
462
+ }
463
+
464
+ const unfiltered = await fetch(`${baseUrl}/api/approval-requests?limit=1`);
465
+ const unfilteredData = (await unfiltered.json()) as {
466
+ approvalRequests: Array<{ id: string }>;
467
+ };
468
+ expect(unfilteredData.approvalRequests[0]?.id).not.toBe(pending.id);
469
+
470
+ const filtered = await fetch(`${baseUrl}/api/approval-requests?status=pending&limit=1`);
471
+ expect(filtered.status).toBe(200);
472
+ const filteredData = (await filtered.json()) as {
473
+ approvalRequests: Array<{ id: string; status: string }>;
474
+ };
475
+ expect(filteredData.approvalRequests).toEqual([
476
+ expect.objectContaining({ id: pending.id, status: "pending" }),
477
+ ]);
478
+ });
479
+
444
480
  test("filters by workflowRunId", async () => {
445
481
  const runId = crypto.randomUUID();
446
482
  // Create one with this runId
@@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import { rm, unlink } from "node:fs/promises";
10
10
  import type { Subprocess } from "bun";
11
11
  import { Webhook } from "svix";
12
+ import { slackContextKey } from "../tasks/context-key";
12
13
 
13
14
  const TEST_PORT = 19876;
14
15
  const TEST_DB_PATH = `/tmp/test-http-integration-${Date.now()}.sqlite`;
@@ -432,6 +433,43 @@ describe("Tasks", () => {
432
433
  expect(status).toBe(404);
433
434
  });
434
435
 
436
+ test("POST /api/tasks — client-supplied parentTaskId + contextKey cannot persist a Slack route/contextKey mismatch", async () => {
437
+ // POST /api/tasks doesn't accept slackChannelId/slackThreadTs directly,
438
+ // but a slack-family contextKey backfills them (src/be/db.ts). Build a
439
+ // parent whose Slack unit lives in channel A this way.
440
+ const contextKeyA = slackContextKey({ channelId: "HTTP_CHAN_A", threadTs: "1111.1111" });
441
+ const parent = await post("/api/tasks", {
442
+ agentId: ids.leadAgent,
443
+ body: {
444
+ task: "HTTP parent task in channel A",
445
+ contextKey: contextKeyA,
446
+ },
447
+ });
448
+ expect(parent.status).toBe(201);
449
+ expect(parent.body.slackChannelId).toBe("HTTP_CHAN_A");
450
+
451
+ // A child that inherits the parent's Slack unit (via parentTaskId) but
452
+ // carries an explicit contextKey for a DIFFERENT channel B — exactly the
453
+ // client-supplied parentTaskId + contextKey shape the reviewer flagged.
454
+ const contextKeyB = slackContextKey({ channelId: "HTTP_CHAN_B", threadTs: "2222.2222" });
455
+ const child = await post("/api/tasks", {
456
+ agentId: ids.leadAgent,
457
+ body: {
458
+ task: "HTTP child task claiming a different channel via contextKey",
459
+ parentTaskId: parent.body.id,
460
+ contextKey: contextKeyB,
461
+ },
462
+ });
463
+ expect(child.status).toBe(201);
464
+
465
+ // The durable contextKey (channel B) must win over the parent-inherited
466
+ // Slack unit (channel A) — a non-override HTTP caller cannot persist the
467
+ // mismatch that misroutes delivery.
468
+ expect(child.body.slackChannelId).toBe("HTTP_CHAN_B");
469
+ expect(child.body.slackThreadTs).toBe("2222.2222");
470
+ expect(child.body.contextKey).toBe(contextKeyB);
471
+ });
472
+
435
473
  test("PUT /api/tasks/:id/session — update session ID", async () => {
436
474
  const sessionId = randomUUID();
437
475
  const { status, body } = await put(`/api/tasks/${ids.task2}/session`, {
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ fetchAllPages,
4
+ PAGES_BATCH_SIZE,
5
+ type PagesBatchFilters,
6
+ } from "../../apps/ui/src/api/fetch-all-pages";
7
+ import type { PageListItem } from "../../apps/ui/src/api/types";
8
+
9
+ function page(index: number): PageListItem {
10
+ const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString();
11
+ return {
12
+ id: `page-${index}`,
13
+ agentId: index === 620 ? "matching-agent" : "other-agent",
14
+ slug: `page-${index}`,
15
+ title: `Page ${index}`,
16
+ contentType: "text/html",
17
+ authMode: "public",
18
+ body: "",
19
+ viewCount: 0,
20
+ createdAt: timestamp,
21
+ updatedAt: timestamp,
22
+ app_url: `/pages/page-${index}`,
23
+ api_url: `/p/page-${index}`,
24
+ };
25
+ }
26
+
27
+ describe("fetchAllPages", () => {
28
+ test("loads candidates beyond the default page and endpoint batch cap", async () => {
29
+ const source = Array.from({ length: 625 }, (_, index) => page(index));
30
+ const calls: PagesBatchFilters[] = [];
31
+
32
+ const result = await fetchAllPages(async (filters) => {
33
+ calls.push(filters);
34
+ const offset = filters.offset ?? 0;
35
+ const limit = filters.limit ?? 50;
36
+ return {
37
+ pages: source.slice(offset, offset + limit),
38
+ total: source.length,
39
+ };
40
+ });
41
+
42
+ expect(calls).toEqual([
43
+ { limit: PAGES_BATCH_SIZE, offset: 0 },
44
+ { limit: PAGES_BATCH_SIZE, offset: PAGES_BATCH_SIZE },
45
+ ]);
46
+ expect(result.pages).toHaveLength(source.length);
47
+ expect(result.pages.find((candidate) => candidate.agentId === "matching-agent")?.id).toBe(
48
+ "page-620",
49
+ );
50
+ expect(result.pages.map((candidate) => candidate.id)).toEqual(
51
+ source.map((candidate) => candidate.id),
52
+ );
53
+ });
54
+ });