@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,115 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { diagnoseTask, type TaskDiagnosticInput } from "./task-diagnostic";
3
+
4
+ const base = (patch: Partial<TaskDiagnosticInput> = {}): TaskDiagnosticInput => ({
5
+ status: "delivered",
6
+ runtimeSubmittedAt: null,
7
+ consumedAt: null,
8
+ targetSessionStatus: "idle",
9
+ targetSessionExists: true,
10
+ liveSseConnections: 1,
11
+ ...patch,
12
+ });
13
+
14
+ describe("#166 evidence-only task diagnostics", () => {
15
+ test("terminal state wins over stale transport and runtime evidence", () => {
16
+ expect(diagnoseTask(base({
17
+ status: "replied",
18
+ runtimeSubmittedAt: "2026-08-10T00:00:00Z",
19
+ consumedAt: "2026-08-10T00:00:01Z",
20
+ targetSessionExists: false,
21
+ targetSessionStatus: null,
22
+ liveSseConnections: 0,
23
+ }))).toMatchObject({ code: "terminal", action_hint: "none" });
24
+ });
25
+
26
+ test("runtime evidence outranks current target connectivity", () => {
27
+ expect(diagnoseTask(base({ consumedAt: "2026-08-10T00:00:01Z", liveSseConnections: 0 }))).toMatchObject({
28
+ code: "runtime_consumed_nonterminal",
29
+ action_hint: "inspect_runtime",
30
+ });
31
+ expect(diagnoseTask(base({ runtimeSubmittedAt: "2026-08-10T00:00:00Z", targetSessionStatus: "offline" }))).toMatchObject({
32
+ code: "runtime_submitted_waiting_for_signal",
33
+ action_hint: "inspect_runtime",
34
+ });
35
+ });
36
+
37
+ test("distinguishes missing, offline, and no-SSE targets", () => {
38
+ expect(diagnoseTask(base({ targetSessionExists: false, targetSessionStatus: null }))).toMatchObject({
39
+ code: "target_session_missing",
40
+ action_hint: "check_target_registration",
41
+ });
42
+ expect(diagnoseTask(base({ targetSessionStatus: "offline" }))).toMatchObject({
43
+ code: "target_session_offline",
44
+ action_hint: "restore_target",
45
+ });
46
+ expect(diagnoseTask(base({ liveSseConnections: 0 }))).toMatchObject({
47
+ code: "target_no_live_sse",
48
+ action_hint: "restore_sse",
49
+ });
50
+ });
51
+
52
+ test("does not infer consumption from lifecycle status alone", () => {
53
+ const diagnostic = diagnoseTask(base({ status: "running" }));
54
+ expect(diagnostic).toMatchObject({
55
+ code: "lifecycle_progress_without_runtime_evidence",
56
+ action_hint: "inspect_runtime",
57
+ evidence: { runtime_submitted: false, runtime_consumed: false },
58
+ });
59
+ });
60
+
61
+ test("reports delivered waiting only when registration and SSE are present", () => {
62
+ expect(diagnoseTask(base())).toEqual({
63
+ schema_version: 1,
64
+ code: "delivered_waiting_for_agent",
65
+ action_hint: "inspect_agent_queue",
66
+ evidence: {
67
+ task_status: "delivered",
68
+ target_session_status: "idle",
69
+ live_sse_connections: 1,
70
+ runtime_submitted: false,
71
+ runtime_consumed: false,
72
+ // 🔴 #1083 新增。这里保持 toEqual(整形断言)而**不**退化成 toMatchObject ——
73
+ // 隔壁那条测试的名字就是 "without widening the evidence",
74
+ // 这个严格性是故意的:它防的正是「有人往 evidence 里悄悄加字段」。
75
+ // 加宽了就把新字段写进来,不是把断言放松。
76
+ target_ever_reported_runtime_evidence: null,
77
+ },
78
+ });
79
+ });
80
+
81
+ // 🔴 #1083:【时序】与【能力】必须落在不同的 code 上。
82
+ // 实测背景:一个**确实回了长信**的任务与一个**毫无回音**的任务,
83
+ // 在旧实现下 diagnostic 逐字相同(都是 lifecycle_progress_without_runtime_evidence
84
+ // + inspect_runtime)⇒ 鉴别力 = 0。
85
+ test("a target that never reported runtime evidence gets its own code, not inspect_runtime", () => {
86
+ const d = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: false }));
87
+ expect(d.code).toBe("runtime_evidence_channel_absent");
88
+ expect(d.action_hint).toBe("wire_runtime_evidence");
89
+ expect(d.evidence.target_ever_reported_runtime_evidence).toBe(false);
90
+ });
91
+
92
+ test("a target that HAS reported evidence before keeps the timing-flavoured code", () => {
93
+ const d = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: true }));
94
+ expect(d.code).toBe("lifecycle_progress_without_runtime_evidence");
95
+ expect(d.action_hint).toBe("inspect_runtime");
96
+ });
97
+
98
+ // 「Hub 没算这一项」不等于「没报过」—— 不知道就不改判。
99
+ test("an unknown capability (null / omitted) does not change the old verdict", () => {
100
+ const omitted = diagnoseTask(base({ status: "acked" }));
101
+ const explicitNull = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: null }));
102
+ for (const d of [omitted, explicitNull]) {
103
+ expect(d.code).toBe("lifecycle_progress_without_runtime_evidence");
104
+ expect(d.action_hint).toBe("inspect_runtime");
105
+ expect(d.evidence.target_ever_reported_runtime_evidence).toBeNull();
106
+ }
107
+ });
108
+
109
+ test("normalizes invalid SSE counts without widening the evidence", () => {
110
+ expect(diagnoseTask(base({ liveSseConnections: Number.NaN }))).toMatchObject({
111
+ code: "target_no_live_sse",
112
+ evidence: { live_sse_connections: 0 },
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,118 @@
1
+ const TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
2
+
3
+ export type TaskDiagnosticCode =
4
+ | "terminal"
5
+ | "runtime_consumed_nonterminal"
6
+ | "runtime_submitted_waiting_for_signal"
7
+ | "target_session_missing"
8
+ | "target_session_offline"
9
+ | "target_no_live_sse"
10
+ | "lifecycle_progress_without_runtime_evidence"
11
+ // 🔴 #1083:与上一条的区别是【时序】vs【能力】。上一条说的是「还没走到那一步」,
12
+ // 这一条说的是「这个目标从来没有报过任何一次运行时证据」——它不会走到。
13
+ // 两者处置相反:前者【等】,后者【别等,去接线】。
14
+ | "runtime_evidence_channel_absent"
15
+ | "delivered_waiting_for_agent";
16
+
17
+ export type TaskDiagnosticAction =
18
+ | "none"
19
+ | "inspect_runtime"
20
+ | "check_target_registration"
21
+ | "restore_target"
22
+ | "restore_sse"
23
+ | "inspect_agent_queue"
24
+ | "wire_runtime_evidence";
25
+
26
+ export interface TaskDiagnosticInput {
27
+ status: string;
28
+ runtimeSubmittedAt: string | null;
29
+ consumedAt: string | null;
30
+ targetSessionStatus: string | null;
31
+ targetSessionExists: boolean;
32
+ liveSseConnections: number;
33
+ /**
34
+ * 🔴 #1083:这个目标节点【历史上是否报过任何一次】运行时证据。
35
+ * `false` = 从来没报过 ⇒ `runtime_submitted_at`/`consumed_at` 为空**推不出**「没消费」,
36
+ * 它只说明这条证据通道不存在。
37
+ * `null`/省略 = Hub 没算这一项 ⇒ 保持旧行为(不因为「不知道」就改判)。
38
+ *
39
+ * 背景:RFC-035 §75 要求 agent-node 接线上报,但 agent 侧至今没有实现
40
+ * (`grep -rn runtime_evidence agent-node/src/ agent-network/src/` = 0 命中),
41
+ * 于是这两列对所有任务恒为 NULL。实测后果:一个**确实回了长信**的任务
42
+ * 与一个**毫无回音**的任务,diagnostic 读数逐字相同 ⇒ 鉴别力 = 0。
43
+ */
44
+ targetEverReportedRuntimeEvidence?: boolean | null;
45
+ }
46
+
47
+ export interface TaskDiagnostic {
48
+ schema_version: 1;
49
+ code: TaskDiagnosticCode;
50
+ action_hint: TaskDiagnosticAction;
51
+ evidence: {
52
+ task_status: string;
53
+ target_session_status: string | null;
54
+ live_sse_connections: number;
55
+ runtime_submitted: boolean;
56
+ runtime_consumed: boolean;
57
+ /** `null` = Hub 没算这一项;`false` = 该目标从来没报过运行时证据。 */
58
+ target_ever_reported_runtime_evidence: boolean | null;
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Classify only facts the Hub can prove. This deliberately does not claim
64
+ * that a model session has (or lacks) MCP tools: that capability lives in an
65
+ * external runtime and is not observable from the Hub wire.
66
+ */
67
+ export function diagnoseTask(input: TaskDiagnosticInput): TaskDiagnostic {
68
+ const liveSseConnections = Number.isSafeInteger(input.liveSseConnections) && input.liveSseConnections > 0
69
+ ? input.liveSseConnections
70
+ : 0;
71
+ const evidence: TaskDiagnostic["evidence"] = {
72
+ task_status: input.status,
73
+ target_session_status: input.targetSessionStatus,
74
+ live_sse_connections: liveSseConnections,
75
+ runtime_submitted: Boolean(input.runtimeSubmittedAt),
76
+ runtime_consumed: Boolean(input.consumedAt),
77
+ target_ever_reported_runtime_evidence:
78
+ input.targetEverReportedRuntimeEvidence ?? null,
79
+ };
80
+
81
+ let code: TaskDiagnosticCode;
82
+ let action_hint: TaskDiagnosticAction;
83
+ if (TERMINAL_STATUSES.has(input.status)) {
84
+ code = "terminal";
85
+ action_hint = "none";
86
+ } else if (input.consumedAt) {
87
+ code = "runtime_consumed_nonterminal";
88
+ action_hint = "inspect_runtime";
89
+ } else if (input.runtimeSubmittedAt) {
90
+ code = "runtime_submitted_waiting_for_signal";
91
+ action_hint = "inspect_runtime";
92
+ } else if (!input.targetSessionExists) {
93
+ code = "target_session_missing";
94
+ action_hint = "check_target_registration";
95
+ } else if (input.targetSessionStatus === "offline") {
96
+ code = "target_session_offline";
97
+ action_hint = "restore_target";
98
+ } else if (liveSseConnections === 0) {
99
+ code = "target_no_live_sse";
100
+ action_hint = "restore_sse";
101
+ } else if (input.status === "acked" || input.status === "running") {
102
+ // 🔴 #1083:这里原本只有一档,于是【还没走到】和【不会走到】共用同一个 code
103
+ // 和同一个 action_hint(`inspect_runtime`)。后者被支去查一个没坏的东西。
104
+ // 只有在 Hub **确知**该目标从未报过证据时才改判;`null`(没算) 保持旧行为。
105
+ if (input.targetEverReportedRuntimeEvidence === false) {
106
+ code = "runtime_evidence_channel_absent";
107
+ action_hint = "wire_runtime_evidence";
108
+ } else {
109
+ code = "lifecycle_progress_without_runtime_evidence";
110
+ action_hint = "inspect_runtime";
111
+ }
112
+ } else {
113
+ code = "delivered_waiting_for_agent";
114
+ action_hint = "inspect_agent_queue";
115
+ }
116
+
117
+ return { schema_version: 1, code, action_hint, evidence };
118
+ }
@@ -0,0 +1,287 @@
1
+ // #500 —— 任务过期时告诉派活的人;节点已经取走的任务不再被过期。HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ // test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
3
+ //
4
+ // 任务一律经真入口 POST /api/task 派出(节点令牌 / 用户令牌),只把 created_at / expires_at 往前拨,
5
+ // 然后调 TTL 巡检(server.ts patrolExpiredTasks,定时器调的就是它)。收件一侧也走真入口:
6
+ // agent 用自己的节点令牌订阅 /events/<alias> 并经 /mcp get_inbox 取;人订阅 /events/users/me。
7
+ //
8
+ // 钉住:
9
+ // 1. consumed_at 早于期限 → 不过期、不通知;consumed_at 晚于期限(迟到的回执)→ 照样过期。
10
+ // 2. agent 派的任务过期 → 它收到恰好一条 type=reply / from=hub / requires_response=none 的通知 + SSE new_reply;
11
+ // 正文带目标别名、等了几分钟、前面还有几个、retry_task、「不要原样重发」。
12
+ // 3. 人派的 → user_inbox kind=task_expired 一条,from_session = 目标节点(出现在与它的会话里)+ SSE desktop_message。
13
+ // 4. scheduler 派的 → 谁都不通知。
14
+ // 5. 子任务过期 → 父任务的发送方收到一条「子任务已过期」,父任务状态 / result 不变。
15
+ // 6. 同一次巡检里同一 (发送方, 目标) 5 条过期 → 只发一条,列出 5 个 id。
16
+ //
17
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/task-expiry-notice-http.test.ts
18
+
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ const DIR = mkdtempSync(join(tmpdir(), "anet-expiry-notice-"));
25
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
26
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
27
+ process.env.HOST = "127.0.0.1";
28
+ const PW = "ExpiryNotice123!x";
29
+ const stamp = Date.now();
30
+
31
+ let db: any;
32
+ let patrol: () => void;
33
+ let KIND = "";
34
+ let hub: any = null;
35
+ let BASE = "";
36
+ let NET = "";
37
+ let bossToken = "", umaToken = "", umaId = "", umaName = "";
38
+ type Node = { alias: string; nodeId: string; token: string };
39
+ const nodes: Record<"sender" | "target" | "other", Node> = {} as any;
40
+
41
+ async function send(token: string, method: string, path: string, payload?: unknown) {
42
+ const res = await fetch(`${BASE}${path}`, {
43
+ method,
44
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
45
+ body: payload === undefined ? undefined : JSON.stringify(payload),
46
+ });
47
+ return { status: res.status, body: await res.json().catch(() => null) as any };
48
+ }
49
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
50
+ const res = await fetch(`${BASE}/mcp`, {
51
+ method: "POST",
52
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
53
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
54
+ });
55
+ const raw = await res.text();
56
+ const data = raw.split("\n").filter((x) => x.startsWith("data:"));
57
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
58
+ return JSON.parse(payload.result.content[0].text);
59
+ }
60
+
61
+ type Sub = { events: any[]; close: () => void };
62
+ async function subscribe(path: string, token: string): Promise<Sub> {
63
+ const ctrl = new AbortController();
64
+ const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${token}` }, signal: ctrl.signal });
65
+ expect(res.status).toBe(200);
66
+ const events: any[] = [];
67
+ const reader = res.body!.getReader();
68
+ const dec = new TextDecoder();
69
+ let buf = "";
70
+ void (async () => {
71
+ try {
72
+ for (;;) {
73
+ const { done, value } = await reader.read();
74
+ if (done) break;
75
+ buf += dec.decode(value);
76
+ let i: number;
77
+ while ((i = buf.indexOf("\n\n")) >= 0) {
78
+ const frame = buf.slice(0, i); buf = buf.slice(i + 2);
79
+ for (const line of frame.split("\n")) if (line.startsWith("data: ")) { try { events.push(JSON.parse(line.slice(6))); } catch {} }
80
+ }
81
+ }
82
+ } catch {}
83
+ })();
84
+ await until(() => events.some((e) => e.type === "connected"));
85
+ return { events, close: () => ctrl.abort() };
86
+ }
87
+ const until = async (cond: () => boolean, tries = 150) => { for (let k = 0; k < tries && !cond(); k++) await new Promise((r) => setTimeout(r, 20)); };
88
+
89
+ let seq = 0;
90
+ /** 经 POST /api/task 派一条;返回 task_id。 */
91
+ async function dispatch(token: string, to: string, extra: Record<string, unknown> = {}): Promise<string> {
92
+ const r = await send(token, "POST", "/api/task", { alias: to, task: `expiry case ${stamp} #${++seq}`, network_id: NET, ...extra });
93
+ expect(r.status).toBe(200);
94
+ expect(r.body.ok).toBe(true);
95
+ return r.body.task_id;
96
+ }
97
+ /** 把这条任务拨成「61 分钟前派出、1 分钟前到期」。 */
98
+ const backdate = (taskId: string) => db.run(
99
+ "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",
100
+ [taskId],
101
+ );
102
+ const taskRow = (taskId: string) => db.get("SELECT status, result FROM tasks WHERE task_id = ?1", taskId) as { status: string; result: string | null };
103
+ const agentNotices = (alias: string) => db.all(
104
+ "SELECT id, type, content, from_session, in_reply_to, requires_response, meta_json, acked FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND from_session = 'hub' AND id LIKE 'exp_%' ORDER BY created_at, id",
105
+ alias, NET,
106
+ ) as Array<{ id: string; type: string; content: string; from_session: string; in_reply_to: string; requires_response: string; meta_json: string; acked: number }>;
107
+ const userNotices = (userId: string) => db.all(
108
+ "SELECT message_id, from_session, title, content, meta_json FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
109
+ userId, NET, KIND,
110
+ ) as Array<{ message_id: string; from_session: string; title: string; content: string; meta_json: string }>;
111
+
112
+ beforeAll(async () => {
113
+ ({ db } = await import("./db.js"));
114
+ const { addNetworkMember, createNetworkTokenForNode, register } = await import("./auth.js");
115
+ ({ TASK_EXPIRED_NOTICE_KIND: KIND } = await import("./task-expiry-notice.js"));
116
+ const boss = register(`exp_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(`exp_uma_${stamp}`, PW);
121
+ umaToken = uma.token!; umaId = uma.user!.user_id; umaName = uma.user!.username;
122
+ expect(addNetworkMember(NET, umaId, "member", bossId, { agentAccess: "all" }).ok).toBe(true);
123
+ for (const key of ["sender", "target", "other"] as const) {
124
+ const alias = `exp-${key}-${stamp}`;
125
+ const nodeId = `n_exp_${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 id LIKE 'exp_%'", [NET]);
144
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, KIND]);
145
+ });
146
+
147
+ afterAll(() => {
148
+ try { hub?.stop?.(true); } catch {}
149
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
150
+ });
151
+
152
+ describe("#500 consumed before the deadline → not expired", () => {
153
+ test("consumed_at < expires_at keeps the task; a late consumed_at (after the deadline) does not", async () => {
154
+ const taken = await dispatch(nodes.sender.token, nodes.target.alias);
155
+ const late = await dispatch(nodes.sender.token, nodes.target.alias);
156
+ backdate(taken); backdate(late);
157
+ db.run("UPDATE tasks SET consumed_at = datetime('now', '-30 minutes') WHERE task_id = ?1", [taken]);
158
+ db.run("UPDATE tasks SET consumed_at = datetime('now') WHERE task_id = ?1", [late]);
159
+ patrol();
160
+ expect(taskRow(taken).status).toBe("delivered");
161
+ expect(taskRow(late).status).toBe("expired");
162
+ // 通知只说过期的那一条。
163
+ const n = agentNotices(nodes.sender.alias);
164
+ expect(n.length).toBe(1);
165
+ expect(n[0].content).toContain(late);
166
+ expect(n[0].content).not.toContain(taken);
167
+ });
168
+ });
169
+
170
+ describe("#500 sender is an agent node", () => {
171
+ test("exactly one terminal reply-row notice + new_reply push; get_inbox returns it", async () => {
172
+ const sub = await subscribe(`/events/${encodeURIComponent(nodes.sender.alias)}?network_id=${NET}`, nodes.sender.token);
173
+ try {
174
+ // 目标上有一条比它早、还开着的任务(别人派的,没到期)→ 「前面还有 1 个」。
175
+ const ahead = await dispatch(bossToken, nodes.target.alias);
176
+ db.run("UPDATE tasks SET created_at = datetime('now', '-90 minutes') WHERE task_id = ?1", [ahead]);
177
+ const id = await dispatch(nodes.sender.token, nodes.target.alias);
178
+ backdate(id);
179
+ patrol();
180
+ expect(taskRow(id).status).toBe("expired");
181
+ expect(taskRow(ahead).status).toBe("delivered");
182
+
183
+ const n = agentNotices(nodes.sender.alias);
184
+ expect(n.length).toBe(1);
185
+ expect(n[0].type).toBe("reply");
186
+ expect(n[0].from_session).toBe("hub");
187
+ expect(n[0].requires_response).toBe("none");
188
+ expect(n[0].in_reply_to).toBe(id);
189
+ for (const frag of ["[Hub] 任务已过期", nodes.target.alias, id, "等了 61 分钟", "期限 60 分钟", "还有 1 个", "retry_task", "不要原样重发", "do not blindly resend"]) {
190
+ expect(n[0].content).toContain(frag);
191
+ }
192
+ expect(JSON.parse(n[0].meta_json).task_expired).toEqual({ target: nodes.target.alias, task_ids: [id], queued_ahead: 1 });
193
+
194
+ await until(() => sub.events.some((e) => e.type === "new_reply" && e.message_id === n[0].id));
195
+ const push = sub.events.find((e) => e.type === "new_reply" && e.message_id === n[0].id);
196
+ expect(push).toMatchObject({ from: "hub", in_reply_to: id, status: "expired" });
197
+
198
+ const inbox = await tool(nodes.sender.token, "get_inbox", { alias: nodes.sender.alias, limit: 50 });
199
+ const got = JSON.stringify(inbox);
200
+ expect(got).toContain(n[0].id);
201
+ // 目标节点自己什么都没多收(过期的任务行已 acked,通知只给发送方)。
202
+ expect(agentNotices(nodes.target.alias).length).toBe(0);
203
+
204
+ // 再巡检一次:不重复发。
205
+ patrol();
206
+ expect(agentNotices(nodes.sender.alias).length).toBe(1);
207
+ } finally { sub.close(); }
208
+ });
209
+ });
210
+
211
+ describe("#500 sender is a person (Dashboard / App)", () => {
212
+ test("user_inbox task_expired from the target node + desktop_message push; no agent inbox row", async () => {
213
+ const sub = await subscribe(`/events/users/me?network_id=${NET}`, umaToken);
214
+ try {
215
+ const id = await dispatch(umaToken, nodes.target.alias);
216
+ backdate(id);
217
+ patrol();
218
+ expect(taskRow(id).status).toBe("expired");
219
+ const n = userNotices(umaId);
220
+ expect(n.length).toBe(1);
221
+ expect(n[0].from_session).toBe(nodes.target.alias);
222
+ expect(n[0].title).toBe("任务已过期");
223
+ for (const frag of [nodes.target.alias, id, "等了 61 分钟", "retry_task", "不要原样重发"]) expect(n[0].content).toContain(frag);
224
+ await until(() => sub.events.some((e) => e.type === "desktop_message" && e.message_id === n[0].message_id));
225
+ expect(sub.events.find((e) => e.type === "desktop_message" && e.message_id === n[0].message_id)).toMatchObject({ kind: KIND, from: nodes.target.alias });
226
+ // 没有往「用户名」那个 alias 的 agent inbox 里塞东西。
227
+ expect(agentNotices(umaName).length).toBe(0);
228
+ } finally { sub.close(); }
229
+ });
230
+ });
231
+
232
+ describe("#500 sender is the scheduler", () => {
233
+ test("no notice to anyone; the task still expires", async () => {
234
+ const id = `sched_task_${stamp}`;
235
+ db.run(
236
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, meta_json)
237
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'delivered', 'scheduled', 'reply', datetime('now', '-61 minutes'), datetime('now', '-61 minutes'), datetime('now', '-1 minutes'), ?4, ?5)`,
238
+ [id, nodes.target.alias, nodes.target.nodeId, NET, JSON.stringify({ auth_origin: "hub_scheduler" })],
239
+ );
240
+ patrol();
241
+ expect(taskRow(id).status).toBe("expired");
242
+ const anyAgent = db.get("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1 AND id LIKE 'exp_%'", NET) as { n: number };
243
+ expect(Number(anyAgent.n)).toBe(0);
244
+ const anyUser = db.get("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id = ?1 AND kind = ?2", NET, KIND) as { n: number };
245
+ expect(Number(anyUser.n)).toBe(0);
246
+ });
247
+ });
248
+
249
+ describe("#500 child task expires", () => {
250
+ test("parent sender is told; parent stays open with its result untouched", async () => {
251
+ const parent = await dispatch(umaToken, nodes.sender.alias);
252
+ const child = await dispatch(nodes.sender.token, nodes.target.alias, { parent_task_id: parent });
253
+ backdate(child);
254
+ patrol();
255
+ expect(taskRow(child).status).toBe("expired");
256
+ expect(taskRow(parent)).toEqual({ status: "delivered", result: null });
257
+ // 子任务的发送方(父任务的执行者)照常收到过期通知。
258
+ expect(agentNotices(nodes.sender.alias).length).toBe(1);
259
+ // 父任务的发送方(人)收到一条「子任务已过期」,出现在与父任务执行者的会话里。
260
+ const n = userNotices(umaId);
261
+ expect(n.length).toBe(1);
262
+ expect(n[0].title).toBe("子任务已过期");
263
+ expect(n[0].from_session).toBe(nodes.sender.alias);
264
+ for (const frag of [parent, child, nodes.target.alias, "父任务没有被结束"]) expect(n[0].content).toContain(frag);
265
+ });
266
+ });
267
+
268
+ describe("#500 one notice per (sender, target) per patrol pass", () => {
269
+ test("5 expiring to the same target → one summarized notice listing all 5", async () => {
270
+ const ids: string[] = [];
271
+ for (let i = 0; i < 5; i++) ids.push(await dispatch(nodes.sender.token, nodes.target.alias));
272
+ // 再来一条派给另一个目标:那是另一组,单独一条。
273
+ const elsewhere = await dispatch(nodes.sender.token, nodes.other.alias);
274
+ for (const id of [...ids, elsewhere]) backdate(id);
275
+ patrol();
276
+ for (const id of ids) expect(taskRow(id).status).toBe("expired");
277
+ const n = agentNotices(nodes.sender.alias);
278
+ expect(n.length).toBe(2);
279
+ const batch = n.find((x) => x.content.includes(nodes.target.alias))!;
280
+ expect(batch.content).toContain("[Hub] 5 个任务已过期");
281
+ for (const id of ids) expect(batch.content).toContain(id);
282
+ expect(batch.content).not.toContain(elsewhere);
283
+ expect(JSON.parse(batch.meta_json).task_expired.task_ids.sort()).toEqual([...ids].sort());
284
+ const single = n.find((x) => x.content.includes(nodes.other.alias))!;
285
+ expect(single.content).toContain(elsewhere);
286
+ });
287
+ });