@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

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 (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,309 @@
1
+ // #500 step 2 —— 派活时让发送方看见目标的队列。HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ // test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
3
+ //
4
+ // 任务一律经真入口派出:POST /api/task(用户 / 节点令牌)和 /mcp send_task(节点令牌);消息经 /mcp send_message。
5
+ // 只有「历史耗时」和「已开工」这类节点侧时间戳用 SQL 拨。
6
+ //
7
+ // 钉住:
8
+ // 1. queue_ahead = 派这一条之前目标上开着的任务(created/delivered/acked/running,24 小时内);
9
+ // 消息不算、终态不算、超过 24 小时的遗弃行不算、别的节点 / 别的网络不算。REST 与 MCP 同一个数。
10
+ // 2. target_busy:有已开工(acked / running / consumed_at)的开着任务 → true;会话 working(report_status 真入口)→ true。
11
+ // 3. est_wait_minutes:前面没人 → 0;有人但没历史(< 3 条 replied)→ null;有历史 → 中位数 × queue_ahead。
12
+ // 4. warning:queue_ahead 2 → 没有;3 → 有,且仍然 200 ok(从不拒绝);est_wait > 30 → 有。
13
+ // 5. /api/status 全量每行 queue_depth;light / 旧别名解析器投影没有这个字段(字节不变);
14
+ // 只写 tasks 的语句也让全量缓存失效(ETag 变、queue_depth 跟着变);MCP get_all_status 每行 queue_depth。
15
+ //
16
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/task-queue-ahead-http.test.ts
17
+
18
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const DIR = mkdtempSync(join(tmpdir(), "anet-queue-ahead-"));
24
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
25
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
26
+ process.env.HOST = "127.0.0.1";
27
+ const PW = "QueueAhead123!x";
28
+ const stamp = Date.now();
29
+
30
+ let db: any;
31
+ let hub: any = null;
32
+ let BASE = "";
33
+ let NET = "", NET2 = "";
34
+ let bossToken = "", boss2Token = "";
35
+ type Node = { alias: string; nodeId: string; token: string };
36
+ const nodes: Record<"sender" | "target" | "idle", Node> = {} as any;
37
+ let other: Node;
38
+
39
+ async function send(token: string, method: string, path: string, payload?: unknown, headers: Record<string, string> = {}) {
40
+ const res = await fetch(`${BASE}${path}`, {
41
+ method,
42
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...headers },
43
+ body: payload === undefined ? undefined : JSON.stringify(payload),
44
+ });
45
+ return { status: res.status, headers: res.headers, body: await res.json().catch(() => null) as any };
46
+ }
47
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
48
+ const res = await fetch(`${BASE}/mcp`, {
49
+ method: "POST",
50
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
51
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
52
+ });
53
+ const raw = await res.text();
54
+ const data = raw.split("\n").filter((x) => x.startsWith("data:"));
55
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
56
+ return JSON.parse(payload.result.content[0].text);
57
+ }
58
+
59
+ let seq = 0;
60
+ const text = () => `queue case ${stamp} #${++seq}`;
61
+ /** 经 POST /api/task 派一条,返回整个响应体。 */
62
+ async function dispatch(token: string, to: string, extra: Record<string, unknown> = {}) {
63
+ const r = await send(token, "POST", "/api/task", { alias: to, task: text(), network_id: NET, ...extra });
64
+ expect(r.status).toBe(200);
65
+ expect(r.body.ok).toBe(true);
66
+ return r.body;
67
+ }
68
+ const setStatus = (taskId: string, status: string) => db.run("UPDATE tasks SET status = ?1 WHERE task_id = ?2", [status, taskId]);
69
+ const reportStatus = (n: Node, status: string, task?: string) =>
70
+ tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status, node_id: n.nodeId, network_id: NET, ...(task ? { task } : {}) });
71
+ /** 目标上 3 条 replied 历史:耗时 10 / 20 / 30 分钟(started_at 到 completed_at),中位数 20。 */
72
+ async function seedHistory(to: Node, minutes = [10, 20, 30]) {
73
+ for (const m of minutes) {
74
+ const b = await dispatch(bossToken, to.alias);
75
+ db.run(
76
+ `UPDATE tasks SET status = 'replied', created_at = datetime('now', '-${m + 5} minutes'), started_at = datetime('now', '-${m + 1} minutes'),
77
+ completed_at = datetime('now', '-1 minutes') WHERE task_id = ?1`,
78
+ [b.task_id],
79
+ );
80
+ }
81
+ }
82
+
83
+ beforeAll(async () => {
84
+ ({ db } = await import("./db.js"));
85
+ const { createNetworkTokenForNode, register } = await import("./auth.js");
86
+ const boss = register(`qa_boss_${stamp}`, PW);
87
+ expect(boss.ok).toBe(true);
88
+ bossToken = boss.token!; NET = boss.network_id!;
89
+ const bossId = boss.user!.user_id;
90
+ for (const key of ["sender", "target", "idle"] as const) {
91
+ const alias = `qa-${key}-${stamp}`;
92
+ const nodeId = `n_qa_${key}_${stamp}`;
93
+ const minted = createNetworkTokenForNode(bossId, NET, alias, nodeId);
94
+ expect(minted.ok).toBe(true);
95
+ nodes[key] = { alias, nodeId, token: minted.token! };
96
+ }
97
+ // 另一个网络里一个**同名**节点:它的任务不许算进 NET 的队列。
98
+ const boss2 = register(`qa_boss2_${stamp}`, PW);
99
+ boss2Token = boss2.token!; NET2 = boss2.network_id!;
100
+ const m2 = createNetworkTokenForNode(boss2.user!.user_id, NET2, nodes.target.alias, `n_qa_other_${stamp}`);
101
+ other = { alias: nodes.target.alias, nodeId: `n_qa_other_${stamp}`, token: m2.token! };
102
+ const mod: any = await import("./server.js");
103
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
104
+ BASE = `http://127.0.0.1:${hub.port}`;
105
+ for (const n of Object.values(nodes)) expect((await reportStatus(n, "idle")).ok).toBe(true);
106
+ expect((await tool(other.token, "report_status", { resume_id: `sdk-${other.nodeId}`, alias: other.alias, status: "idle", node_id: other.nodeId, network_id: NET2 })).ok).toBe(true);
107
+ }, 30_000);
108
+
109
+ beforeEach(async () => {
110
+ // 每条用例从「目标上没有开着的任务、没有历史、会话 idle」开始。
111
+ db.run("UPDATE tasks SET status = 'cancelled', completed_at = datetime('now') WHERE network_id IN (?1, ?2) AND status IN ('created', 'delivered', 'acked', 'running')", [NET, NET2]);
112
+ db.run("DELETE FROM tasks WHERE network_id = ?1 AND status = 'replied'", [NET]);
113
+ for (const n of Object.values(nodes)) await reportStatus(n, "idle");
114
+ });
115
+
116
+ afterAll(() => {
117
+ try { hub?.stop?.(true); } catch {}
118
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
119
+ });
120
+
121
+ describe("#500 queue_ahead on dispatch", () => {
122
+ test("empty target → queue_ahead 0, not busy, est_wait 0, no warning (REST and MCP)", async () => {
123
+ const r = await dispatch(nodes.sender.token, nodes.target.alias);
124
+ expect(r).toMatchObject({ queue_ahead: 0, target_busy: false, est_wait_minutes: 0 });
125
+ expect(r.warning).toBeUndefined();
126
+ setStatus(r.task_id, "cancelled");
127
+ const m = await tool(nodes.sender.token, "send_task", { alias: nodes.target.alias, task: text(), network_id: NET });
128
+ expect(m.ok).toBe(true);
129
+ expect(m).toMatchObject({ queue_ahead: 0, target_busy: false, est_wait_minutes: 0 });
130
+ expect(m.warning).toBeUndefined();
131
+ });
132
+
133
+ test("counts open tasks only: messages, terminal, >24 h stale, other nodes and other networks are excluded", async () => {
134
+ const a = await dispatch(bossToken, nodes.target.alias); // delivered → counts
135
+ const b = await dispatch(nodes.idle.token, nodes.target.alias); // delivered → counts (another sender)
136
+ // 消息:只写 inbox,不算。
137
+ expect((await tool(nodes.sender.token, "send_message", { alias: nodes.target.alias, message: `msg ${stamp}`, network_id: NET })).ok).toBe(true);
138
+ // 终态:不算。
139
+ for (const st of ["replied", "failed", "expired", "cancelled"]) setStatus((await dispatch(bossToken, nodes.target.alias)).task_id, st);
140
+ // 24 小时之前派出、从没终态的遗弃行:不算。
141
+ const stale = await dispatch(bossToken, nodes.target.alias);
142
+ db.run("UPDATE tasks SET status = 'running', created_at = datetime('now', '-25 hours') WHERE task_id = ?1", [stale.task_id]);
143
+ // 别的节点 / 别的网络里同名节点:不算。
144
+ await dispatch(bossToken, nodes.idle.alias);
145
+ const o = await send(boss2Token, "POST", "/api/task", { alias: other.alias, task: text(), network_id: NET2 });
146
+ expect(o.status).toBe(200);
147
+ expect(o.body.queue_ahead).toBe(0);
148
+
149
+ const r = await dispatch(nodes.sender.token, nodes.target.alias);
150
+ expect(r.queue_ahead).toBe(2);
151
+ expect(r.target_busy).toBe(false);
152
+ // MCP 同一个数(上一条也开着了 → 3)。
153
+ const m = await tool(nodes.sender.token, "send_task", { alias: nodes.target.alias, task: text(), network_id: NET });
154
+ expect(m.queue_ahead).toBe(3);
155
+ // acked / running 也是开着的。
156
+ setStatus(a.task_id, "acked"); setStatus(b.task_id, "running");
157
+ const r2 = await dispatch(nodes.sender.token, nodes.target.alias);
158
+ expect(r2.queue_ahead).toBe(4);
159
+ });
160
+
161
+ test("target_busy: a started open task, or the session reporting working", async () => {
162
+ const a = await dispatch(bossToken, nodes.target.alias);
163
+ expect((await dispatch(nodes.sender.token, nodes.target.alias)).target_busy).toBe(false);
164
+ // 运行时取走(consumed_at)但状态还是 delivered → 已开工。
165
+ db.run("UPDATE tasks SET consumed_at = datetime('now') WHERE task_id = ?1", [a.task_id]);
166
+ expect((await dispatch(nodes.sender.token, nodes.target.alias)).target_busy).toBe(true);
167
+ db.run("UPDATE tasks SET status = 'cancelled' WHERE network_id = ?1 AND status = 'delivered'", [NET]);
168
+ expect((await dispatch(nodes.sender.token, nodes.target.alias)).target_busy).toBe(false);
169
+ // 真入口:节点 report_status(working, task=<原文>)→ 任务 running + 会话 working。
170
+ const c = await dispatch(bossToken, nodes.target.alias);
171
+ const content = db.get("SELECT content FROM tasks WHERE task_id = ?1", c.task_id).content;
172
+ expect((await reportStatus(nodes.target, "working", content)).ok).toBe(true);
173
+ expect(db.get("SELECT status FROM tasks WHERE task_id = ?1", c.task_id).status).toBe("running");
174
+ const r = await dispatch(nodes.sender.token, nodes.target.alias);
175
+ expect(r.target_busy).toBe(true);
176
+ expect(r.queue_ahead).toBe(2);
177
+ // 会话 working 但没有开着的任务 → 仍然 busy(节点自己说在忙)。
178
+ db.run("UPDATE tasks SET status = 'cancelled' WHERE network_id = ?1 AND status IN ('delivered','running')", [NET]);
179
+ const w = await dispatch(nodes.sender.token, nodes.target.alias);
180
+ expect(w).toMatchObject({ queue_ahead: 0, target_busy: true, est_wait_minutes: 0 });
181
+ });
182
+
183
+ test("est_wait_minutes: null without history, median × queue_ahead with it", async () => {
184
+ await dispatch(bossToken, nodes.target.alias);
185
+ const noHist = await dispatch(nodes.sender.token, nodes.target.alias);
186
+ expect(noHist.queue_ahead).toBe(1);
187
+ expect(noHist.est_wait_minutes).toBeNull();
188
+ // 两条历史还不够(< 3)。
189
+ await seedHistory(nodes.target, [10, 20]);
190
+ expect((await dispatch(nodes.sender.token, nodes.target.alias)).est_wait_minutes).toBeNull();
191
+ db.run("DELETE FROM tasks WHERE network_id = ?1 AND status = 'replied'", [NET]);
192
+ await seedHistory(nodes.target, [10, 20, 60]);
193
+ const r = await dispatch(nodes.sender.token, nodes.target.alias);
194
+ expect(r.queue_ahead).toBe(3);
195
+ expect(r.est_wait_minutes).toBe(60); // 中位数 20 × 3(平均数会是 30 × 3 = 90)
196
+ // 别的节点的历史不借用。
197
+ const idle = await dispatch(nodes.sender.token, nodes.idle.alias);
198
+ expect(idle.est_wait_minutes).toBe(0);
199
+ const idle2 = await dispatch(nodes.sender.token, nodes.idle.alias);
200
+ expect(idle2.est_wait_minutes).toBeNull();
201
+ });
202
+
203
+ test("warning at queue_ahead ≥ 3 or est_wait > 30 — the send still succeeds", async () => {
204
+ await dispatch(bossToken, nodes.target.alias);
205
+ await dispatch(bossToken, nodes.target.alias);
206
+ const two = await dispatch(nodes.sender.token, nodes.target.alias); // 前面 2 条
207
+ expect(two.queue_ahead).toBe(2);
208
+ expect(two.warning).toBeUndefined();
209
+ const three = await dispatch(nodes.sender.token, nodes.target.alias, { ttl_seconds: 1800 }); // 前面 3 条
210
+ expect(three.ok).toBe(true);
211
+ expect(three.queue_ahead).toBe(3);
212
+ expect(typeof three.warning).toBe("string");
213
+ for (const frag of [nodes.target.alias, "3 open task(s) ahead", "queued, not refused", "idle node", "ttl_seconds", "30 min", "Do not resend"]) {
214
+ expect(three.warning).toContain(frag);
215
+ }
216
+ // 任务确实写进去了(没被拒)。
217
+ expect(db.get("SELECT status FROM tasks WHERE task_id = ?1", three.task_id).status).toBe("delivered");
218
+ // MCP 同样带 warning。
219
+ const m = await tool(nodes.sender.token, "send_task", { alias: nodes.target.alias, task: text(), network_id: NET });
220
+ expect(m.ok).toBe(true);
221
+ expect(m.queue_ahead).toBe(4);
222
+ expect(m.warning).toContain("4 open task(s) ahead");
223
+
224
+ // 只排 1 个,但估计要等 > 30 分钟 → 也给。
225
+ db.run("UPDATE tasks SET status = 'cancelled' WHERE network_id = ?1 AND status = 'delivered'", [NET]);
226
+ await seedHistory(nodes.target, [40, 45, 50]);
227
+ await dispatch(bossToken, nodes.target.alias);
228
+ const slow = await dispatch(nodes.sender.token, nodes.target.alias, { ttl_seconds: 1800 });
229
+ expect(slow).toMatchObject({ queue_ahead: 1, est_wait_minutes: 45 });
230
+ expect(slow.warning).toContain("rough wait ~45 min");
231
+ expect(slow.warning).toContain("may expire before it starts");
232
+ });
233
+ });
234
+
235
+ describe("#500 queue_depth on status reads", () => {
236
+ const rowOf = (body: any, alias: string) => body.sessions.find((s: any) => s.alias === alias);
237
+
238
+ test("/api/status full rows carry queue_depth; light and the old alias-resolver projection do not", async () => {
239
+ await dispatch(bossToken, nodes.target.alias);
240
+ await dispatch(bossToken, nodes.target.alias);
241
+ const full = await send(bossToken, "GET", `/api/status?network_id=${NET}`);
242
+ expect(full.status).toBe(200);
243
+ expect(rowOf(full.body, nodes.target.alias).queue_depth).toBe(2);
244
+ expect(rowOf(full.body, nodes.idle.alias).queue_depth).toBe(0);
245
+ const light = await send(bossToken, "GET", `/api/status?network_id=${NET}&light=1`);
246
+ expect("queue_depth" in rowOf(light.body, nodes.target.alias)).toBe(false);
247
+ const resolver = await send(nodes.idle.token, "GET", `/api/status?network_id=${NET}`, undefined, { Accept: "application/json" });
248
+ expect(resolver.headers.get("x-status-projection")).toBe("alias-resolver");
249
+ expect("queue_depth" in rowOf(resolver.body, nodes.target.alias)).toBe(false);
250
+ // 另一个网络里的同名节点:看不到 NET 的队列。
251
+ const net2 = await send(boss2Token, "GET", `/api/status?network_id=${NET2}`);
252
+ expect(rowOf(net2.body, other.alias).queue_depth).toBe(0);
253
+ // App 的单行读(?alias=)只查这一个别名,数一样。
254
+ const one = await send(bossToken, "GET", `/api/status?network_id=${NET}&alias=${encodeURIComponent(nodes.target.alias)}`);
255
+ expect(one.body.sessions.length).toBe(1);
256
+ expect(one.body.sessions[0].queue_depth).toBe(2);
257
+ });
258
+
259
+ test("queueDepthByNode: the alias-list query and the whole-table scan (> 50 aliases) give the same counts", async () => {
260
+ const { queueDepthByNode, queueDepthKey, QUEUE_DEPTH_ALIAS_LIST_MAX } = await import("./task-queue-ahead.js");
261
+ await dispatch(bossToken, nodes.target.alias);
262
+ await dispatch(bossToken, nodes.target.alias);
263
+ await dispatch(bossToken, nodes.idle.alias);
264
+ await send(boss2Token, "POST", "/api/task", { alias: other.alias, task: text(), network_id: NET2 });
265
+ const few = queueDepthByNode([nodes.target.alias, nodes.idle.alias]);
266
+ const fillers = Array.from({ length: QUEUE_DEPTH_ALIAS_LIST_MAX }, (_, i) => `qa-filler-${i}`);
267
+ const many = queueDepthByNode([nodes.target.alias, nodes.idle.alias, ...fillers]);
268
+ for (const m of [few, many]) {
269
+ expect(m.get(queueDepthKey(NET, nodes.target.alias))).toBe(2);
270
+ expect(m.get(queueDepthKey(NET, nodes.idle.alias))).toBe(1);
271
+ expect(m.get(queueDepthKey(NET2, other.alias))).toBe(1); // 同名、别的网络:分开计
272
+ }
273
+ expect(queueDepthByNode([]).size).toBe(0);
274
+ });
275
+
276
+ test("a write that touches only tasks invalidates the cached full body (new ETag, new queue_depth); light stays cached", async () => {
277
+ const a = await dispatch(bossToken, nodes.target.alias);
278
+ await dispatch(bossToken, nodes.target.alias);
279
+ const first = await send(bossToken, "GET", `/api/status?network_id=${NET}`);
280
+ const etag = first.headers.get("etag")!;
281
+ expect(rowOf(first.body, nodes.target.alias).queue_depth).toBe(2);
282
+ const lightBefore = await send(bossToken, "GET", `/api/status?network_id=${NET}&light=1`);
283
+ // 只改 tasks(不碰 sessions):全量必须重算。
284
+ setStatus(a.task_id, "replied");
285
+ const again = await send(bossToken, "GET", `/api/status?network_id=${NET}`, undefined, { "If-None-Match": etag });
286
+ expect(again.status).toBe(200);
287
+ expect(again.headers.get("etag")).not.toBe(etag);
288
+ expect(rowOf(again.body, nodes.target.alias).queue_depth).toBe(1);
289
+ // light 不读 tasks:同一个 ETag → 304。
290
+ const lightAfter = await fetch(`${BASE}/api/status?network_id=${NET}&light=1`, { headers: { Authorization: `Bearer ${bossToken}`, "If-None-Match": lightBefore.headers.get("etag")! } });
291
+ expect(lightAfter.status).toBe(304);
292
+ // 没有写 → 全量命中缓存:同一个 ETag → 304。
293
+ const cached = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: { Authorization: `Bearer ${bossToken}`, "If-None-Match": again.headers.get("etag")! } });
294
+ expect(cached.status).toBe(304);
295
+ });
296
+
297
+ test("MCP get_all_status rows carry queue_depth", async () => {
298
+ await dispatch(bossToken, nodes.target.alias);
299
+ const r = await tool(bossToken, "get_all_status", { network_id: NET, filter_alias: `${nodes.target.alias},${nodes.idle.alias}` });
300
+ expect(r.ok).toBe(true);
301
+ expect(rowOf(r, nodes.target.alias).queue_depth).toBe(1);
302
+ expect(rowOf(r, nodes.idle.alias).queue_depth).toBe(0);
303
+ // filter_status + filter_alias on top of the network scope: every placeholder numbered (PG rejected the mix).
304
+ const f = await tool(bossToken, "get_all_status", { network_id: NET, filter_status: "idle", filter_alias: nodes.target.alias });
305
+ expect(f.ok).toBe(true);
306
+ expect(f.sessions.length).toBe(1);
307
+ expect(f.sessions[0].queue_depth).toBe(1);
308
+ });
309
+ });
@@ -0,0 +1,236 @@
1
+ // #500 follow-up to #2325 —— queue_depth at production scale. SQLite only (the plan assertions are SQLite's
2
+ // EXPLAIN QUERY PLAN; the result-equality side runs on PostgreSQL in task-queue-ahead-http.test.ts via test2123).
3
+ //
4
+ // The .100 release drill on a read-only copy of a production-shaped DB (306 sessions, 57k tasks, ~31k stuck in
5
+ // `acked` for weeks, ~900 `running`, only ~1.9k created in the last 24 h) measured full /api/status at p50 66–77 ms
6
+ // against 8–9 ms on .99: the queue_depth GROUP BY went through idx_tasks_status and walked every open-status row
7
+ // ever written to keep a 24 h window. This file seeds that shape and pins:
8
+ // 1. the plans: the whole-table queue_depth query and the per-send open-task count never use idx_tasks_status;
9
+ // the whole-table one is driven by the covering idx_tasks_created_queue (no task-row lookups), the alias list
10
+ // and the per-send count by idx_tasks_to_created;
11
+ // 2. the cost, relative only (no absolute ms, so a slow runner can't fail it): the queue_depth statement is at most
12
+ // half the #2325 statement measured on the same DB in the same loop; and, on the low percentile of alternated
13
+ // samples (#517), the statement costs at most half a full /api/status read (cache bypassed) without it, and that
14
+ // read with queue_depth is at most 50 % slower than with it switched off (= the pre-#2325 read);
15
+ // 3. the counts are still right at that scale (stale `acked` rows are outside the 24 h window).
16
+ //
17
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/task-queue-ahead-scale.test.ts
18
+
19
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ const PG = !!process.env.COMMHUB_TEST_PG_URL;
25
+ const DIR = mkdtempSync(join(tmpdir(), "anet-queue-scale-"));
26
+ if (!PG) process.env.COMMHUB_DB ||= join(DIR, "hub.db");
27
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
28
+ process.env.HOST = "127.0.0.1";
29
+
30
+ const NODES = 306;
31
+ const STUCK_ACKED = 31_000; // older than 24 h, never terminal
32
+ const STUCK_RUNNING = 900; // older than 24 h
33
+ const OLD_DONE = 23_400; // replied / failed / expired, older than 24 h
34
+ const RECENT = 1_900; // created in the last 24 h
35
+ const OPEN_EVERY = 30; // 3 in every OPEN_EVERY recent tasks are still open (~190 of 1.9k; see the cost test)
36
+ const HOT = "scale-node-7"; // most stuck rows land on this one (the per-send plan must not care)
37
+ const N = 25;
38
+ /** #517: A/B pairs per pass, the percentile compared, and how many extra passes a failing pass gets. */
39
+ const AB_PAIRS = 60, LOW_P = 0.2, RETRIES = 2;
40
+ /** The #2325 statement (bare columns): the in-test baseline the fixed query is measured against. */
41
+ const SQL_2325 = `SELECT network_id, to_name, COUNT(*) AS n FROM tasks
42
+ WHERE status IN ('created', 'delivered', 'acked', 'running') AND created_at >= datetime('now', '-86400 seconds')
43
+ GROUP BY network_id, to_name`;
44
+
45
+ let db: any;
46
+ let hub: any = null;
47
+ let BASE = "", NET = "", token = "";
48
+ let mod: any;
49
+ let expectedOpen = new Map<string, number>();
50
+ let seedMs = 0, regMs = 0;
51
+
52
+ const pct = (xs: number[], p: number) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
53
+ const plan = (sql: string, params: any[]) => (db.all("EXPLAIN QUERY PLAN " + sql, ...params) as Array<{ detail: string }>).map((r) => r.detail).join(" | ");
54
+
55
+ beforeAll(async () => {
56
+ if (PG) return;
57
+ ({ db } = await import("./db.js"));
58
+ mod = await import("./task-queue-ahead.js");
59
+ const { register } = await import("./auth.js");
60
+ const seedStart = performance.now();
61
+ const u = register(`scale_${Date.now()}`, "QueueScale123!x");
62
+ token = u.token!; NET = u.network_id!;
63
+ // Seed with a handful of set-based INSERT … SELECT over a recursive CTE (no per-row JS round trips): the whole
64
+ // 57k-row seed is ~0.2 s, so the file stays far inside test638's per-file budget even under strace and with two
65
+ // aggregates running at once. Rows are deterministic (functions of the row number), so the expected counts below
66
+ // are computed from the same formulas, independently of the code under test.
67
+ const SEQ = `WITH RECURSIVE seq(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM seq WHERE i + 1 < ?1)`;
68
+ const OLD_AGE = `(90000 + (i * 7919) % 2592000)`; // 25 h … 25 h + 30 d, in seconds
69
+ const insertTasks = (prefix: string, count: number, toExpr: string, statusExpr: string, ageExpr: string, doneExpr: string) => db.run(
70
+ `${SEQ}
71
+ INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, created_at, delivered_at, completed_at, expires_at, network_id)
72
+ SELECT '${prefix}-' || i, 'scale-sender', ${toExpr}, 'normal', ${statusExpr}, 'c',
73
+ datetime('now', '-' || ${ageExpr} || ' seconds'), datetime('now', '-' || ${ageExpr} || ' seconds'),
74
+ CASE WHEN ${doneExpr} THEN datetime('now', '-' || (${ageExpr} - 120) || ' seconds') END,
75
+ datetime('now', '-' || (${ageExpr} - 3600) || ' seconds'), ?2
76
+ FROM seq`,
77
+ [count, NET],
78
+ );
79
+ const NODE_OF = (expr: string) => `'scale-node-' || ((${expr}) % ${NODES})`;
80
+ // Throwaway DB only: no fsync, and a page cache big enough that the seed transaction never spills to the WAL
81
+ // mid-way (test638 runs this file under `strace -f`, where every write syscall is expensive).
82
+ regMs = performance.now() - seedStart;
83
+ db.exec("PRAGMA synchronous = OFF; PRAGMA cache_size = -131072;");
84
+ db.transaction(() => {
85
+ db.run(`${SEQ}
86
+ INSERT INTO sessions (resume_id, alias, status, agent, network_id, node_id, updated_at, last_seen_at, task)
87
+ SELECT 'sdk-scale-' || i, 'scale-node-' || i, 'idle', 'agent-node:codex', ?2, 'n_scale_' || i, datetime('now'), datetime('now'), 'x' FROM seq`,
88
+ [NODES, NET]);
89
+ // Two thirds of the stuck rows on one node, the rest spread out.
90
+ insertTasks("acked", STUCK_ACKED, `CASE WHEN i % 3 = 0 THEN ${NODE_OF("i")} ELSE '${HOT}' END`, `'acked'`, OLD_AGE, "0");
91
+ insertTasks("running", STUCK_RUNNING, NODE_OF("i"), `'running'`, OLD_AGE, "0");
92
+ insertTasks("done", OLD_DONE, NODE_OF("i"), `CASE i % 5 WHEN 3 THEN 'failed' WHEN 4 THEN 'expired' ELSE 'replied' END`, OLD_AGE, "1");
93
+ // Last 24 h: i % OPEN_EVERY = 3 / 4 / 5 → delivered / running / acked (open), else failed (i % 7 = 6) or replied;
94
+ // age 0 … 23 h.
95
+ insertTasks("recent", RECENT, NODE_OF("i * 13"),
96
+ `CASE i % ${OPEN_EVERY} WHEN 3 THEN 'delivered' WHEN 4 THEN 'running' WHEN 5 THEN 'acked' ELSE CASE i % 7 WHEN 6 THEN 'failed' ELSE 'replied' END END`,
97
+ `((i * 37) % 82800)`, `i % ${OPEN_EVERY} NOT IN (3, 4, 5)`);
98
+ });
99
+ for (let i = 0; i < RECENT; i++) {
100
+ if (![3, 4, 5].includes(i % OPEN_EVERY)) continue;
101
+ const to = `scale-node-${(i * 13) % NODES}`;
102
+ expectedOpen.set(to, (expectedOpen.get(to) ?? 0) + 1);
103
+ }
104
+ seedMs = performance.now() - seedStart;
105
+ const srv: any = await import("./server.js");
106
+ hub = srv.bootServer({ port: 0, hostname: "127.0.0.1" });
107
+ BASE = `http://127.0.0.1:${hub.port}`;
108
+ }, 120_000);
109
+
110
+ afterAll(() => {
111
+ try { hub?.stop?.(true); } catch {}
112
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
113
+ });
114
+
115
+ describe.skipIf(PG)("#500 queue_depth at production scale (57k tasks, 31k stuck acked)", () => {
116
+ test("plans: never idx_tasks_status; whole table via covering idx_tasks_created_queue, alias list and per-send via idx_tasks_to_created", () => {
117
+ const total = Number(db.get("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1", NET).n);
118
+ expect(total).toBe(STUCK_ACKED + STUCK_RUNNING + OLD_DONE + RECENT);
119
+ const stale = Number(db.get("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND status IN ('acked','running') AND created_at < datetime('now', '-86400 seconds')", NET).n);
120
+ expect(stale).toBe(STUCK_ACKED + STUCK_RUNNING);
121
+ console.log(`[seed] ${total} tasks in ${seedMs.toFixed(0)} ms (register ${regMs.toFixed(0)} ms)`);
122
+ // Positive control: the shape is one where the #2325 statement really does go through idx_tasks_status.
123
+ expect(plan(SQL_2325, [])).toContain("idx_tasks_status");
124
+
125
+ const whole = mod.queueDepthQuery();
126
+ const wholePlan = plan(whole.sql, whole.params);
127
+ console.log(`[plan] queue_depth whole table: ${wholePlan}`);
128
+ expect(wholePlan).not.toContain("idx_tasks_status");
129
+ expect(wholePlan).toContain("COVERING INDEX idx_tasks_created_queue");
130
+
131
+ // > QUEUE_DEPTH_ALIAS_LIST_MAX aliases → the whole-table form too.
132
+ const many = mod.queueDepthQuery(Array.from({ length: NODES }, (_, i) => `scale-node-${i}`));
133
+ expect(many.sql).toBe(whole.sql);
134
+
135
+ const few = mod.queueDepthQuery([HOT, "scale-node-8"]);
136
+ const fewPlan = plan(few.sql, few.params);
137
+ console.log(`[plan] queue_depth alias list: ${fewPlan}`);
138
+ expect(fewPlan).not.toContain("idx_tasks_status");
139
+ expect(fewPlan).toContain("idx_tasks_to_created");
140
+
141
+ for (const net of [NET, null]) {
142
+ const send = mod.openTaskCountsQuery(HOT, net);
143
+ const sendPlan = plan(send.sql, send.params);
144
+ console.log(`[plan] per-send open count (net=${net ? "set" : "none"}): ${sendPlan}`);
145
+ expect(sendPlan).not.toContain("idx_tasks_status");
146
+ expect(sendPlan).toContain("idx_tasks_to_created");
147
+ }
148
+ });
149
+
150
+ test("counts stay right at scale: stale acked rows are outside the 24 h window", async () => {
151
+ const depth: Map<string, number> = mod.queueDepthByNode();
152
+ for (const [alias, n] of expectedOpen) expect(depth.get(mod.queueDepthKey(NET, alias))).toBe(n);
153
+ expect(depth.get(mod.queueDepthKey(NET, HOT)) ?? 0).toBe(expectedOpen.get(HOT) ?? 0);
154
+ const r = await fetch(`${BASE}/api/status?network_id=${NET}&alias=${HOT}`, { headers: { Authorization: `Bearer ${token}` } });
155
+ const body = await r.json() as any;
156
+ expect(body.sessions[0].queue_depth).toBe(expectedOpen.get(HOT) ?? 0);
157
+ expect(mod.openTaskCounts(HOT, NET).open).toBe(expectedOpen.get(HOT) ?? 0);
158
+ // And a node that does have open tasks in the window (HOT's open rows are all > 24 h old).
159
+ expect(expectedOpen.size).toBeGreaterThan(100);
160
+ const [busy, busyN] = [...expectedOpen].sort((a, b) => b[1] - a[1])[0];
161
+ expect(busyN).toBeGreaterThan(1);
162
+ const rb = await fetch(`${BASE}/api/status?network_id=${NET}&alias=${busy}`, { headers: { Authorization: `Bearer ${token}` } });
163
+ expect(((await rb.json()) as any).sessions[0].queue_depth).toBe(busyN);
164
+ expect(mod.openTaskCounts(busy, NET).open).toBe(busyN);
165
+ });
166
+
167
+ test("cost (relative, robust on slow runners): ≤ half the #2325 statement on the same DB, and ≤ +50% on a full /api/status read vs the same read without queue_depth", async () => {
168
+ // Interleave the two statements so a slow / noisy runner slows both alike.
169
+ const fixed: number[] = [], old: number[] = [];
170
+ for (let i = 0; i < N + 3; i++) {
171
+ let s0 = performance.now(); mod.queueDepthByNode(); const a = performance.now() - s0;
172
+ s0 = performance.now(); db.all(SQL_2325); const b = performance.now() - s0;
173
+ if (i >= 3) { fixed.push(a); old.push(b); }
174
+ }
175
+ // Measured locally (this fixture, covering index): ≈ 0.15× (1.0 vs 6.5 ms); on the production copy 0.3 vs 55 ms.
176
+ const qP50 = pct(fixed, 0.5), oldP50 = pct(old, 0.5);
177
+ console.log(`[perf] queue_depth p50=${qP50.toFixed(2)}ms vs #2325 statement p50=${oldP50.toFixed(2)}ms`);
178
+ expect(qP50).toBeLessThanOrEqual(0.5 * oldP50);
179
+
180
+ // Second budget: queue_depth adds at most +50 % to a full /api/status read (cache bypassed) — the same read
181
+ // with it switched off is the pre-#2325 read. #517: the first version compared the p50 of `with` and `without`
182
+ // on a ~4 ms read and asserted the *difference*; under test638's concurrent aggregates a difference of two
183
+ // noisy p50s is mostly noise (CI #2355: statement 0.64 ms, yet with−without = 2.4 ms > 2.03 ms budget). Now:
184
+ // - each pass alternates A/B request by request (order flipped every pair) and also times the bare
185
+ // queue_depth statement between the reads, so all three series share one time window;
186
+ // - it compares a low percentile (LOW_P) rather than the p50: scheduler noise only ever adds time, so the
187
+ // lower tail is the closest a busy runner gets to the real cost;
188
+ // - it asserts both what the comment claims — the statement's own cost ≤ 50 % of the read without it — and
189
+ // the end-to-end with/without delta on the same low percentile;
190
+ // - a failing pass is re-measured up to RETRIES more times; the real regression (the #2325 statement through
191
+ // idx_tasks_status, 5–8× the fixed cost) fails every pass, noise does not.
192
+ const cache = await import("./status-read-cache.js");
193
+ cache.__setStatusCacheBypassForTest(true);
194
+ const readOnce = async (depthOn: boolean) => {
195
+ mod.__setQueueDepthOffForTest(!depthOn);
196
+ const s0 = performance.now();
197
+ await (await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: { Authorization: `Bearer ${token}` } })).text();
198
+ return performance.now() - s0;
199
+ };
200
+ const abPass = async () => {
201
+ const on: number[] = [], off: number[] = [], q: number[] = [];
202
+ for (let i = 0; i < AB_PAIRS + 3; i++) {
203
+ const first = i % 2 === 0;
204
+ const a = await readOnce(first), b = await readOnce(!first);
205
+ mod.__setQueueDepthOffForTest(false);
206
+ const s0 = performance.now(); mod.queueDepthByNode(); const c = performance.now() - s0;
207
+ if (i >= 3) { (first ? on : off).push(a); (first ? off : on).push(b); q.push(c); }
208
+ }
209
+ const onLow = pct(on, LOW_P), offLow = pct(off, LOW_P), qLow = pct(q, LOW_P);
210
+ return { onLow, offLow, qLow, onP50: pct(on, 0.5), offP50: pct(off, 0.5),
211
+ okDirect: qLow <= 0.5 * offLow, okRead: onLow - offLow <= 0.5 * offLow };
212
+ };
213
+ let r: Awaited<ReturnType<typeof abPass>> | null = null;
214
+ try {
215
+ for (let attempt = 0; attempt <= RETRIES; attempt++) {
216
+ r = await abPass();
217
+ console.log(`[perf] pass ${attempt + 1}: p${LOW_P * 100} queue_depth=${r.qLow.toFixed(2)}ms; full /api/status (bypass) p${LOW_P * 100} with=${r.onLow.toFixed(2)}ms without=${r.offLow.toFixed(2)}ms (+${(100 * (r.onLow - r.offLow) / r.offLow).toFixed(0)}%); p50 with=${r.onP50.toFixed(2)}ms without=${r.offP50.toFixed(2)}ms; direct ${r.okDirect ? "ok" : "OVER"} read ${r.okRead ? "ok" : "OVER"}`);
218
+ if (r.okDirect && r.okRead) break;
219
+ }
220
+ } finally {
221
+ mod.__setQueueDepthOffForTest(false);
222
+ cache.__setStatusCacheBypassForTest(false);
223
+ }
224
+ // What the read budget was measured at (2026-10-04, p50s, before #517):
225
+ // production copy (VACUUM INTO of the live DB: 306 sessions, 57k tasks, 64 open in the last 24 h on 4 nodes),
226
+ // full /api/status p50, cache bypassed, 3 runs each —
227
+ // pre-#2325 10.5–11.3 ms · #2325 68–71 ms · first fix (idx_tasks_created + row lookups) 16.1–17.1 ms (+50 %,
228
+ // statement 3.1 ms) · covering idx_tasks_created_queue 11.5–12.2 ms; this A/B on that copy: +7 / +8 / +9 %.
229
+ // this fixture: +17–31 %. It is harsher than production: ~190 open tasks in the window spread over ~150 nodes
230
+ // (3× production's open count, ~38× its groups) and one-word session rows that make the rest of the read cheap.
231
+ // With the original 1-in-7 open share (813 open) it measured +55–62 % even with the covering index — the
232
+ // GROUP BY over 300 groups, not the index.
233
+ expect(r!.qLow).toBeLessThanOrEqual(0.5 * r!.offLow);
234
+ expect(r!.onLow - r!.offLow).toBeLessThanOrEqual(0.5 * r!.offLow);
235
+ }, 120_000);
236
+ });