@sleep2agi/commhub-server 0.9.0-preview.12 → 0.9.0-preview.121

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 (304) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -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/codex-config-flags.ts +77 -0
  37. package/src/codex-fork-contract.ts +23 -0
  38. package/src/codex-fork-migration.test.ts +38 -0
  39. package/src/commhub-bin-args.test.ts +85 -0
  40. package/src/config-apply-sec1.test.ts +107 -0
  41. package/src/config-apply-validate.test.ts +134 -1
  42. package/src/config-apply-validate.ts +111 -5
  43. package/src/create-node-blocked-gate.test.ts +356 -0
  44. package/src/create-node-tool-schema.test.ts +48 -0
  45. package/src/create-node-unicode-name.test.ts +120 -0
  46. package/src/create-node-validate.test.ts +193 -7
  47. package/src/create-node-validate.ts +217 -15
  48. package/src/create-node-workdir.test.ts +289 -0
  49. package/src/create-node.ts +77 -23
  50. package/src/daemon-provider-snapshots.test.ts +256 -0
  51. package/src/daemon-provider-snapshots.ts +226 -0
  52. package/src/daemon-token-owner-http.test.ts +231 -0
  53. package/src/dashboard-slash-routing-http.test.ts +107 -0
  54. package/src/db-adapter-guard.test.ts +148 -0
  55. package/src/db-adapter-translate.test.ts +128 -0
  56. package/src/db-adapter.ts +323 -87
  57. package/src/db-timestamp.test.ts +38 -0
  58. package/src/db-timestamp.ts +15 -0
  59. package/src/db.ts +903 -35
  60. package/src/delivered-without-push.test.ts +140 -0
  61. package/src/department-groups-http.test.ts +209 -0
  62. package/src/department-groups-sync-http.test.ts +328 -0
  63. package/src/department-groups.ts +434 -0
  64. package/src/department-heads-http.test.ts +330 -0
  65. package/src/department-heads.ts +226 -0
  66. package/src/departments-http.test.ts +185 -0
  67. package/src/departments.ts +260 -0
  68. package/src/desktop-message-seam.test.ts +150 -0
  69. package/src/desktop-message.test.ts +368 -0
  70. package/src/dm-file-scope-http.test.ts +178 -0
  71. package/src/ext-token-shared.test.ts +60 -0
  72. package/src/ext-validation-regression.test.ts +131 -0
  73. package/src/external-schedule-edits-http.test.ts +321 -0
  74. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  75. package/src/external-schedule-edits.ts +327 -0
  76. package/src/file-download-authz-dev-open.test.ts +135 -0
  77. package/src/file-download-authz.test.ts +404 -0
  78. package/src/file-download-date-bucket.test.ts +436 -0
  79. package/src/file-network-scope-dev-open.test.ts +97 -0
  80. package/src/file-network-scope.test.ts +732 -0
  81. package/src/group-membership-events-http.test.ts +264 -0
  82. package/src/group-messages-http.test.ts +482 -0
  83. package/src/group-messages.ts +297 -0
  84. package/src/health-redaction.test.ts +642 -0
  85. package/src/http-gzip.test.ts +94 -0
  86. package/src/http-gzip.ts +112 -0
  87. package/src/hub-timestamp.test.ts +38 -0
  88. package/src/hub-timestamp.ts +27 -0
  89. package/src/human-dm.ts +215 -0
  90. package/src/inbox-count-sse.test.ts +310 -0
  91. package/src/inbox-count.ts +29 -0
  92. package/src/index.ts +15 -2356
  93. package/src/lifecycle-guard.ts +1 -1
  94. package/src/mcp-context-cost-http.test.ts +120 -0
  95. package/src/mcp-projects-events-http.test.ts +162 -0
  96. package/src/mcp-route-label-http.test.ts +120 -0
  97. package/src/mcp-write-network-resolution.test.ts +429 -0
  98. package/src/member-audit-network-id-http.test.ts +93 -0
  99. package/src/member-presence-http.test.ts +210 -0
  100. package/src/messages-ack-by-agent-http.test.ts +142 -0
  101. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  102. package/src/migrate-sqlite-to-pg.ts +263 -0
  103. package/src/model-auth-notify-http.test.ts +181 -0
  104. package/src/model-auth-notify.ts +116 -0
  105. package/src/network-name-http.test.ts +75 -0
  106. package/src/network-scope.ts +276 -0
  107. package/src/network-token-membership-http.test.ts +139 -0
  108. package/src/node-alias-control.ts +12 -0
  109. package/src/node-attrs-validate.ts +88 -0
  110. package/src/node-attrs.test.ts +217 -0
  111. package/src/node-caller-identity-http.test.ts +714 -0
  112. package/src/node-create-request-status-http.test.ts +105 -0
  113. package/src/node-daemon-bindings.test.ts +132 -0
  114. package/src/node-daemon-bindings.ts +130 -0
  115. package/src/node-files-transport.test.ts +344 -0
  116. package/src/node-health-codex-login.test.ts +120 -0
  117. package/src/node-health-dispatch-http.test.ts +279 -0
  118. package/src/node-health-guard.ts +105 -0
  119. package/src/node-health-passthrough.test.ts +115 -0
  120. package/src/node-health-store.ts +101 -0
  121. package/src/node-id-alias.test.ts +190 -0
  122. package/src/node-identity-conflict-http.test.ts +269 -0
  123. package/src/node-identity-conflict.ts +142 -0
  124. package/src/node-identity-mismatch.test.ts +74 -0
  125. package/src/node-lifecycle-controllable.test.ts +64 -0
  126. package/src/node-lifecycle-controllable.ts +53 -0
  127. package/src/node-lifecycle-read-http.test.ts +245 -0
  128. package/src/node-lifecycle-read.ts +121 -0
  129. package/src/node-logs-transport.test.ts +263 -0
  130. package/src/node-permissions-http.test.ts +276 -0
  131. package/src/node-permissions.ts +283 -0
  132. package/src/node-request-retention.test.ts +281 -0
  133. package/src/node-request-retention.ts +120 -0
  134. package/src/node-skills-transport.test.ts +209 -0
  135. package/src/node-token-ownership.ts +36 -0
  136. package/src/node-token-privilege-http.test.ts +158 -0
  137. package/src/observer-avatar-http.test.ts +431 -0
  138. package/src/observer-push.test.ts +252 -0
  139. package/src/package-version-consistency.test.ts +25 -0
  140. package/src/peer-reply-atomic.test.ts +455 -0
  141. package/src/peer-reply-capability.test.ts +70 -0
  142. package/src/pg-worker.ts +87 -0
  143. package/src/project-dir-warn-clear-http.test.ts +132 -0
  144. package/src/project-viewer-can-http.test.ts +114 -0
  145. package/src/push.ts +429 -10
  146. package/src/redact-tokens.test.ts +65 -0
  147. package/src/redact-tokens.ts +40 -0
  148. package/src/rename.ts +22 -2
  149. package/src/reply-target-derivation.test.ts +96 -0
  150. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  151. package/src/report-status-host-schema.test.ts +235 -0
  152. package/src/report-status-resume-id-handover.test.ts +150 -0
  153. package/src/require-explicit-test-db.ts +9 -0
  154. package/src/requirement-due-reminders-http.test.ts +620 -0
  155. package/src/requirement-due-reminders.ts +416 -0
  156. package/src/requirement-events-http.test.ts +268 -0
  157. package/src/requirement-events.test.ts +45 -0
  158. package/src/requirement-events.ts +144 -0
  159. package/src/requirement-notify.ts +165 -0
  160. package/src/requirement-participant-notify-http.test.ts +309 -0
  161. package/src/requirement-tag-ops-http.test.ts +266 -0
  162. package/src/requirement-tags.ts +89 -0
  163. package/src/requirements-abandoned-http.test.ts +236 -0
  164. package/src/requirements-comment-http.test.ts +167 -0
  165. package/src/requirements-completed-at-migration.test.ts +70 -0
  166. package/src/requirements-errors-mcp-http.test.ts +164 -0
  167. package/src/requirements-errors.ts +143 -0
  168. package/src/requirements-etag-http.test.ts +91 -0
  169. package/src/requirements-http.test.ts +709 -0
  170. package/src/requirements-last-event-http.test.ts +349 -0
  171. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  172. package/src/requirements-list-slim-http.test.ts +319 -0
  173. package/src/requirements-mcp.test.ts +163 -0
  174. package/src/requirements-migrate.ts +259 -0
  175. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  176. package/src/requirements-people-http.test.ts +114 -0
  177. package/src/requirements-people-mcp-http.test.ts +225 -0
  178. package/src/requirements-people.ts +127 -0
  179. package/src/requirements-priority-lowest.test.ts +114 -0
  180. package/src/requirements-search-http.test.ts +182 -0
  181. package/src/requirements-search.test.ts +32 -0
  182. package/src/requirements-search.ts +145 -0
  183. package/src/requirements-seq-http.test.ts +193 -0
  184. package/src/requirements-seq-migration.test.ts +63 -0
  185. package/src/requirements-start-date.test.ts +125 -0
  186. package/src/requirements-stats-http.test.ts +288 -0
  187. package/src/requirements-stats.ts +167 -0
  188. package/src/requirements-status-participants-http.test.ts +222 -0
  189. package/src/requirements-upgrade-e2e.test.ts +126 -0
  190. package/src/requirements-upgrade-migration.test.ts +98 -0
  191. package/src/requirements.ts +1806 -0
  192. package/src/resolve-port.test.ts +57 -0
  193. package/src/resolve-port.ts +44 -0
  194. package/src/response-charset.test.ts +130 -0
  195. package/src/rest-explicit-columns-http.test.ts +222 -0
  196. package/src/rest-identity.test.ts +129 -0
  197. package/src/rest-identity.ts +125 -0
  198. package/src/rest-projections.ts +68 -0
  199. package/src/rest-write-network-resolution.test.ts +133 -0
  200. package/src/restricted-files.ts +76 -0
  201. package/src/retention.ts +15 -3
  202. package/src/route-callers-http.test.ts +120 -0
  203. package/src/route-timing.test.ts +211 -0
  204. package/src/route-timing.ts +258 -0
  205. package/src/rules-file-duplicate-alias.test.ts +234 -0
  206. package/src/rules-file-session-target.test.ts +207 -0
  207. package/src/rules-file-transport.test.ts +237 -0
  208. package/src/runtime-label.test.ts +22 -0
  209. package/src/runtime-label.ts +21 -0
  210. package/src/runtime-readiness.ts +90 -0
  211. package/src/schedule-agent-mcp-http.test.ts +381 -0
  212. package/src/schedule-agent.ts +192 -0
  213. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  214. package/src/scheduled-failures-http.test.ts +233 -0
  215. package/src/scheduled-failures.ts +179 -0
  216. package/src/scheduled-offline.test.ts +131 -0
  217. package/src/scheduled-offline.ts +66 -0
  218. package/src/scheduled-reply-unread-http.test.ts +212 -0
  219. package/src/scheduled-run-terminal.test.ts +268 -0
  220. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  221. package/src/scheduled-stuck-http.test.ts +238 -0
  222. package/src/scheduled-stuck.ts +153 -0
  223. package/src/scheduled-tasks-http.test.ts +638 -0
  224. package/src/scheduled-tasks.ts +766 -0
  225. package/src/send-dedup-request-id.test.ts +229 -0
  226. package/src/send-reply-agent-warning.test.ts +202 -0
  227. package/src/send-reply-alias-optional.test.ts +131 -0
  228. package/src/send-reply-attachments.test.ts +458 -0
  229. package/src/send_dedup.test.ts +54 -0
  230. package/src/send_dedup.ts +52 -6
  231. package/src/serve-error.test.ts +38 -0
  232. package/src/serve-error.ts +27 -0
  233. package/src/server.ts +5253 -0
  234. package/src/session-task-on-dispatch.ts +14 -0
  235. package/src/shared/codex-inventory.ts +34 -0
  236. package/src/shared/daemon-provider-snapshot.ts +49 -0
  237. package/src/shared/external-schedule-contract.ts +60 -0
  238. package/src/shared/node-name.test.ts +62 -0
  239. package/src/shared/node-name.ts +161 -0
  240. package/src/shared/provider-application.ts +24 -0
  241. package/src/shared/reserved-env-drift.test.ts +10 -0
  242. package/src/shared/task-content-limit-drift.test.ts +28 -0
  243. package/src/shared/task-content-limit.ts +25 -0
  244. package/src/side-thread-command-transport.test.ts +312 -0
  245. package/src/side-thread-command-transport.ts +318 -0
  246. package/src/side-thread-http-integration.test.ts +233 -0
  247. package/src/side-thread-http.ts +504 -0
  248. package/src/side-thread-production.test.ts +72 -0
  249. package/src/side-thread-production.ts +118 -0
  250. package/src/side-thread.test.ts +1067 -0
  251. package/src/side-thread.ts +2499 -0
  252. package/src/skillhub-http.test.ts +108 -0
  253. package/src/sqlite-close-checkpoint.test.ts +78 -0
  254. package/src/start-node.test.ts +264 -0
  255. package/src/status-alias-filter-http.test.ts +106 -0
  256. package/src/status-alias-resolver-http.test.ts +163 -0
  257. package/src/status-alias-resolver.ts +19 -0
  258. package/src/status-node-id-filter-http.test.ts +161 -0
  259. package/src/status-read-cache-http.test.ts +229 -0
  260. package/src/status-read-cache.ts +122 -0
  261. package/src/stop-delete-node.test.ts +469 -1
  262. package/src/task-access-http.test.ts +402 -0
  263. package/src/task-access.ts +420 -0
  264. package/src/task-auth-origin-http.test.ts +91 -0
  265. package/src/task-auth-origin.test.ts +24 -0
  266. package/src/task-auth-origin.ts +7 -0
  267. package/src/task-consumption.test.ts +482 -0
  268. package/src/task-diagnostic-http.test.ts +149 -0
  269. package/src/task-diagnostic.test.ts +115 -0
  270. package/src/task-diagnostic.ts +118 -0
  271. package/src/task-expiry-notice-http.test.ts +287 -0
  272. package/src/task-expiry-notice.ts +315 -0
  273. package/src/task-idempotency-mcp.test.ts +142 -0
  274. package/src/task-idempotency.test.ts +60 -0
  275. package/src/task-idempotency.ts +75 -0
  276. package/src/task-late-replies.ts +54 -0
  277. package/src/task-late-reply-http.test.ts +152 -0
  278. package/src/task-lifecycle-watcher.test.ts +204 -0
  279. package/src/task-lifecycle-watcher.ts +49 -0
  280. package/src/task-network-resolution-http.test.ts +141 -0
  281. package/src/task-orphan-http.test.ts +287 -0
  282. package/src/task-orphan.ts +119 -0
  283. package/src/task-queue-ahead-http.test.ts +309 -0
  284. package/src/task-queue-ahead-scale.test.ts +236 -0
  285. package/src/task-queue-ahead.ts +186 -0
  286. package/src/task-rejection-log-http.test.ts +160 -0
  287. package/src/task-rejection-log.ts +49 -0
  288. package/src/task-stale-open-http.test.ts +290 -0
  289. package/src/task-stale-open.ts +103 -0
  290. package/src/tasks-pagination-http.test.ts +141 -0
  291. package/src/tool-audience-http.test.ts +222 -0
  292. package/src/tool-audience.ts +116 -0
  293. package/src/tools-list-json-schema.test.ts +67 -0
  294. package/src/tools.ts +3598 -313
  295. package/src/upload-early-reject-guard.test.ts +155 -0
  296. package/src/uploads-http.test.ts +97 -15
  297. package/src/uploads.test.ts +66 -0
  298. package/src/uploads.ts +138 -5
  299. package/src/user-avatar-http.test.ts +74 -0
  300. package/src/user-avatar-migration.test.ts +25 -0
  301. package/src/user-inbox-by-agent-http.test.ts +161 -0
  302. package/src/user-inbox-netid-notnull.test.ts +122 -0
  303. package/src/user-inbox-read-http.test.ts +223 -0
  304. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,122 @@
