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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,287 @@
1
+ // #758 —— 孤儿任务:acked / running 超过阈值,接收节点在开工之后回到 idle / offline 却没回复。
2
+ // HTTP 集成测试(真实 Bun.serve,私有端口,临时库);test2123 在真实 PostgreSQL 上原样再跑一遍。
3
+ //
4
+ // 任务经真入口 POST /api/task 派出,目标用自己的节点令牌经 /mcp ack_inbox / report_status 推进;
5
+ // 只把任务的时间戳往前拨,然后调巡检(server.ts patrolOrphanTasks,5 分钟定时器调的就是它)。
6
+ //
7
+ // 钉住:
8
+ // 1. 孤儿(agent 派的)→ 记一条 task.orphan_suspected、发一条 inbox reply 给派活节点,任务状态不变;第二次巡检不重发。
9
+ // 2. 人派的孤儿 → user_inbox 一条。
10
+ // 3. 节点 working → 不标。
11
+ // 4. 不到阈值 → 不标;阈值可由 COMMHUB_ORPHAN_TASK_MINUTES 改,0 = 关。
12
+ // 5. 节点开工之后没再报过状态 → 不标。
13
+ // 6. scheduler 派的孤儿 → 标,但不通知。
14
+ // 7. TTL 巡检(patrolExpiredTasks)不碰孤儿阶段:不写 orphan 事件。
15
+ //
16
+ // 跑法:cd server && bun test src/task-orphan-http.test.ts
17
+
18
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const DIR = mkdtempSync(join(tmpdir(), "anet-orphan-"));
24
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
25
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
26
+ process.env.HOST = "127.0.0.1";
27
+ const ENV = "COMMHUB_ORPHAN_TASK_MINUTES";
28
+ const LOOKBACK_ENV = "COMMHUB_ORPHAN_TASK_LOOKBACK_HOURS";
29
+ delete process.env[ENV];
30
+ delete process.env[LOOKBACK_ENV];
31
+ const PW = "Orphan123!xyz";
32
+ const stamp = Date.now();
33
+
34
+ let db: any;
35
+ let patrol: () => void;
36
+ let ttlPatrol: () => void;
37
+ let flag: (o?: { minutes?: number | null }) => Array<{ task_id: string; notified: string | null }>;
38
+ let hub: any = null;
39
+ let BASE = "";
40
+ let NET = "";
41
+ let umaToken = "", umaId = "";
42
+ let EVENT = "";
43
+ type Node = { alias: string; nodeId: string; token: string };
44
+ const nodes: Record<"sender" | "target", Node> = {} as any;
45
+
46
+ async function send(token: string, method: string, path: string, payload?: unknown) {
47
+ const res = await fetch(`${BASE}${path}`, {
48
+ method,
49
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
50
+ body: payload === undefined ? undefined : JSON.stringify(payload),
51
+ });
52
+ return { status: res.status, body: await res.json().catch(() => null) as any };
53
+ }
54
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
55
+ const res = await fetch(`${BASE}/mcp`, {
56
+ method: "POST",
57
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
58
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
59
+ });
60
+ const raw = await res.text();
61
+ const data = raw.split("\n").filter((x) => x.startsWith("data:"));
62
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
63
+ return JSON.parse(payload.result.content[0].text);
64
+ }
65
+
66
+ let seq = 0;
67
+ async function dispatch(token: string): Promise<{ id: string; content: string }> {
68
+ const content = `orphan case ${stamp} #${++seq}`;
69
+ const r = await send(token, "POST", "/api/task", { alias: nodes.target.alias, task: content, network_id: NET });
70
+ expect(r.status).toBe(200);
71
+ expect(r.body.ok).toBe(true);
72
+ return { id: r.body.task_id, content };
73
+ }
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
+ async function targetStatus(status: string, task?: string) {
80
+ const r = await tool(nodes.target.token, "report_status", { resume_id: `sdk-${nodes.target.nodeId}`, alias: nodes.target.alias, status, task, node_id: nodes.target.nodeId, network_id: NET });
81
+ expect(r.ok).toBe(true);
82
+ }
83
+ /** 任务的时间戳拨到 `minutes` 分钟前(节点的 last_seen_at 不动 = 「之后还报过状态」)。 */
84
+ function age(taskId: string, minutes: number) {
85
+ const off = `-${minutes * 60} seconds`;
86
+ db.run(
87
+ `UPDATE tasks SET created_at = datetime('now', ?2),
88
+ delivered_at = CASE WHEN delivered_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
89
+ started_at = CASE WHEN started_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
90
+ consumed_at = CASE WHEN consumed_at IS NULL THEN NULL ELSE datetime('now', ?2) END
91
+ WHERE task_id = ?1`,
92
+ [taskId, off],
93
+ );
94
+ }
95
+ 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 };
96
+ const orphanEvents = (taskId: string) => db.all("SELECT from_status, to_status, event_type, event_key, actor, detail FROM task_events WHERE task_id = ?1 AND event_type = ?2", taskId, EVENT) as any[];
97
+ const agentNotices = () => db.all("SELECT in_reply_to, type, requires_response, content FROM inbox WHERE network_id = ?1 AND session_name = ?2 AND from_session = 'hub'", NET, nodes.sender.alias) as any[];
98
+ const userNotices = () => db.all("SELECT kind, from_session, content FROM user_inbox WHERE network_id = ?1 AND user_id = ?2", NET, umaId) as any[];
99
+
100
+ function withMinutes<T>(value: string | undefined, fn: () => T): T {
101
+ const prev = process.env[ENV];
102
+ if (value === undefined) delete process.env[ENV]; else process.env[ENV] = value;
103
+ try { return fn(); } finally { if (prev === undefined) delete process.env[ENV]; else process.env[ENV] = prev; }
104
+ }
105
+
106
+ beforeAll(async () => {
107
+ ({ db } = await import("./db.js"));
108
+ const { addNetworkMember, createNetworkTokenForNode, register } = await import("./auth.js");
109
+ const orphan: any = await import("./task-orphan.js");
110
+ EVENT = orphan.ORPHAN_EVENT_TYPE;
111
+ flag = orphan.flagOrphanTasks;
112
+ const boss = register(`orphan_boss_${stamp}`, PW);
113
+ expect(boss.ok).toBe(true);
114
+ NET = boss.network_id!;
115
+ const bossId = boss.user!.user_id;
116
+ const uma = register(`orphan_uma_${stamp}`, PW);
117
+ umaToken = uma.token!; umaId = uma.user!.user_id;
118
+ expect(addNetworkMember(NET, umaId, "member", bossId, { agentAccess: "all" }).ok).toBe(true);
119
+ for (const key of ["sender", "target"] as const) {
120
+ const alias = `orphan-${key}-${stamp}`;
121
+ const nodeId = `n_orphan_${key}_${stamp}`;
122
+ const minted = createNetworkTokenForNode(bossId, NET, alias, nodeId);
123
+ expect(minted.ok).toBe(true);
124
+ nodes[key] = { alias, nodeId, token: minted.token! };
125
+ }
126
+ const mod: any = await import("./server.js");
127
+ patrol = mod.patrolOrphanTasks;
128
+ ttlPatrol = mod.patrolExpiredTasks;
129
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
130
+ BASE = `http://127.0.0.1:${hub.port}`;
131
+ for (const n of Object.values(nodes)) {
132
+ 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 });
133
+ expect(r.ok).toBe(true);
134
+ }
135
+ }, 30_000);
136
+
137
+ beforeEach(() => {
138
+ db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE network_id = ?1 AND status IN ('created', 'delivered', 'acked', 'running')", [NET]);
139
+ db.run("DELETE FROM inbox WHERE network_id = ?1 AND from_session = 'hub'", [NET]);
140
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1", [NET]);
141
+ // 上一条用例可能把目标留在 offline / working:派活前先让它回到 idle(否则 /api/task 回 202 排队)。
142
+ db.run("UPDATE sessions SET status = 'idle' WHERE alias = ?1 AND network_id = ?2", [nodes.target.alias, NET]);
143
+ });
144
+
145
+ afterAll(() => {
146
+ try { hub?.stop?.(true); } catch {}
147
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
148
+ });
149
+
150
+ describe("#758 orphan detected", () => {
151
+ test("acked 2h, node idle since → one event + one inbox reply to the sending agent; status unchanged; second pass silent", async () => {
152
+ const t = await dispatch(nodes.sender.token);
153
+ await ack(t.id);
154
+ age(t.id, 120);
155
+ await targetStatus("idle");
156
+
157
+ withMinutes(undefined, () => patrol());
158
+
159
+ expect(taskRow(t.id)).toMatchObject({ status: "acked", result: null, completed_at: null });
160
+ const ev = orphanEvents(t.id);
161
+ expect(ev.length).toBe(1);
162
+ expect(ev[0]).toMatchObject({ from_status: "acked", to_status: "acked", event_key: EVENT, actor: "patrol" });
163
+ expect(ev[0].detail).toContain(`notify: agent ${nodes.sender.alias}`);
164
+ const n = agentNotices();
165
+ expect(n.length).toBe(1);
166
+ expect(n[0]).toMatchObject({ in_reply_to: t.id, type: "reply", requires_response: "none" });
167
+ expect(n[0].content).toContain(nodes.target.alias);
168
+ expect(n[0].content).toContain("没有改这条任务的状态");
169
+
170
+ // 第二、三次巡检:不重发、不重记。
171
+ withMinutes(undefined, () => { patrol(); patrol(); });
172
+ expect(orphanEvents(t.id).length).toBe(1);
173
+ expect(agentNotices().length).toBe(1);
174
+ expect(taskRow(t.id).status).toBe("acked");
175
+ });
176
+
177
+ test("running task, node went offline afterwards → flagged; a human sender gets one user_inbox notice", async () => {
178
+ const t = await dispatch(umaToken);
179
+ await ack(t.id);
180
+ await targetStatus("working", t.content);
181
+ expect(taskRow(t.id).status).toBe("running");
182
+ age(t.id, 90);
183
+ await targetStatus("idle");
184
+ db.run("UPDATE sessions SET status = 'offline' WHERE alias = ?1 AND network_id = ?2", [nodes.target.alias, NET]);
185
+
186
+ const out = withMinutes(undefined, () => flag());
187
+ expect(out).toEqual([{ task_id: t.id, notified: "user" }]);
188
+ expect(taskRow(t.id).status).toBe("running");
189
+ const u = userNotices();
190
+ expect(u.length).toBe(1);
191
+ expect(u[0]).toMatchObject({ kind: "task_orphan_suspected", from_session: nodes.target.alias });
192
+ expect(withMinutes(undefined, () => flag())).toEqual([]);
193
+ expect(userNotices().length).toBe(1);
194
+ });
195
+ });
196
+
197
+ describe("#758 not orphans", () => {
198
+ test("node is working (busy) → not flagged", async () => {
199
+ const t = await dispatch(nodes.sender.token);
200
+ await ack(t.id);
201
+ age(t.id, 120);
202
+ await targetStatus("working", "something else");
203
+ withMinutes(undefined, () => patrol());
204
+ expect(orphanEvents(t.id).length).toBe(0);
205
+ expect(agentNotices().length).toBe(0);
206
+ });
207
+
208
+ test("younger than the threshold → not flagged; env lowers it; 0 turns it off", async () => {
209
+ const t = await dispatch(nodes.sender.token);
210
+ await ack(t.id);
211
+ age(t.id, 45);
212
+ await targetStatus("idle");
213
+ withMinutes(undefined, () => patrol());
214
+ expect(orphanEvents(t.id).length).toBe(0);
215
+ withMinutes("0", () => patrol());
216
+ expect(orphanEvents(t.id).length).toBe(0);
217
+ withMinutes("30", () => patrol());
218
+ expect(orphanEvents(t.id).length).toBe(1);
219
+ expect(agentNotices().length).toBe(1);
220
+ });
221
+
222
+ test("node has not reported any status since the task started → not flagged", async () => {
223
+ const t = await dispatch(nodes.sender.token);
224
+ await ack(t.id);
225
+ age(t.id, 120);
226
+ await targetStatus("idle");
227
+ db.run("UPDATE sessions SET last_seen_at = datetime('now', '-180 minutes'), updated_at = datetime('now', '-180 minutes') WHERE alias = ?1 AND network_id = ?2", [nodes.target.alias, NET]);
228
+ withMinutes(undefined, () => patrol());
229
+ expect(orphanEvents(t.id).length).toBe(0);
230
+ expect(agentNotices().length).toBe(0);
231
+ });
232
+ });
233
+
234
+ describe("#758 lookback cap", () => {
235
+ test("a 10-day-old orphan is not flagged by default (72h cap) but is with lookback 0", async () => {
236
+ // 10 天前的那条直接写库(目标上已有开着的任务时 /api/task 回 202 排队,而这里要两条同时开着)。
237
+ const t = { id: `orphan_old_${stamp}` };
238
+ db.run(
239
+ `INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, priority, status, content, requires_response, created_at, delivered_at, network_id)
240
+ VALUES (?1, ?2, ?3, ?4, ?5, 'normal', 'acked', 'ten days old', 'reply', datetime('now', '-240 hours'), datetime('now', '-240 hours'), ?6)`,
241
+ [t.id, nodes.sender.alias, nodes.sender.nodeId, nodes.target.alias, nodes.target.nodeId, NET],
242
+ );
243
+ await targetStatus("idle");
244
+ withMinutes(undefined, () => patrol());
245
+ expect(orphanEvents(t.id).length).toBe(0);
246
+ expect(agentNotices().length).toBe(0);
247
+ // 71 小时的照常标出:上限只挡更老的。
248
+ const recent = await dispatch(nodes.sender.token);
249
+ await ack(recent.id);
250
+ age(recent.id, 71 * 60);
251
+ await targetStatus("idle");
252
+ withMinutes(undefined, () => patrol());
253
+ expect(orphanEvents(recent.id).length).toBe(1);
254
+ expect(orphanEvents(t.id).length).toBe(0);
255
+ process.env[LOOKBACK_ENV] = "0";
256
+ try { withMinutes(undefined, () => patrol()); } finally { delete process.env[LOOKBACK_ENV]; }
257
+ expect(orphanEvents(t.id).length).toBe(1);
258
+ expect(agentNotices().map((n) => n.in_reply_to).sort()).toEqual([recent.id, t.id].sort());
259
+ });
260
+ });
261
+
262
+ describe("#758 scheduler and regression", () => {
263
+ test("scheduler-sent orphan → flagged, sender not notified", async () => {
264
+ const id = `orphan_sched_${stamp}`;
265
+ db.run(
266
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, delivered_at, network_id)
267
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'acked', 'scheduled run', 'reply', datetime('now', '-120 minutes'), datetime('now', '-120 minutes'), ?4)`,
268
+ [id, nodes.target.alias, nodes.target.nodeId, NET],
269
+ );
270
+ await targetStatus("idle");
271
+ const out = withMinutes(undefined, () => flag());
272
+ expect(out).toEqual([{ task_id: id, notified: null }]);
273
+ expect(orphanEvents(id)[0].detail).toContain("notify: none (scheduler)");
274
+ expect(agentNotices().length).toBe(0);
275
+ expect(userNotices().length).toBe(0);
276
+ });
277
+
278
+ test("the TTL patrol alone does not run the orphan phase", async () => {
279
+ const t = await dispatch(nodes.sender.token);
280
+ await ack(t.id);
281
+ age(t.id, 120);
282
+ await targetStatus("idle");
283
+ withMinutes(undefined, () => ttlPatrol());
284
+ expect(orphanEvents(t.id).length).toBe(0);
285
+ expect(taskRow(t.id).status).toBe("acked");
286
+ });
287
+ });
@@ -0,0 +1,119 @@
1
+ // #758 —— 「孤儿任务」:任务还停在 acked / running,接收节点却已经回到 idle(或 offline)、
2
+ // 并且在任务开工之后还报过状态 —— 节点转去干别的了,没回这一条。
3
+ //
4
+ // 生产只读实测:17 条任务 acked / running 超过 1 小时,接收节点早已 idle;最长一条(admin 的 P0)挂了 15.7 小时。
5
+ // 以前巡检只对 delivered 记警告(task-lifecycle-watcher.ts),acked / running 这一段谁都看不见。
6
+ //
7
+ // 规则(每次 5 分钟任务巡检跑一遍,server.ts runTaskPatrol):
8
+ // status ∈ {acked, running}
9
+ // ∧ 开工时刻 = COALESCE(started_at, delivered_at, created_at) 早于 COMMHUB_ORPHAN_TASK_MINUTES(默认 60;0 = 关)
10
+ // ∧ 开工时刻在最近 COMMHUB_ORPHAN_TASK_LOOKBACK_HOURS 内(默认 72;0 = 不设上限)—— 首次部署不爆发历史通知
11
+ // ∧ 接收节点(sessions 同网络同别名)当前 status ∈ {idle, offline}
12
+ // ∧ 节点最后一次报状态 COALESCE(last_seen_at, updated_at) 晚于开工时刻
13
+ // 命中后对每条任务**只做一次**:
14
+ // - task_events 写一行 event_type = event_key = task.orphan_suspected(唯一索引 (task_id, event_key) 兜底,
15
+ // ON CONFLICT DO NOTHING;只有真插进去的那一次才往下走)。
16
+ // - 通知派活的一方,与过期通知同一条路(task-expiry-notice.ts 的 classifyExpirySender / deliver):
17
+ // agent → inbox reply + new_reply;人 → user_inbox;scheduler / hub / api → 不发。
18
+ // - **不改任务状态**:不 fail、不关、不碰过期语义。
19
+
20
+ import { db } from "./db.js";
21
+ import { parseDbTimestampMs } from "./db-timestamp.js";
22
+ import { classifyExpirySender, deliver, type ExpiredTaskRow } from "./task-expiry-notice.js";
23
+
24
+ export const ORPHAN_TASK_ENV = "COMMHUB_ORPHAN_TASK_MINUTES";
25
+ export const ORPHAN_DEFAULT_MINUTES = 60;
26
+ export const ORPHAN_LOOKBACK_ENV = "COMMHUB_ORPHAN_TASK_LOOKBACK_HOURS";
27
+ export const ORPHAN_DEFAULT_LOOKBACK_HOURS = 72;
28
+ export const ORPHAN_EVENT_TYPE = "task.orphan_suspected";
29
+ export const ORPHAN_NOTICE_KIND = "task_orphan_suspected";
30
+ const BATCH_LIMIT = 200;
31
+
32
+ /** 未设 / 非数字 → 默认 60;0 或负数 → 关闭(null)。 */
33
+ export function orphanTaskMinutes(env: Record<string, string | undefined> = process.env): number | null {
34
+ const raw = (env[ORPHAN_TASK_ENV] ?? "").trim();
35
+ if (!raw) return ORPHAN_DEFAULT_MINUTES;
36
+ const n = Number(raw);
37
+ if (!Number.isFinite(n)) return ORPHAN_DEFAULT_MINUTES;
38
+ return n > 0 ? n : null;
39
+ }
40
+
41
+ /** 只看开工时刻在最近 N 小时内的任务:未设 / 非数字 / 负数 → 默认 72;0 → 不设上限。
42
+ * 首次部署时不把几周前的历史 acked / running 一次性全标出来、给派活方(包括 owner)刷一屏通知。 */
43
+ export function orphanLookbackHours(env: Record<string, string | undefined> = process.env): number | null {
44
+ const raw = (env[ORPHAN_LOOKBACK_ENV] ?? "").trim();
45
+ const n = raw ? Number(raw) : NaN;
46
+ if (!Number.isFinite(n) || n < 0) return ORPHAN_DEFAULT_LOOKBACK_HOURS;
47
+ return n === 0 ? null : n;
48
+ }
49
+
50
+ type OrphanRow = ExpiredTaskRow & { status: string; started: string; node_status: string; node_seen: string };
51
+
52
+ export type OrphanFlagged = { task_id: string; notified: "agent" | "user" | null };
53
+
54
+ export function orphanNoticeText(r: Pick<OrphanRow, "task_id" | "to_name" | "status" | "content" | "node_status">, openMin: number | null): { title: string; text: string } {
55
+ const preview = (r.content ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
56
+ const m = openMin === null ? "?" : String(openMin);
57
+ return {
58
+ title: "任务可能没人收尾",
59
+ text: `[Hub] 任务可能没人收尾:你派给 ${r.to_name} 的任务 ${r.task_id} 停在 ${r.status} 已 ${m} 分钟,但 ${r.to_name} 之后已回到 ${r.node_status},一直没有回复。`
60
+ + `Hub 没有改这条任务的状态。\n内容:${preview}\n`
61
+ + `建议:问 ${r.to_name} 一句,或 cancel_task 后换个节点重派。\n`
62
+ + `Task ${r.task_id} to ${r.to_name} has been ${r.status} for ${m} min while ${r.to_name} went ${r.node_status} without replying. Its status was not changed.`,
63
+ };
64
+ }
65
+
66
+ /** 一次巡检的孤儿阶段。返回本次新标记的任务(测试 / 日志用)。调用方负责吞异常。 */
67
+ export function flagOrphanTasks(opts: { minutes?: number | null; lookbackHours?: number | null; nowMs?: number } = {}): OrphanFlagged[] {
68
+ const minutes = opts.minutes === undefined ? orphanTaskMinutes() : opts.minutes;
69
+ if (!minutes || minutes <= 0) return [];
70
+ const cutoff = `datetime('now', '-${Math.max(1, Math.round(minutes * 60))} seconds')`;
71
+ const started = "COALESCE(t.started_at, t.delivered_at, t.created_at)";
72
+ const lookback = opts.lookbackHours === undefined ? orphanLookbackHours() : opts.lookbackHours;
73
+ const lookbackSql = lookback && lookback > 0
74
+ ? `AND ${started} >= datetime('now', '-${Math.max(1, Math.round(lookback * 3600))} seconds')`
75
+ : "";
76
+ const rows = db.all<OrphanRow>(
77
+ `SELECT t.task_id, t.network_id, t.from_name, t.from_node_id, t.to_name, t.content, t.created_at, t.expires_at,
78
+ t.parent_task_id, t.meta_json, t.status, ${started} AS started,
79
+ s.status AS node_status, COALESCE(s.last_seen_at, s.updated_at) AS node_seen
80
+ FROM tasks t
81
+ JOIN sessions s ON s.alias = t.to_name AND s.network_id = COALESCE(t.network_id, 'default')
82
+ WHERE t.status IN ('acked', 'running')
83
+ AND ${started} < ${cutoff}
84
+ ${lookbackSql}
85
+ AND s.status IN ('idle', 'offline')
86
+ AND COALESCE(s.last_seen_at, s.updated_at) > ${started}
87
+ AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = t.task_id AND e.event_key = '${ORPHAN_EVENT_TYPE}')
88
+ ORDER BY t.created_at ASC LIMIT ${BATCH_LIMIT}`,
89
+ );
90
+ const nowMs = opts.nowMs ?? Date.now();
91
+ const flagged: OrphanFlagged[] = [];
92
+ for (const r of rows) {
93
+ const route = classifyExpirySender(r);
94
+ const who = route.kind === "none" ? `none (${route.reason})` : `${route.kind} ${route.kind === "agent" ? route.alias : route.username}`;
95
+ const detail = `${r.to_name} is ${r.node_status} (last status ${r.node_seen}) since ${r.status} at ${r.started}; notify: ${who}`;
96
+ const res = db.run(
97
+ `INSERT INTO task_events (task_id, from_status, to_status, event_type, event_key, actor, detail, network_id)
98
+ VALUES (?1, ?2, ?2, '${ORPHAN_EVENT_TYPE}', '${ORPHAN_EVENT_TYPE}', 'patrol', ?3, ?4)
99
+ ON CONFLICT(task_id, event_key) DO NOTHING`,
100
+ [r.task_id, r.status, detail, r.network_id ?? null],
101
+ );
102
+ if (res.changes < 1) continue; // 只做一次:别的巡检 / 别的 Hub worker 已经标过了
103
+ let notified: OrphanFlagged["notified"] = null;
104
+ if (route.kind !== "none") {
105
+ try {
106
+ const startedMs = parseDbTimestampMs(r.started);
107
+ const openMin = Number.isFinite(startedMs) ? Math.max(0, Math.round((nowMs - startedMs) / 60_000)) : null;
108
+ const meta = { task_orphan_suspected: { task_id: r.task_id, target: r.to_name, status: r.status, node_status: r.node_status } };
109
+ const id = deliver(route, r.network_id, r.to_name, r.task_id, orphanNoticeText(r, openMin), meta,
110
+ { kind: ORPHAN_NOTICE_KIND, status: "orphan_suspected", agentPrefix: "orph_", userPrefix: "dm_orphan_" });
111
+ if (id) notified = route.kind;
112
+ } catch (e: any) {
113
+ console.error(`[patrol] orphan notice failed task=${r.task_id}: ${e?.message || e}`);
114
+ }
115
+ }
116
+ flagged.push({ task_id: r.task_id, notified });
117
+ }
118
+ return flagged;
119
+ }