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

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 (318) 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 +933 -36
  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 +130 -0
  205. package/src/rest-identity.ts +139 -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 +93 -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 +140 -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-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,290 @@
1
+ // #519 —— 已开工(acked / running)却再无动静的任务由巡检结束为 expired;默认关闭。
2
+ // 外加 #2322 的遗留缺陷:过期通知里「前面还有几个」没有时间下界,把几个月前被遗弃的 acked 行全算进去。
3
+ // HTTP 集成测试(真实 Bun.serve,私有端口,临时库);test2123 在真实 PostgreSQL 上原样再跑一遍。
4
+ //
5
+ // 任务一律经真入口 POST /api/task 派出,目标用自己的节点令牌经 /mcp ack_inbox(→ acked)、
6
+ // report_status(working, task=原文)(→ running)推进;只把时间戳(tasks 的各列 + 它的 task_events)往前拨,
7
+ // 然后调巡检(server.ts patrolExpiredTasks,定时器调的就是它)。迟到的回复走 /mcp send_reply。
8
+ //
9
+ // 钉住:
10
+ // 1. 未设 COMMHUB_STALE_OPEN_TASK_HOURS → 什么都不变;设成 0 → 同样关闭。
11
+ // 2. 设 72:71 小时无动静的留着,73 小时的结束为 expired,result 带原因,task_events 记 task.stale_expired。
12
+ // 3. running 但 consumed_at 是最近的 → 留着;最近有 task_event 的 acked → 留着。
13
+ // 4. 每次巡检最多 500 行,先结束最老的;下一次巡检接着来。
14
+ // 5. 不通知派活的一方(agent inbox / user_inbox 都没有)。
15
+ // 6. 之后对它的 send_reply 被拒:reply_task_terminal。
16
+ // 7. (#2322 缺陷)过期通知的「前面还有几个」只数 24 小时内的开着任务。
17
+ //
18
+ // 跑法:cd server && bun test src/task-stale-open-http.test.ts
19
+
20
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
21
+ import { mkdtempSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ const DIR = mkdtempSync(join(tmpdir(), "anet-stale-open-"));
26
+ 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
+ const ENV = "COMMHUB_STALE_OPEN_TASK_HOURS";
30
+ delete process.env[ENV];
31
+ const PW = "StaleOpen123!x";
32
+ const stamp = Date.now();
33
+
34
+ let db: any;
35
+ let patrol: () => void;
36
+ let hub: any = null;
37
+ let BASE = "";
38
+ let NET = "";
39
+ let bossToken = "", umaToken = "", umaId = "";
40
+ let EVENT = "", LIMIT = 0;
41
+ type Node = { alias: string; nodeId: string; token: string };
42
+ const nodes: Record<"sender" | "target", Node> = {} as any;
43
+
44
+ async function send(token: string, method: string, path: string, payload?: unknown) {
45
+ const res = await fetch(`${BASE}${path}`, {
46
+ method,
47
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
48
+ body: payload === undefined ? undefined : JSON.stringify(payload),
49
+ });
50
+ return { status: res.status, body: await res.json().catch(() => null) as any };
51
+ }
52
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
53
+ const res = await fetch(`${BASE}/mcp`, {
54
+ method: "POST",
55
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
56
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
57
+ });
58
+ const raw = await res.text();
59
+ const data = raw.split("\n").filter((x) => x.startsWith("data:"));
60
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
61
+ return JSON.parse(payload.result.content[0].text);
62
+ }
63
+
64
+ let seq = 0;
65
+ /** 经 POST /api/task 派一条;返回 { id, content }。 */
66
+ async function dispatch(token: string, to: string): Promise<{ id: string; content: string }> {
67
+ const content = `stale case ${stamp} #${++seq}`;
68
+ const r = await send(token, "POST", "/api/task", { alias: to, task: content, network_id: NET });
69
+ expect(r.status).toBe(200);
70
+ expect(r.body.ok).toBe(true);
71
+ return { id: r.body.task_id, content };
72
+ }
73
+ /** 目标经 /mcp ack_inbox 收下 → acked。 */
74
+ async function ack(taskId: string) {
75
+ const r = await tool(nodes.target.token, "ack_inbox", { alias: nodes.target.alias, message_id: taskId, network_id: NET });
76
+ expect(r.ok).toBe(true);
77
+ expect(taskRow(taskId).status).toBe("acked");
78
+ }
79
+ /** 目标经 /mcp report_status(working, task=原文)开工 → running。 */
80
+ async function run(t: { id: string; content: string }) {
81
+ await ack(t.id);
82
+ const r = await tool(nodes.target.token, "report_status", { resume_id: `sdk-${nodes.target.nodeId}`, alias: nodes.target.alias, status: "working", task: t.content, node_id: nodes.target.nodeId, network_id: NET });
83
+ expect(r.ok).toBe(true);
84
+ expect(taskRow(t.id).status).toBe("running");
85
+ }
86
+ /** 把这条任务所有的时间戳(含它的 task_events)拨到 `hours` 小时前。 */
87
+ function age(taskId: string, hours: number) {
88
+ const off = `-${Math.round(hours * 3600)} seconds`;
89
+ db.run(
90
+ `UPDATE tasks SET created_at = datetime('now', ?2),
91
+ delivered_at = CASE WHEN delivered_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
92
+ started_at = CASE WHEN started_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
93
+ consumed_at = CASE WHEN consumed_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
94
+ runtime_submitted_at = CASE WHEN runtime_submitted_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
95
+ expires_at = datetime('now', ?2)
96
+ WHERE task_id = ?1`,
97
+ [taskId, off],
98
+ );
99
+ db.run("UPDATE task_events SET created_at = datetime('now', ?2) WHERE task_id = ?1", [taskId, off]);
100
+ }
101
+ const taskRow = (taskId: string) => db.get("SELECT status, result, completed_at FROM tasks WHERE task_id = ?1", taskId) as { status: string; result: string | null; completed_at: string | null };
102
+ const staleEvents = (taskId: string) => db.all("SELECT from_status, to_status, event_type, actor, detail FROM task_events WHERE task_id = ?1 AND event_type = ?2", taskId, EVENT) as any[];
103
+ const hubNotices = () => Number((db.get("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1 AND from_session = 'hub'", NET) as { n: number }).n);
104
+ const userNotices = () => Number((db.get("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id = ?1 AND user_id = ?2", NET, umaId) as { n: number }).n);
105
+
106
+ function withHours<T>(value: string | undefined, fn: () => T): T {
107
+ const prev = process.env[ENV];
108
+ if (value === undefined) delete process.env[ENV]; else process.env[ENV] = value;
109
+ try { return fn(); } finally { if (prev === undefined) delete process.env[ENV]; else process.env[ENV] = prev; }
110
+ }
111
+
112
+ beforeAll(async () => {
113
+ ({ db } = await import("./db.js"));
114
+ const { addNetworkMember, createNetworkTokenForNode, register } = await import("./auth.js");
115
+ ({ STALE_OPEN_EVENT_TYPE: EVENT, STALE_OPEN_BATCH_LIMIT: LIMIT } = await import("./task-stale-open.js"));
116
+ const boss = register(`stale_boss_${stamp}`, PW);
117
+ expect(boss.ok).toBe(true);
118
+ bossToken = boss.token!; NET = boss.network_id!;
119
+ const bossId = boss.user!.user_id;
120
+ const uma = register(`stale_uma_${stamp}`, PW);
121
+ umaToken = uma.token!; umaId = uma.user!.user_id;
122
+ expect(addNetworkMember(NET, umaId, "member", bossId, { agentAccess: "all" }).ok).toBe(true);
123
+ for (const key of ["sender", "target"] as const) {
124
+ const alias = `stale-${key}-${stamp}`;
125
+ const nodeId = `n_stale_${key}_${stamp}`;
126
+ const minted = createNetworkTokenForNode(bossId, NET, alias, nodeId);
127
+ expect(minted.ok).toBe(true);
128
+ nodes[key] = { alias, nodeId, token: minted.token! };
129
+ }
130
+ const mod: any = await import("./server.js");
131
+ patrol = mod.patrolExpiredTasks;
132
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
133
+ BASE = `http://127.0.0.1:${hub.port}`;
134
+ for (const n of Object.values(nodes)) {
135
+ const r = await tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status: "idle", node_id: n.nodeId, network_id: NET });
136
+ expect(r.ok).toBe(true);
137
+ }
138
+ }, 30_000);
139
+
140
+ beforeEach(() => {
141
+ // 每条用例自己的数据:上一条留下的开着的任务不许被这一次巡检碰到,也不许算进「前面还有几个」。
142
+ db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE network_id = ?1 AND status IN ('created', 'delivered', 'acked', 'running')", [NET]);
143
+ db.run("DELETE FROM inbox WHERE network_id = ?1 AND from_session = 'hub'", [NET]);
144
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1", [NET]);
145
+ });
146
+
147
+ afterAll(() => {
148
+ try { hub?.stop?.(true); } catch {}
149
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
150
+ });
151
+
152
+ describe("#519 off by default", () => {
153
+ test("env unset → a 30-day-old acked and running task stay open, no event", async () => {
154
+ const a = await dispatch(nodes.sender.token, nodes.target.alias);
155
+ await ack(a.id);
156
+ const r = await dispatch(nodes.sender.token, nodes.target.alias);
157
+ await run(r);
158
+ age(a.id, 720); age(r.id, 720);
159
+ withHours(undefined, () => patrol());
160
+ expect(taskRow(a.id).status).toBe("acked");
161
+ expect(taskRow(r.id).status).toBe("running");
162
+ expect(staleEvents(a.id).length + staleEvents(r.id).length).toBe(0);
163
+ });
164
+
165
+ test("0 (and garbage) disable it too", async () => {
166
+ const a = await dispatch(nodes.sender.token, nodes.target.alias);
167
+ await ack(a.id);
168
+ age(a.id, 720);
169
+ for (const v of ["0", "-5", "abc", ""]) withHours(v, () => patrol());
170
+ expect(taskRow(a.id).status).toBe("acked");
171
+ expect(staleEvents(a.id).length).toBe(0);
172
+ });
173
+ });
174
+
175
+ describe("#519 at 72h", () => {
176
+ test("71h stays open; 73h acked/running → expired with reason + task.stale_expired; no sender notice", async () => {
177
+ const young = await dispatch(nodes.sender.token, nodes.target.alias);
178
+ await ack(young.id);
179
+ const old = await dispatch(nodes.sender.token, nodes.target.alias);
180
+ await ack(old.id);
181
+ const oldRun = await dispatch(umaToken, nodes.target.alias); // 人派的:同样不通知
182
+ await run(oldRun);
183
+ // send_ack 只改任务状态、不动 inbox:它的 inbox 行还是未确认,巡检要一并确认掉。
184
+ const viaSendAck = await dispatch(nodes.sender.token, nodes.target.alias);
185
+ const sa = await tool(nodes.target.token, "send_ack", { task_id: viaSendAck.id, network_id: NET });
186
+ expect(sa.ok).toBe(true);
187
+ expect(taskRow(viaSendAck.id).status).toBe("acked");
188
+ const unackedInbox = (id: string) => Number((db.get("SELECT COUNT(*) AS n FROM inbox WHERE COALESCE(task_id, id) = ?1 AND acked = 0", id) as any).n);
189
+ expect(unackedInbox(viaSendAck.id)).toBe(1);
190
+ age(young.id, 71); age(old.id, 73); age(oldRun.id, 73); age(viaSendAck.id, 73);
191
+
192
+ withHours("72", () => patrol());
193
+
194
+ expect(taskRow(young.id).status).toBe("acked");
195
+ const o = taskRow(old.id);
196
+ expect(o.status).toBe("expired");
197
+ expect(o.completed_at).toBeTruthy();
198
+ expect(o.result).toBe("stale: no activity for 72h (was acked) — closed by hub patrol (#519)");
199
+ expect(taskRow(oldRun.id)).toMatchObject({ status: "expired", result: "stale: no activity for 72h (was running) — closed by hub patrol (#519)" });
200
+ expect(staleEvents(old.id)).toEqual([{ from_status: "acked", to_status: "expired", event_type: EVENT, actor: "patrol", detail: o.result }]);
201
+ expect(staleEvents(oldRun.id).map((e) => e.from_status)).toEqual(["running"]);
202
+ expect(taskRow(viaSendAck.id).status).toBe("expired");
203
+ expect(unackedInbox(viaSendAck.id)).toBe(0);
204
+ // 不是 TTL 过期:不多写一条 task.expired。
205
+ expect(Number((db.get("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?1 AND event_type = 'task.expired'", old.id) as any).n)).toBe(0);
206
+ // 不通知派活的一方:agent inbox 没有 hub 的行,人的 user_inbox 也没有。
207
+ expect(hubNotices()).toBe(0);
208
+ expect(userNotices()).toBe(0);
209
+ // 再巡检一次:幂等,不重复记事件。
210
+ withHours("72", () => patrol());
211
+ expect(staleEvents(old.id).length).toBe(1);
212
+ });
213
+
214
+ test("recent activity keeps it open: fresh consumed_at on running, fresh task_event on acked", async () => {
215
+ const r = await dispatch(nodes.sender.token, nodes.target.alias);
216
+ await run(r);
217
+ age(r.id, 720);
218
+ db.run("UPDATE tasks SET consumed_at = datetime('now', '-1 hours') WHERE task_id = ?1", [r.id]);
219
+ const a = await dispatch(nodes.sender.token, nodes.target.alias);
220
+ await ack(a.id);
221
+ age(a.id, 720);
222
+ db.run(
223
+ "INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, network_id, created_at) VALUES (?1, 'acked', 'acked', 'task.note', 'test', ?2, datetime('now', '-1 hours'))",
224
+ [a.id, NET],
225
+ );
226
+ withHours("72", () => patrol());
227
+ expect(taskRow(r.id).status).toBe("running");
228
+ expect(taskRow(a.id).status).toBe("acked");
229
+ });
230
+
231
+ test("a late reply to a stale-expired task is rejected as terminal", async () => {
232
+ const a = await dispatch(nodes.sender.token, nodes.target.alias);
233
+ await ack(a.id);
234
+ age(a.id, 100);
235
+ withHours("72", () => patrol());
236
+ expect(taskRow(a.id).status).toBe("expired");
237
+ const r = await tool(nodes.target.token, "send_reply", { in_reply_to: a.id, text: "late answer", status: "replied", network_id: NET });
238
+ expect(r.ok).toBe(false);
239
+ expect(r.error).toBe("reply_task_terminal");
240
+ expect(taskRow(a.id).status).toBe("expired");
241
+ });
242
+
243
+ test(`at most ${500} rows per pass, oldest first; the next pass continues`, async () => {
244
+ expect(LIMIT).toBe(500);
245
+ const total = LIMIT + 20;
246
+ db.transaction(() => {
247
+ for (let i = 0; i < total; i++) {
248
+ // 批量造数据直接写库(经 /api/task 派 520 条只会更慢,不改变被测路径:巡检只读 tasks / task_events)。
249
+ db.run(
250
+ `INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
251
+ VALUES (?1, ?2, ?3, ?4, ?5, 'normal', 'acked', 'bulk', 'reply', datetime('now', ?6), datetime('now', ?6), datetime('now', ?6), ?7)`,
252
+ [`stale_bulk_${stamp}_${String(i).padStart(4, "0")}`, nodes.sender.alias, nodes.sender.nodeId, nodes.target.alias, nodes.target.nodeId, `-${(100 + total - i) * 60} minutes`, NET],
253
+ );
254
+ }
255
+ });
256
+ const openBulk = () => Number((db.get("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND task_id LIKE 'stale_bulk_%' AND status = 'acked'", NET) as any).n);
257
+ expect(openBulk()).toBe(total);
258
+ withHours("72", () => patrol());
259
+ expect(openBulk()).toBe(20);
260
+ // 最老的先结束:剩下的是最新的 20 条(i = 500..519)。
261
+ const left = (db.all("SELECT task_id FROM tasks WHERE network_id = ?1 AND task_id LIKE 'stale_bulk_%' AND status = 'acked' ORDER BY task_id", NET) as any[]).map((r) => r.task_id);
262
+ expect(left[0]).toBe(`stale_bulk_${stamp}_0500`);
263
+ withHours("72", () => patrol());
264
+ expect(openBulk()).toBe(0);
265
+ expect(hubNotices()).toBe(0);
266
+ }, 60_000);
267
+ });
268
+
269
+ describe("#2322 follow-up: expiry notice counts only open tasks within 24h", () => {
270
+ test("old abandoned acked rows on the target are not 'ahead'", async () => {
271
+ // 3 条几个月前被遗弃的 acked + 1 条 2 小时前的 acked(真的排在前面)。
272
+ for (let i = 0; i < 3; i++) {
273
+ const s = await dispatch(bossToken, nodes.target.alias);
274
+ await ack(s.id);
275
+ age(s.id, 24 * 60 + i);
276
+ }
277
+ const recent = await dispatch(bossToken, nodes.target.alias);
278
+ await ack(recent.id);
279
+ age(recent.id, 2);
280
+ // 一条 61 分钟前派出、1 分钟前到期、没人取的 → TTL 过期,通知发送方。
281
+ const id = (await dispatch(nodes.sender.token, nodes.target.alias)).id;
282
+ db.run("UPDATE tasks SET created_at = datetime('now', '-61 minutes'), delivered_at = datetime('now', '-61 minutes'), expires_at = datetime('now', '-1 minutes') WHERE task_id = ?1", [id]);
283
+ withHours(undefined, () => patrol());
284
+ expect(taskRow(id).status).toBe("expired");
285
+ const n = db.all("SELECT content, meta_json FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND from_session = 'hub'", nodes.sender.alias, NET) as Array<{ content: string; meta_json: string }>;
286
+ expect(n.length).toBe(1);
287
+ expect(JSON.parse(n[0].meta_json).task_expired.queued_ahead).toBe(1);
288
+ expect(n[0].content).toContain("还有 1 个");
289
+ });
290
+ });
@@ -0,0 +1,103 @@
1
+ // #519 —— 已开工(acked / running)却再也没有动静的任务,巡检把它结束为 expired。**默认关闭。**
2
+ //
3
+ // TTL 巡检(server.ts patrolExpiredTasks)从第一版起只看 created / delivered。任务一旦被 ack,
4
+ // 除非执行者带着这个 task_id 回终态(send_reply / report_completion / cancel_task),什么都不会再结束它:
5
+ // - claude-code channel 注入任务后立刻 ack_inbox,模型用 send_task 回了对方却没有 commhub_reply 收尾;
6
+ // - agent-node 跳过的任务(自派 / 低价值回复)也只 ack 不回;
7
+ // - running 的那一轮中途重启,回复再也没来。
8
+ // 这些行永远留在 acked / running,在 App「正在运行」里显示「N 天」,在 /api/stats 里堆成几万。
9
+ //
10
+ // 规则(COMMHUB_STALE_OPEN_TASK_HOURS = H,> 0 才启用;建议 72):
11
+ // status ∈ {acked, running},且「最近一次动静」早于 H 小时前 → status = expired,completed_at = now,
12
+ // result(原来是空的才写)= "stale: no activity for <H>h (was <status>) — closed by hub patrol (#519)"。
13
+ // 最近一次动静 = created_at / delivered_at / started_at / consumed_at / runtime_submitted_at
14
+ // 和这条任务最新一条 task_events 的最大值。写成「每一列都早于截止点 + 没有更晚的事件」,
15
+ // 不用标量 MAX/GREATEST:SQLite 与 PostgreSQL 写法一致,NULL 列不参与。
16
+ // - 每次巡检最多 STALE_OPEN_BATCH_LIMIT(500)行,最老的先结束;积压分多次巡检排空。
17
+ // - 每行一个带同样条件的 UPDATE(守卫):选中之后又有动静 / 被回复了的行不会被改。
18
+ // - 同一事务里把该任务的 inbox 行置 acked、同步定时任务运行行。
19
+ // - task_events 记一条 event_type = task.stale_expired(不是 task.expired:统计「TTL 过期」的口径不变)。
20
+ // - **不通知派活的一方**(多数情况下对方早已用 send_task 收到了答复;几天后再来一条通知只是噪音),
21
+ // **不走 chainReplyToParent**(不改父任务)。
22
+ // - 之后对这条任务的 send_reply 按现状被拒(reply_task_terminal),与任何终态任务一样。
23
+ //
24
+ // 为什么默认关:打开的那一刻会把历史积压一并结束(生产库上万行,且是与别的团队共享的 Hub)——
25
+ // 什么时候打开由 Hub 的负责人决定。
26
+
27
+ import { db, syncScheduledRunForTask } from "./db.js";
28
+
29
+ export const STALE_OPEN_TASK_ENV = "COMMHUB_STALE_OPEN_TASK_HOURS";
30
+ export const STALE_OPEN_RECOMMENDED_HOURS = 72;
31
+ export const STALE_OPEN_BATCH_LIMIT = 500;
32
+ export const STALE_OPEN_EVENT_TYPE = "task.stale_expired";
33
+
34
+ /** 读环境变量:未设 / 0 / 负数 / 非数字 → null(关闭)。每次巡检现读,改 env 不用重启测试进程。 */
35
+ export function staleOpenTaskHours(env: Record<string, string | undefined> = process.env): number | null {
36
+ const raw = (env[STALE_OPEN_TASK_ENV] ?? "").trim();
37
+ if (!raw) return null;
38
+ const h = Number(raw);
39
+ return Number.isFinite(h) && h > 0 ? h : null;
40
+ }
41
+
42
+ export function staleOpenReason(hours: number, status: string): string {
43
+ return `stale: no activity for ${hours}h (was ${status}) — closed by hub patrol (#519)`;
44
+ }
45
+
46
+ /** 「最近一次动静早于截止点」的 WHERE 片段。截止点是字面量(datetime('now', '-N seconds')),不绑参数。 */
47
+ function staleWhere(cutoff: string, alias = "tasks"): string {
48
+ return `${alias}.status IN ('acked', 'running')
49
+ AND ${alias}.created_at < ${cutoff}
50
+ AND (${alias}.delivered_at IS NULL OR ${alias}.delivered_at < ${cutoff})
51
+ AND (${alias}.started_at IS NULL OR ${alias}.started_at < ${cutoff})
52
+ AND (${alias}.consumed_at IS NULL OR ${alias}.consumed_at < ${cutoff})
53
+ AND (${alias}.runtime_submitted_at IS NULL OR ${alias}.runtime_submitted_at < ${cutoff})
54
+ AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = ${alias}.task_id AND e.created_at >= ${cutoff})`;
55
+ }
56
+
57
+ export type StaleClosedRow = { task_id: string; network_id: string | null; status: string };
58
+
59
+ /**
60
+ * 一次巡检的「陈旧开着的任务」阶段。关闭时什么都不做、返回 []。
61
+ * 返回本次结束的行(测试 / 日志用)。调用方负责吞异常。
62
+ */
63
+ export function expireStaleOpenTasks(opts: { hours?: number | null; limit?: number } = {}): StaleClosedRow[] {
64
+ const hours = opts.hours === undefined ? staleOpenTaskHours() : opts.hours;
65
+ if (!hours || hours <= 0) return [];
66
+ const limit = Math.max(1, Math.floor(opts.limit ?? STALE_OPEN_BATCH_LIMIT));
67
+ const seconds = Math.max(1, Math.round(hours * 3600));
68
+ const cutoff = `datetime('now', '-${seconds} seconds')`;
69
+ return db.transaction(() => {
70
+ const due = db.all<StaleClosedRow>(
71
+ `SELECT tasks.task_id, tasks.network_id, tasks.status FROM tasks
72
+ WHERE ${staleWhere(cutoff)}
73
+ ORDER BY tasks.created_at ASC LIMIT ${limit}`,
74
+ );
75
+ const closed: StaleClosedRow[] = [];
76
+ for (const task of due) {
77
+ const reason = staleOpenReason(hours, task.status);
78
+ const res = db.run(
79
+ `UPDATE tasks SET status = 'expired', completed_at = datetime('now'), result = COALESCE(result, ?2)
80
+ WHERE task_id = ?1 AND ${staleWhere(cutoff)}`,
81
+ [task.task_id, reason],
82
+ );
83
+ if (res.changes < 1) continue;
84
+ db.run(
85
+ `INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
86
+ VALUES (?1, ?2, 'expired', '${STALE_OPEN_EVENT_TYPE}', 'patrol', ?3, ?4)`,
87
+ [task.task_id, task.status, reason, task.network_id ?? null],
88
+ );
89
+ closed.push(task);
90
+ }
91
+ if (closed.length === 0) return closed;
92
+ // inbox 与 scheduled_task_runs 按 task_id 都没有可用索引(inbox 只能 COALESCE(task_id, id)):
93
+ // 每行各查一次 = 每行一次全表扫,生产库上 500 行要 ~8 秒的写事务。所以整批各查**一次**。
94
+ const ids = closed.map((t) => t.task_id);
95
+ const inList = ids.map((_, i) => `?${i + 1}`).join(", ");
96
+ db.run(`UPDATE inbox SET acked = 1 WHERE acked = 0 AND COALESCE(task_id, id) IN (${inList})`, ids);
97
+ const scheduled = new Set(
98
+ db.all<{ task_id: string }>(`SELECT task_id FROM scheduled_task_runs WHERE task_id IN (${inList})`, ...ids).map((r) => r.task_id),
99
+ );
100
+ for (const t of closed) if (scheduled.has(t.task_id)) syncScheduledRunForTask(t.task_id, t.network_id);
101
+ return closed;
102
+ });
103
+ }
@@ -0,0 +1,141 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-task-pagination-"));
7
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
8
+
9
+ let db: typeof import("./db.js").db;
10
+ let register: typeof import("./auth.js").register;
11
+ let server: ReturnType<typeof Bun.serve>;
12
+ let base = "";
13
+ let tokenA = "";
14
+ let networkA = "";
15
+ let networkB = "";
16
+
17
+ function seedTask(
18
+ taskId: string,
19
+ networkId: string,
20
+ toName: string,
21
+ createdAt: string,
22
+ ): void {
23
+ db.run(
24
+ `INSERT INTO tasks
25
+ (task_id, from_name, to_name, priority, status, content, network_id, created_at)
26
+ VALUES (?1, 'sender', ?2, 'normal', 'delivered', ?1, ?3, ?4)`,
27
+ [taskId, toName, networkId, createdAt],
28
+ );
29
+ }
30
+
31
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
32
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
33
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
34
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
35
+ beforeAll(async () => {
36
+ ({ db } = await import("./db.js"));
37
+ ({ register } = await import("./auth.js"));
38
+ const b = register(`page_b_${Date.now()}`, "PageTest-B-Strong!", undefined, "seed-b");
39
+ // The first registered user is the instance admin and may read all
40
+ // networks. Register the foreign fixture first so tokenA exercises the
41
+ // ordinary member-scoped path rather than the admin bypass.
42
+ const a = register(`page_a_${Date.now()}`, "PageTest-A-Strong!", undefined, "seed-a");
43
+ expect(a.ok).toBe(true);
44
+ expect(b.ok).toBe(true);
45
+ tokenA = a.token!;
46
+ networkA = a.network_id!;
47
+ networkB = b.network_id!;
48
+
49
+ seedTask("a-new", networkA, "agent-a", "2026-01-04 00:00:00");
50
+ seedTask("tie-z", networkA, "agent-a", "2026-01-05 00:00:00");
51
+ seedTask("tie-y", networkA, "agent-a", "2026-01-05 00:00:00");
52
+ seedTask("tie-x", networkA, "agent-a", "2026-01-05 00:00:00");
53
+ seedTask("a-cursor", networkA, "agent-a", "2026-01-03 00:00:00");
54
+ seedTask("a-old-2", networkA, "agent-a", "2026-01-02 00:00:00");
55
+ seedTask("a-old-1", networkA, "agent-b", "2026-01-01 00:00:00");
56
+ seedTask("b-old", networkB, "agent-a", "2026-01-02 12:00:00");
57
+
58
+ const mod = await import("./server.js");
59
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
60
+ base = `http://127.0.0.1:${server.port}`;
61
+ }, 30_000);
62
+
63
+ afterAll(() => {
64
+ try { server?.stop(true); } catch {}
65
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
66
+ });
67
+
68
+ async function list(query: string): Promise<{ response: Response; body: any }> {
69
+ const response = await fetch(`${base}/api/tasks?skip_stats=1&${query}`, {
70
+ headers: { Authorization: `Bearer ${tokenA}` },
71
+ });
72
+ return { response, body: await response.json() };
73
+ }
74
+
75
+ describe("GET /api/tasks cursor pagination", () => {
76
+ test("before is exclusive and remains network-scoped", async () => {
77
+ const { response, body } = await list("before=2026-01-03%2000%3A00%3A00&limit=20");
78
+ expect(response.status).toBe(200);
79
+ expect(body.tasks.map((row: any) => row.task_id)).toEqual(["a-old-2", "a-old-1"]);
80
+ expect(body.tasks.some((row: any) => row.task_id === "a-cursor")).toBe(false);
81
+ expect(body.tasks.some((row: any) => row.task_id === "b-old")).toBe(false);
82
+ });
83
+
84
+ test("ISO cursor normalizes to SQLite UTC and composes with to_name", async () => {
85
+ const { body } = await list("before=2026-01-03T00%3A00%3A00Z&to_name=agent-a&limit=20");
86
+ expect(body.tasks.map((row: any) => row.task_id)).toEqual(["a-old-2"]);
87
+ });
88
+
89
+ test("before_task_id preserves rows that share the cursor second", async () => {
90
+ const first = await list("limit=2");
91
+ expect(first.body.tasks.map((row: any) => row.task_id)).toEqual(["tie-z", "tie-y"]);
92
+ const cursor = first.body.tasks.at(-1);
93
+ const second = await list(
94
+ `before=${encodeURIComponent(cursor.created_at)}&before_task_id=${encodeURIComponent(cursor.task_id)}&limit=20`,
95
+ );
96
+ expect(second.response.status).toBe(200);
97
+ expect(second.body.tasks.map((row: any) => row.task_id)).toEqual([
98
+ "tie-x",
99
+ "a-new",
100
+ "a-cursor",
101
+ "a-old-2",
102
+ "a-old-1",
103
+ ]);
104
+ });
105
+
106
+ test("omitting before preserves the existing response shape and newest-first order", async () => {
107
+ const { body } = await list("limit=20");
108
+ expect(body.ok).toBe(true);
109
+ expect(body.count).toBe(7);
110
+ expect(body.tasks.map((row: any) => row.task_id)).toEqual([
111
+ "tie-z",
112
+ "tie-y",
113
+ "tie-x",
114
+ "a-new",
115
+ "a-cursor",
116
+ "a-old-2",
117
+ "a-old-1",
118
+ ]);
119
+ expect(body.stats).toBeUndefined();
120
+ });
121
+
122
+ test("empty, malformed, and rolled-over cursors fail closed", async () => {
123
+ for (const cursor of ["", "not-a-time", "2026-02-31 00:00:00"]) {
124
+ const { response, body } = await list(`before=${encodeURIComponent(cursor)}`);
125
+ expect(response.status).toBe(400);
126
+ expect(body).toEqual({ ok: false, error: "invalid_before" });
127
+ }
128
+ });
129
+
130
+ test("before_task_id requires a valid timestamp cursor and task id", async () => {
131
+ for (const query of [
132
+ "before_task_id=tie-y",
133
+ "before=2026-01-05%2000%3A00%3A00&before_task_id=",
134
+ `before=2026-01-05%2000%3A00%3A00&before_task_id=${"x".repeat(257)}`,
135
+ ]) {
136
+ const { response, body } = await list(query);
137
+ expect(response.status).toBe(400);
138
+ expect(body).toEqual({ ok: false, error: "invalid_before_task_id" });
139
+ }
140
+ });
141
+ });