1
+ // #431(app「连接较慢」)—— GET /api/status 的序列化结果记忆化 + 强 ETag。
2
+ //
3
+ // 现场(2026-10-02 生产 /api/stats/routes,15 分钟窗口):GET /api/status 2000 次 ≈ 2.2 次/秒,全部来自 UA「node」
4
+ // (旧版 agent-node 的别名解析器,.82 起给它 light+node_id 投影),线上平均 30 KB/次(已经是 gzip 后的字节:
5
+ // node 的 fetch 发 `Accept-Encoding: gzip, deflate`)。两次读之间表多半没变(report_status 约 0.75 次/秒),
6
+ // 但每次都重新 SELECT 300 行、拼对象、JSON.stringify、再 gzip 一遍。
7
+ //
8
+ // 这里只做「同一份正文不重复算」,**不改任何正文**:
9
+ // 键 = 最终 SQL + 参数(里面已经编码了调用者能看到哪些节点)+ 投影开关;
10
+ // 失效 = 任何写 sessions 表的语句(db.ts 里统一挂钩,SQLite / PG 同一处)、任何健康上报、
11
+ // 任何一份健康报告过 TTL(那一刻 degraded / health 字段会变)。
12
+ // 全量投影里 `health_observed_ms_ago` 随时间走:只要有一行带健康报告,这份正文就是时间的函数,不缓存。
13
+ // 命中时正文和当场重算逐字节相同(status-read-cache-http.test.ts 用随机写序列钉住)。
14
+ //
15
+ // ETag:正文的哈希,`If-None-Match` 命中回 304 空体。🔴 旧 agent-node 不发 If-None-Match(fetch 没有 HTTP 缓存),
16
+ // 所以 ETag 只帮得上会带它的客户端(app / dashboard 以后接上);隧道上省字节靠的是同一份 gzip 只压一次
17
+ // 且用更高的压缩级别(http-gzip.ts:可复用的正文压一次、存起来,级别 9)。
18
+
19
+ import type { DbAdapter } from "./db-adapter";
20
+ import { nodeHealthNextExpiryAt, nodeHealthVersion } from "./node-health-store.js";
21
+
22
+ let sessionsWriteGen = 0;
23
+ // #500 step 2 —— 全量投影带 queue_depth(tasks 表的开着任务数):写 tasks 的语句也要让**全量**正文失效。
24
+ // light / 别名解析器投影不读 tasks,不看这个代数(写任务不打掉它们的缓存)。
25
+ let tasksWriteGen = 0;
26
+
27
+ const WRITE_START = /^\s*(?:insert|update|delete|replace|upsert|merge|alter|drop|create|truncate|with)\b/i;
28
+ const SESSIONS_WORD = /\bsessions\b/i;
29
+ // `\b` 不在 `_` 与字母之间:scheduled_tasks / task_events 不命中,只有 tasks 表本身。
30
+ const TASKS_WORD = /\btasks\b/i;
31
+
32
+ /**
33
+ * 每条经过 db 的语句都过一遍。写语句(含带 RETURNING 的、DDL)里点名了 sessions → 代数 +1。读语句不动。
34
+ * 不会有「没点名 sessions 却改了它」的写:两种方言的 schema 里都没有指向 sessions 的外键 / 级联删除
35
+ * (status-read-cache.test.ts 钉住这一点)。
36
+ */
37
+ export function noteStatement(sql: string): void {
38
+ if (!WRITE_START.test(sql)) return;
39
+ if (SESSIONS_WORD.test(sql)) sessionsWriteGen++;
40
+ if (TASKS_WORD.test(sql)) tasksWriteGen++;
41
+ }
42
+
43
+ /** 原生 exec(迁移 / DDL / 多语句):不逐句判断,一律失效。 */
44
+ export function noteExec(): void { sessionsWriteGen++; tasksWriteGen++; }
45
+
46
+ export function statusWriteGeneration(): number { return sessionsWriteGen; }
47
+ export function statusTasksWriteGeneration(): number { return tasksWriteGen; }
48
+
49
+ /**
50
+ * db.ts 用它包住唯一的那个适配器:每条语句先报给上面两个函数。挂在这一个出口上,SQLite / PG 一样,
51
+ * 不靠每个写 sessions 的地方各自记得。直接换掉实例上的四个方法(不用 Proxy):有测试按 `db.all = …`
52
+ * 临时打桩数查询次数,方法得是普通可写属性。
53
+ */
54
+ export function withStatementHook(inner: DbAdapter): DbAdapter {
55
+ const run = inner.run.bind(inner), get = inner.get.bind(inner), all = inner.all.bind(inner), exec = inner.exec.bind(inner);
56
+ inner.run = (sql: string, params?: any[]) => { noteStatement(sql); return run(sql, params); };
57
+ inner.get = ((sql: string, ...params: any[]) => { noteStatement(sql); return get(sql, ...params); }) as DbAdapter["get"];
58
+ inner.all = ((sql: string, ...params: any[]) => { noteStatement(sql); return all(sql, ...params); }) as DbAdapter["all"];
59
+ inner.exec = (sql: string) => { noteExec(); return exec(sql); };
60
+ return inner;
61
+ }
62
+
63
+ type Entry = { gen: number; taskGen: number | null; healthVer: number; validUntil: number; body: string; etag: string };
64
+ const MAX_ENTRIES = 64;
65
+ const memo = new Map<string, Entry>();
66
+ export const statusCacheStats = { hits: 0, misses: 0, uncacheable: 0 };
67
+
68
+ function etagOf(body: string): string {
69
+ return `"s-${Bun.hash(body).toString(36)}-${body.length.toString(36)}"`;
70
+ }
71
+
72
+ /**
73
+ * 取或算。compute 返回正文和它能不能缓存(全量投影里有健康报告 = 正文随时间变 = 不能)。
74
+ * 返回的 etag 总是这份正文的哈希(不缓存的也给,便于 304)。
75
+ */
76
+ let bypass = false;
77
+ /** 测试用:临时绕过缓存(既不读也不写),拿「当场重算」的那一份来对比,而不清掉缓存里已有的条目。 */
78
+ export function __setStatusCacheBypassForTest(on: boolean): void { bypass = on; }
79
+
80
+ export function memoStatusBody(
81
+ key: string,
82
+ compute: () => { body: string; cacheable: boolean },
83
+ now = Date.now(),
84
+ opts: { dependsOnTasks?: boolean } = {},
85
+ ): { body: string; etag: string; hit: boolean } {
86
+ if (bypass) { const { body } = compute(); return { body, etag: etagOf(body), hit: false }; }
87
+ const gen = sessionsWriteGen;
88
+ const taskGen = opts.dependsOnTasks ? tasksWriteGen : null;
89
+ const healthVer = nodeHealthVersion();
90
+ const hit = memo.get(key);
91
+ if (hit && hit.gen === gen && hit.taskGen === taskGen && hit.healthVer === healthVer && now < hit.validUntil) {
92
+ statusCacheStats.hits++;
93
+ memo.delete(key);
94
+ memo.set(key, hit); // LRU
95
+ return { body: hit.body, etag: hit.etag, hit: true };
96
+ }
97
+ const { body, cacheable } = compute();
98
+ const etag = etagOf(body);
99
+ // 以算之前的代数入账:算的过程中若有写(改名清理之类),下一次比较就对不上、重算 —— 宁可多算一次也不发旧正文。
100
+ if (!cacheable) {
101
+ statusCacheStats.uncacheable++;
102
+ memo.delete(key);
103
+ return { body, etag, hit: false };
104
+ }
105
+ statusCacheStats.misses++;
106
+ memo.set(key, { gen, taskGen, healthVer, validUntil: nodeHealthNextExpiryAt(now), body, etag });
107
+ while (memo.size > MAX_ENTRIES) memo.delete(memo.keys().next().value as string);
108
+ return { body, etag, hit: false };
109
+ }
110
+
111
+ /** If-None-Match 里有这个 ETag(或 `*`)。弱比较:去掉 W/ 前缀。 */
112
+ export function ifNoneMatchHits(header: string | null, etag: string): boolean {
113
+ if (!header) return false;
114
+ const bare = (t: string) => t.trim().replace(/^W\//, "");
115
+ return header.split(",").some((t) => t.trim() === "*" || bare(t) === bare(etag));
116
+ }
117
+
118
+ /** 测试用。 */
119
+ export function __resetStatusCacheForTest(): void {
120
+ memo.clear();
121
+ statusCacheStats.hits = statusCacheStats.misses = statusCacheStats.uncacheable = 0;
122
+ }
@@ -2,12 +2,13 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { db } from "./db.js";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { registerTools } from "./tools.js";
5
+ import { patrolExpiredTasks } from "./server.js";
5
6
 
6
7
  // RFC-027 PR1 — handler-driven tests for stop_node, delete_node,
7
8
  // get_stop_request, ack_stop_request. Drives the MCP handlers via
8
9
  // registerTools() so the wired auth context is real (not the
9
10
  // mirror-SQL pattern that PR2 v1 used and which let SEC-1 leak slip
10
- // through, per [[feedback_parallel_fork_review_catches_wired_wrong]]).
11
+ // through — per team rule: independent fork-review catches wired-wrong even when unit tests pass).
11
12
  //
12
13
  // Covers RFC-027 P1 e2e plan §5.2 scenarios A/B/C/D/E/F at the
13
14
  // handler layer:
@@ -36,11 +37,15 @@ interface Reply { ok?: boolean; error?: string; [k: string]: unknown; }
36
37
  function cleanup() {
37
38
  for (const n of [NET_A, NET_B]) {
38
39
  try { db.run("DELETE FROM node_stop_requests WHERE network_id = ?1", [n]); } catch {}
40
+ try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [n]); } catch {}
39
41
  try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [n]); } catch {}
40
42
  try { db.run("DELETE FROM inbox WHERE network_id = ?1", [n]); } catch {}
41
43
  // PR1.2a restart_node tests leave node_config_updates rows; clear
42
44
  // so the next test's restart_node doesn't see them as in_flight.
43
45
  try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [n]); } catch {}
46
+ // #2022 seeds tasks rows whose status drives the in-flight join; clear
47
+ // them by network or a later test inherits a stale terminal row.
48
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [n]); } catch {}
44
49
  try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
45
50
  try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
46
51
  try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
@@ -179,6 +184,12 @@ function setupAlphaNetwork() {
179
184
  seedMembership(USER_A_ID, NET_A, "admin");
180
185
  seedDaemon(NET_A, DAEMON_A_ID, DAEMON_A_ALIAS, USER_A_ID, DAEMON_A_TOK);
181
186
  seedChild(NET_A, CHILD_A_ID, CHILD_A_ALIAS, USER_A_ID, CHILD_TOK_A_ID);
187
+ // Board #625: happy paths must carry actual creation authority, not merely a node_ id.
188
+ db.run(
189
+ `INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
190
+ VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
191
+ [`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
192
+ );
182
193
  }
183
194
 
184
195
  // ── stop_node ──────────────────────────────────────────────────────
@@ -408,6 +419,69 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
408
419
  expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a");
409
420
  });
410
421
 
422
+ // #1448 finding-4 — stop/delete 必须校验 daemon 是 child 的权威创建者(镜像 start)。
423
+ // 改前只校验 daemon 存在+同网 → 同网调用方传错 daemon_node_id,门铃路由到错 daemon。
424
+ // f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
425
+ test("#1448 finding-4: stop/delete reject a wrong same-network daemon_node_id (daemon_child_mismatch)", async () => {
426
+ setupAlphaNetwork();
427
+ // setupAlphaNetwork carries the creation authority.
428
+ // 一个同网、存在、但没创建 CHILD_A 的 daemon
429
+ const DAEMON_B = "node_sd_daemon_b";
430
+ seedDaemon(NET_A, DAEMON_B, "sd-daemon-b", USER_A_ID, "tok_sd_daemon_b");
431
+ const u = buildHandlers(USER_A_ID);
432
+ // 错 daemon → mismatch(stop + delete 都拦)。改前会放行、路由到错 daemon。
433
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, network_id: NET_A })).error).toBe("daemon_child_mismatch");
434
+ expect((await call(u.delete_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, confirm_alias: CHILD_A_ALIAS, network_id: NET_A })).error).toBe("daemon_child_mismatch");
435
+ // 正确 daemon → 放行(回归钉)
436
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A })).ok).toBe(true);
437
+ });
438
+
439
+ // #571 — 手工起的节点(id `n_…`)没有任何 daemon 链接。改前:authority==null 时
440
+ // 放行调用方点名的任意同网 daemon → 门铃发过去,那台 daemon 本地无记录,按 alias
441
+ // 全机 pgrep+SIGTERM,误杀同 alias 的别的 agent-node。改后:不论传没传 daemon 一律拒。
442
+ test("#571: stop/delete of a hand-started node refuse even with an explicit same-network daemon", async () => {
443
+ setupAlphaNetwork();
444
+ const HAND_ID = "n_571abcde";
445
+ const HAND_ALIAS = "sd-hand-571";
446
+ seedChild(NET_A, HAND_ID, HAND_ALIAS, USER_A_ID, "tok_sd_hand_571");
447
+ const u = buildHandlers(USER_A_ID);
448
+ const s = await call(u.stop_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
449
+ expect(s.ok).toBe(false);
450
+ expect(s.error).toBe("not_daemon_managed");
451
+ expect(String(s.message)).toContain("anet node stop");
452
+ const d = await call(u.delete_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, confirm_alias: HAND_ALIAS, network_id: NET_A });
453
+ expect(d.error).toBe("not_daemon_managed");
454
+ // 没有任何副作用:状态没动、没写 stop 请求行
455
+ expect(readNode(HAND_ID)?.lifecycle_state).toBe("active");
456
+ const rows = db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM node_stop_requests WHERE child_node_id = ?1`, HAND_ID);
457
+ expect(Number(rows?.n ?? 0)).toBe(0);
458
+ // 回归钉:setupAlphaNetwork 中有 create 记录的 child 照常可停。
459
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, network_id: NET_A })).ok).toBe(true);
460
+ });
461
+
462
+ // #1448 finding-5 — delete 完成时按派发时定住的 token_id 精确撤 ntok,而非按
463
+ // name='node:<alias>' 广撤。改前:同 alias 在 delete 未收敛窗口内被重建、拿到同名新
464
+ // token,ack 会把新 token 一并误撤(下面 REBUILT_TOK 断言 not revoked 会红)。
465
+ test("#1448 finding-5: delete revokes by token_id, spares a same-alias rebuilt token", async () => {
466
+ setupAlphaNetwork();
467
+ const userTools = buildHandlers(USER_A_ID);
468
+ const dispatch = await call(userTools.delete_node, {
469
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
470
+ });
471
+ expect(dispatch.ok).toBe(true);
472
+ // 派发后、ack 前:同 alias 节点被重建,拿到**同名**新 ntok(不同 token_id,仍 active)。
473
+ const REBUILT_TOK = "tok_sd_child_a_rebuilt";
474
+ db.run(
475
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
476
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
477
+ [REBUILT_TOK, USER_A_ID, NET_A, `node:${CHILD_A_ALIAS}`, `hash_${REBUILT_TOK}`],
478
+ );
479
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
480
+ await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
481
+ expect(readToken(CHILD_TOK_A_ID)?.revoked_at).not.toBeNull(); // 原 token 撤销(精确命中)
482
+ expect(readToken(REBUILT_TOK)?.revoked_at).toBeNull(); // 重建的同名新 token 未被误撤(改前按 name 广撤会红)
483
+ });
484
+
411
485
  test("daemon ack 'stop_failed' → lifecycle_state=stop_failed, request error captured", async () => {
412
486
  setupAlphaNetwork();
413
487
  const userTools = buildHandlers(USER_A_ID);
@@ -676,6 +750,135 @@ describe("list_my_children HANDLER (RFC-027 PR1.1)", () => {
676
750
  });
677
751
  });
678
752
 
753
+ describe("create_node audit wording (#1362)", () => {
754
+ test("create_node records a dispatch attempt, not guaranteed SSE delivery", async () => {
755
+ setupAlphaNetwork();
756
+ const tools = buildHandlers(USER_A_ID);
757
+ const r = await call(tools.create_node, {
758
+ daemon_node_id: DAEMON_A_ID,
759
+ node_spec: {
760
+ name: "sd-new-child",
761
+ runtime: "claude-agent-sdk",
762
+ model: "x",
763
+ },
764
+ network_id: NET_A,
765
+ });
766
+
767
+ expect(r.ok).toBe(true);
768
+ expect(typeof r.request_id).toBe("string");
769
+ expect(readAudit("create_node_dispatch_attempted", NET_A).length).toBe(1);
770
+ expect(readAudit("create_node_dispatched", NET_A).length).toBe(0);
771
+ });
772
+ });
773
+
774
+ describe("list_my_pending_create_requests HANDLER (#1362 SSE reconnect compensation)", () => {
775
+ test("daemon-bound caller gets only its own pending create requests", async () => {
776
+ setupAlphaNetwork();
777
+ const otherDaemonId = "node_sd_daemon_other";
778
+ seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
779
+ db.run(
780
+ `INSERT INTO node_create_requests
781
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
782
+ VALUES
783
+ ('cr_pending_mine', ?1, 'child-pending-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?3, 'tok_test'),
784
+ ('cr_delivered_mine', ?1, 'child-delivered-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'delivered', NULL, ?4, 'tok_test'),
785
+ ('cr_pending_other', ?5, 'child-pending-other', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?6, 'tok_test')`,
786
+ [DAEMON_A_ID, NET_A, Date.now(), Date.now() + 1, otherDaemonId, Date.now() + 2],
787
+ );
788
+
789
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
790
+ const r = await call(daemonTools.list_my_pending_create_requests, {});
791
+
792
+ expect(r.ok).toBe(true);
793
+ expect(r.count).toBe(1);
794
+ expect(r.requests).toEqual([
795
+ expect.objectContaining({ request_id: "cr_pending_mine", child_name: "child-pending-mine" }),
796
+ ]);
797
+ });
798
+
799
+ test("non-daemon caller (utok) refused", async () => {
800
+ setupAlphaNetwork();
801
+ const userTools = buildHandlers(USER_A_ID);
802
+ const r = await call(userTools.list_my_pending_create_requests, {});
803
+ expect(r.ok).toBe(false);
804
+ });
805
+ });
806
+
807
+ describe("list_my_pending_lifecycle_requests HANDLER (#1448 SSE reconnect compensation)", () => {
808
+ function seedStop(reqId: string, daemonId: string, status: string, action: string, ageMs: number) {
809
+ db.run(
810
+ `INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
811
+ child_alias, action, delete_config, created_by_token, status, created_at)
812
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 'tok_test', ?7, ?8)`,
813
+ [reqId, NET_A, daemonId, CHILD_A_ID, CHILD_A_ALIAS, action, status, Date.now() - ageMs],
814
+ );
815
+ }
816
+ // child_node_id parameterized: node_start_requests has a partial-unique
817
+ // index on child_node_id WHERE status IN ('pending','delivered'), so the
818
+ // "mine" and "other" pending start rows must target different children.
819
+ function seedStart(reqId: string, daemonId: string, childId: string, status: string, ageMs: number) {
820
+ db.run(
821
+ `INSERT INTO node_start_requests (request_id, network_id, daemon_node_id, child_node_id,
822
+ child_alias, created_by_token, status, created_at)
823
+ VALUES (?1, ?2, ?3, ?4, ?5, 'tok_test', ?6, ?7)`,
824
+ [reqId, NET_A, daemonId, childId, CHILD_A_ALIAS, status, Date.now() - ageMs],
825
+ );
826
+ }
827
+
828
+ test("daemon-bound caller gets only its own PENDING stop/delete + start (excludes delivered + other daemon)", async () => {
829
+ setupAlphaNetwork();
830
+ const otherDaemonId = "node_sd_daemon_other";
831
+ seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
832
+ // mine — should be returned (stop + delete, oldest first)
833
+ seedStop("sr_pending_stop", DAEMON_A_ID, "pending", "stop", 30);
834
+ seedStop("sr_pending_delete", DAEMON_A_ID, "pending", "delete", 20);
835
+ seedStart("str_pending_mine", DAEMON_A_ID, CHILD_A_ID, "pending", 25);
836
+ // excluded — delivered (already pulled) + other daemon's pending
837
+ seedStop("sr_delivered_mine", DAEMON_A_ID, "delivered", "stop", 40);
838
+ seedStop("sr_pending_other", otherDaemonId, "pending", "stop", 10);
839
+ seedStart("str_pending_other", otherDaemonId, "node_sd_child_other", "pending", 10);
840
+
841
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
842
+ const res = await call(daemonTools.list_my_pending_lifecycle_requests, {});
843
+ expect(res.ok).toBe(true);
844
+ expect(res.stop_count).toBe(2);
845
+ expect((res.stop_requests as Array<{ request_id: string }>).map(x => x.request_id))
846
+ .toEqual(["sr_pending_stop", "sr_pending_delete"]); // oldest-first, action preserved
847
+ expect(res.start_count).toBe(1);
848
+ expect((res.start_requests as Array<{ request_id: string }>).map(x => x.request_id))
849
+ .toEqual(["str_pending_mine"]);
850
+ });
851
+
852
+ // Witnessed-red 的核心链:真实 stop_node 派发时 daemon 无 SSE 订阅者 →
853
+ // pushEvent 静默丢门铃 → 行卡 pending / 节点卡 stopping(缺陷态)。修复后这条
854
+ // pending 行会被 list_my_pending_lifecycle_requests 列出 → 重连 reconcile 能重放。
855
+ // 改前(无此工具)该行永远无人发现、节点永久卡 stopping。
856
+ test("stuck-pending after a dropped doorbell IS surfaced for reconnect replay", async () => {
857
+ setupAlphaNetwork();
858
+ const userTools = buildHandlers(USER_A_ID);
859
+ // 无 SSE 订阅者:dispatch 建 pending 行 + pushEvent 丢弃(测试进程里没有 client)
860
+ const disp = await call(userTools.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
861
+ expect(disp.ok).toBe(true);
862
+ const reqId = disp.request_id as string;
863
+ // 缺陷态:行 pending、节点 stopping(门铃没送达,无人推进)
864
+ const row = db.get<{ status: string }>("SELECT status FROM node_stop_requests WHERE request_id = ?1", reqId);
865
+ expect(row?.status).toBe("pending");
866
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
867
+ // 修复:pending 行经新工具对本 daemon 可见 → 重连补偿能拿到并重放
868
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
869
+ const r = await call(daemonTools.list_my_pending_lifecycle_requests, {});
870
+ expect(r.ok).toBe(true);
871
+ expect((r.stop_requests as Array<{ request_id: string }>).map(x => x.request_id)).toContain(reqId);
872
+ });
873
+
874
+ test("non-daemon caller (utok) refused", async () => {
875
+ setupAlphaNetwork();
876
+ const userTools = buildHandlers(USER_A_ID);
877
+ const r = await call(userTools.list_my_pending_lifecycle_requests, {});
878
+ expect(r.ok).toBe(false);
879
+ });
880
+ });
881
+
679
882
  // ─── PR1.2a — restart_node resets lifecycle_state ──────────────────
680
883
  //
681
884
  // 通信龙 #346 ack latent: without this, a stopped node restart-ed via
@@ -718,3 +921,268 @@ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", (
718
921
  expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
719
922
  });
720
923
  });
924
+
925
+ // ── #1286 stale-deleting 重派出口 ──────────────────────────────────
926
+ //
927
+ // 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
928
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
929
+ // 出口三条件缺一不放行:force=true + 最近 delete 请求非终态 + 晾超 5 分钟。
930
+ describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
931
+ function seedDeletingChild() {
932
+ setupAlphaNetwork();
933
+ db.run(`UPDATE nodes SET lifecycle_state = 'deleting' WHERE node_id = ?1`, [CHILD_A_ID]);
934
+ }
935
+ function seedStopRequest(status: string, ageMs: number) {
936
+ db.run(
937
+ `INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
938
+ child_alias, action, delete_config, created_by_token, status, created_at)
939
+ VALUES (?1, ?2, ?3, ?4, ?5, 'delete', 1, 'tok_test', ?6, ?7)`,
940
+ [`sr_stale_${Date.now()}_${Math.floor(ageMs)}`, NET_A, DAEMON_A_ID, CHILD_A_ID,
941
+ CHILD_A_ALIAS, status, Date.now() - ageMs],
942
+ );
943
+ }
944
+ const base = { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS };
945
+
946
+ test("无 force:仍拒,且带 hint 指出口(不是让人反复撞墙)", async () => {
947
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
948
+ const r = await call(buildHandlers(USER_A_ID).delete_node, base);
949
+ expect(r.ok).toBe(false);
950
+ expect(r.error).toBe("node_already_deleting");
951
+ expect(typeof r.hint).toBe("string");
952
+ });
953
+
954
+ test("🔴 force 但请求还新鲜(1 分钟):仍拒 —— force 不能变成竞态放大器", async () => {
955
+ seedDeletingChild(); seedStopRequest("pending", 60_000);
956
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
957
+ expect(r.ok).toBe(false);
958
+ expect(r.error).toBe("node_already_deleting");
959
+ });
960
+
961
+ test("🔴 force 但上一条已终态(stopped):仍拒 —— daemon 刚 ack 过,行马上会转移", async () => {
962
+ seedDeletingChild(); seedStopRequest("stopped", 10 * 60_000);
963
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
964
+ expect(r.ok).toBe(false);
965
+ expect(r.error).toBe("node_already_deleting");
966
+ });
967
+
968
+ test("force + pending 晾超 5 分钟:放行,生成新 request_id", async () => {
969
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
970
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
971
+ expect(r.ok).toBe(true);
972
+ expect((r.request_id as string).startsWith("sr_")).toBe(true);
973
+ });
974
+
975
+ test("force + dispatched 晾超 5 分钟:同样放行", async () => {
976
+ seedDeletingChild(); seedStopRequest("dispatched", 10 * 60_000);
977
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
978
+ expect(r.ok).toBe(true);
979
+ });
980
+ });
981
+
982
+ // ── #2022 — in-flight gate: terminal / aged rows must not pin busy ──
983
+ //
984
+ // The old gate was COUNT(*) FROM inbox WHERE session_name=? AND acked=0:
985
+ // no join to tasks, no age limit, and nothing acks an inbox row whose
986
+ // task already died (retention sweeps only acked=1 rows), so a single
987
+ // dead row pinned the node to node_busy_in_flight forever.
988
+ //
989
+ // Rule under test (#2029 review):
990
+ // 1. type='task' rows that join to a tasks row are judged by status
991
+ // ALONE — terminal (replied / failed / cancelled / expired) is not
992
+ // counted, anything else is counted with NO age ceiling. agent-node
993
+ // acks the inbox row only when the task is done, so a 90-minute
994
+ // (or ttl_seconds>3600) run stays acked=0 the whole time and must
995
+ // keep the gate closed; expiry is patrolExpiredTasks' job.
996
+ // Join key is COALESCE(task_id, id), the logical id cancel_task
997
+ // uses.
998
+ // 2. rows with no status of their own — task rows whose tasks row is
999
+ // gone, and non-task rows (reply / broadcast / message) — get the
1000
+ // 60-minute age ceiling; nothing else will ever retire them.
1001
+ // 3. patrolExpiredTasks acks the matching inbox rows in the same
1002
+ // transaction that expires the task.
1003
+
1004
+ function seedTaskWithInbox(
1005
+ net: string,
1006
+ alias: string,
1007
+ opts: {
1008
+ task_id: string;
1009
+ status: string;
1010
+ /** signed datetime offset for the tasks row, e.g. '-3 days' */
1011
+ task_age?: string;
1012
+ /** signed datetime offset for the inbox row, e.g. '-3 days' */
1013
+ inbox_age?: string;
1014
+ /** signed datetime offset for tasks.expires_at, e.g. '+1 hour' */
1015
+ expires_in?: string;
1016
+ },
1017
+ ) {
1018
+ db.run(
1019
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
1020
+ created_at, delivered_at, expires_at, network_id)
1021
+ VALUES (?1, 'caller', ?2, 'normal', ?3, 'do the thing', 'reply',
1022
+ datetime('now', ?4), datetime('now', ?4), datetime('now', ?5), ?6)`,
1023
+ [opts.task_id, alias, opts.status, opts.task_age ?? "0 seconds", opts.expires_in ?? "+1 hour", net],
1024
+ );
1025
+ db.run(
1026
+ `INSERT INTO inbox (id, task_id, session_name, type, priority, content, from_session, network_id, acked, created_at)
1027
+ VALUES (?1, ?1, ?2, 'task', 'normal', 'do the thing', 'caller', ?3, 0, datetime('now', ?4))`,
1028
+ [opts.task_id, alias, net, opts.inbox_age ?? "0 seconds"],
1029
+ );
1030
+ }
1031
+
1032
+ function stopArgs() {
1033
+ return { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A };
1034
+ }
1035
+
1036
+ describe("#2022 stop gate — terminal task rows are not in flight", () => {
1037
+ test("28-day-old expired + 3-day-old replied rows → stop dispatches, in_flight=0", async () => {
1038
+ setupAlphaNetwork();
1039
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1040
+ task_id: "task_2022_expired", status: "expired",
1041
+ task_age: "-28 days", inbox_age: "-28 days", expires_in: "-27 days",
1042
+ });
1043
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1044
+ task_id: "task_2022_replied", status: "replied",
1045
+ task_age: "-3 days", inbox_age: "-3 days", expires_in: "-2 days",
1046
+ });
1047
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1048
+ expect(r.ok).toBe(true);
1049
+ expect(r.in_flight_at_dispatch).toBe(0);
1050
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
1051
+ });
1052
+
1053
+ test("FRESH terminal row (replied, 0s old) also not counted — terminal filter alone is enough", async () => {
1054
+ setupAlphaNetwork();
1055
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_fresh_replied", status: "replied" });
1056
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1057
+ expect(r.ok).toBe(true);
1058
+ expect(r.in_flight_at_dispatch).toBe(0);
1059
+ });
1060
+ });
1061
+
1062
+ describe("#2022 stop gate — genuinely in-flight rows still block", () => {
1063
+ test("fresh non-terminal task row → node_busy_in_flight, count surfaced, state untouched", async () => {
1064
+ setupAlphaNetwork();
1065
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_live", status: "delivered" });
1066
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1067
+ expect(r.ok).toBe(false);
1068
+ expect(r.error).toBe("node_busy_in_flight");
1069
+ expect(r.in_flight_count).toBe(1);
1070
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
1071
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
1072
+ });
1073
+
1074
+ test("fresh non-task inbox row (type='reply') still counts — judged by age only", async () => {
1075
+ setupAlphaNetwork();
1076
+ db.run(
1077
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
1078
+ VALUES (?1, ?2, 'reply', 'fresh reply', 'caller', ?3, 0, datetime('now'))`,
1079
+ [`inbox_2022_fresh_reply`, CHILD_A_ALIAS, NET_A],
1080
+ );
1081
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1082
+ expect(r.ok).toBe(false);
1083
+ expect(r.error).toBe("node_busy_in_flight");
1084
+ expect(r.in_flight_count).toBe(1);
1085
+ });
1086
+ });
1087
+
1088
+ describe("#2022 stop gate — a live task is never aged out (status wins over age)", () => {
1089
+ test("🔴 created 2 hours ago + task.status='running' → still node_busy_in_flight", async () => {
1090
+ setupAlphaNetwork();
1091
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1092
+ task_id: "task_2022_running_90m", status: "running",
1093
+ task_age: "-120 minutes", inbox_age: "-120 minutes", expires_in: "-60 minutes",
1094
+ });
1095
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1096
+ expect(r.ok).toBe(false);
1097
+ expect(r.error).toBe("node_busy_in_flight");
1098
+ expect(r.in_flight_count).toBe(1);
1099
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
1100
+ });
1101
+
1102
+ test("task.status='acked' 2 hours old (patrol never expires it) → still blocked", async () => {
1103
+ setupAlphaNetwork();
1104
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1105
+ task_id: "task_2022_acked_120m", status: "acked",
1106
+ inbox_age: "-120 minutes", expires_in: "+12 hours",
1107
+ });
1108
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1109
+ expect(r.error).toBe("node_busy_in_flight");
1110
+ expect(r.in_flight_count).toBe(1);
1111
+ });
1112
+ });
1113
+
1114
+ describe("#2022 stop gate — age ceiling (60 min) for rows with no status", () => {
1115
+ test("orphan task row (tasks row reaped) 8 days old → not counted", async () => {
1116
+ setupAlphaNetwork();
1117
+ db.run(
1118
+ `INSERT INTO inbox (id, task_id, session_name, type, content, from_session, network_id, acked, created_at)
1119
+ VALUES (?1, ?1, ?2, 'task', 'orphan task', 'caller', ?3, 0, datetime('now', '-8 days'))`,
1120
+ [`task_2022_reaped`, CHILD_A_ALIAS, NET_A],
1121
+ );
1122
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1123
+ expect(r.ok).toBe(true);
1124
+ expect(r.in_flight_at_dispatch).toBe(0);
1125
+ });
1126
+
1127
+ test("non-task row older than the ceiling → not counted (the production backlog shape)", async () => {
1128
+ setupAlphaNetwork();
1129
+ db.run(
1130
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
1131
+ VALUES (?1, ?2, 'broadcast', 'week-old broadcast', 'caller', ?3, 0, datetime('now', '-7 days'))`,
1132
+ [`inbox_2022_old_broadcast`, CHILD_A_ALIAS, NET_A],
1133
+ );
1134
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1135
+ expect(r.ok).toBe(true);
1136
+ expect(r.in_flight_at_dispatch).toBe(0);
1137
+ });
1138
+ });
1139
+
1140
+ describe("#2022 patrolExpiredTasks — expires the task AND acks its inbox rows", () => {
1141
+ test("same transaction: tasks.status='expired' + inbox.acked=1 → gate unblocks", async () => {
1142
+ setupAlphaNetwork();
1143
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1144
+ task_id: "task_2022_due", status: "delivered",
1145
+ inbox_age: "-10 minutes", expires_in: "-1 minute",
1146
+ });
1147
+
1148
+ // Before the patrol: fresh, non-terminal → the node is correctly busy.
1149
+ const before = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1150
+ expect(before.error).toBe("node_busy_in_flight");
1151
+ expect(before.in_flight_count).toBe(1);
1152
+
1153
+ patrolExpiredTasks();
1154
+
1155
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", "task_2022_due")?.status).toBe("expired");
1156
+ const inboxRow = db.get<{ acked: number }>(
1157
+ "SELECT acked FROM inbox WHERE task_id = ?1 AND session_name = ?2", "task_2022_due", CHILD_A_ALIAS,
1158
+ );
1159
+ expect(inboxRow?.acked).toBe(1);
1160
+
1161
+ // And the gate that was blocked a moment ago now dispatches.
1162
+ const after = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1163
+ expect(after.ok).toBe(true);
1164
+ expect(after.in_flight_at_dispatch).toBe(0);
1165
+ });
1166
+
1167
+ test("legacy inbox row keyed by id (task_id IS NULL) is acked too", async () => {
1168
+ setupAlphaNetwork();
1169
+ db.run(
1170
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
1171
+ created_at, expires_at, network_id)
1172
+ VALUES ('task_2022_legacy', 'caller', ?1, 'normal', 'delivered', 'do the thing', 'reply',
1173
+ datetime('now', '-10 minutes'), datetime('now', '-1 minute'), ?2)`,
1174
+ [CHILD_A_ALIAS, NET_A],
1175
+ );
1176
+ // Pre-task_id shape: the transport row IS the logical id.
1177
+ db.run(
1178
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
1179
+ VALUES ('task_2022_legacy', ?1, 'task', 'do the thing', 'caller', ?2, 0, datetime('now', '-10 minutes'))`,
1180
+ [CHILD_A_ALIAS, NET_A],
1181
+ );
1182
+
1183
+ patrolExpiredTasks();
1184
+
1185
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = 'task_2022_legacy'")?.status).toBe("expired");
1186
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = 'task_2022_legacy'")?.acked).toBe(1);
1187
+ });
1188
+ });