@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.140

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 (341) 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-department-http.test.ts +148 -0
  16. package/src/agent-groups-perf.test.ts +223 -0
  17. package/src/agent-notice.ts +48 -0
  18. package/src/agent-team-whoami-http.test.ts +98 -0
  19. package/src/agent-teams-http.test.ts +323 -0
  20. package/src/agent-teams.ts +244 -0
  21. package/src/alias-filter.test.ts +72 -0
  22. package/src/alias-filter.ts +42 -0
  23. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  24. package/src/api-host-supervisors-fallback.test.ts +271 -0
  25. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  26. package/src/api-nodes-shape.test.ts +26 -10
  27. package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
  28. package/src/auth-sessions-http.test.ts +250 -0
  29. package/src/auth-validate.test.ts +5 -1
  30. package/src/auth-weak-password-login.test.ts +176 -0
  31. package/src/auth.ts +604 -51
  32. package/src/avatar-validate.test.ts +163 -0
  33. package/src/avatar-validate.ts +103 -0
  34. package/src/binding-generation-http.test.ts +121 -0
  35. package/src/blocked-exit-on-reply.test.ts +122 -0
  36. package/src/boot-side-effect.test.ts +124 -0
  37. package/src/chain-attempt-dedup.test.ts +447 -0
  38. package/src/clone-node.test.ts +248 -0
  39. package/src/clone-node.ts +289 -0
  40. package/src/codex-config-flags.ts +77 -0
  41. package/src/codex-fork-contract.ts +23 -0
  42. package/src/codex-fork-migration.test.ts +38 -0
  43. package/src/commhub-bin-args.test.ts +85 -0
  44. package/src/config-apply-sec1.test.ts +107 -0
  45. package/src/config-apply-validate.test.ts +134 -1
  46. package/src/config-apply-validate.ts +111 -5
  47. package/src/create-node-blocked-gate.test.ts +356 -0
  48. package/src/create-node-network-id.test.ts +79 -0
  49. package/src/create-node-tool-schema.test.ts +49 -0
  50. package/src/create-node-unicode-name.test.ts +120 -0
  51. package/src/create-node-validate.test.ts +199 -7
  52. package/src/create-node-validate.ts +232 -15
  53. package/src/create-node-workdir.test.ts +289 -0
  54. package/src/create-node.ts +88 -23
  55. package/src/daemon-provider-snapshots.test.ts +421 -0
  56. package/src/daemon-provider-snapshots.ts +327 -0
  57. package/src/daemon-token-owner-http.test.ts +236 -0
  58. package/src/dashboard-slash-routing-http.test.ts +109 -0
  59. package/src/db-adapter-guard.test.ts +148 -0
  60. package/src/db-adapter-translate.test.ts +128 -0
  61. package/src/db-adapter.ts +323 -87
  62. package/src/db-timestamp.test.ts +38 -0
  63. package/src/db-timestamp.ts +15 -0
  64. package/src/db.ts +1111 -42
  65. package/src/delivered-without-push.test.ts +140 -0
  66. package/src/department-groups-http.test.ts +209 -0
  67. package/src/department-groups-sync-http.test.ts +328 -0
  68. package/src/department-groups.ts +434 -0
  69. package/src/department-heads-http.test.ts +330 -0
  70. package/src/department-heads.ts +226 -0
  71. package/src/departments-http.test.ts +185 -0
  72. package/src/departments.ts +276 -0
  73. package/src/desktop-message-seam.test.ts +150 -0
  74. package/src/desktop-message.test.ts +368 -0
  75. package/src/dm-file-scope-http.test.ts +178 -0
  76. package/src/env-transport-gate-http.test.ts +146 -0
  77. package/src/env-transport-gate.ts +74 -0
  78. package/src/env-transport-policy.ts +21 -0
  79. package/src/ext-token-shared.test.ts +60 -0
  80. package/src/ext-validation-regression.test.ts +131 -0
  81. package/src/external-schedule-edits-http.test.ts +321 -0
  82. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  83. package/src/external-schedule-edits.ts +327 -0
  84. package/src/file-download-authz-dev-open.test.ts +135 -0
  85. package/src/file-download-authz.test.ts +404 -0
  86. package/src/file-download-date-bucket.test.ts +436 -0
  87. package/src/file-network-scope-dev-open.test.ts +97 -0
  88. package/src/file-network-scope.test.ts +732 -0
  89. package/src/group-membership-events-http.test.ts +264 -0
  90. package/src/group-messages-http.test.ts +482 -0
  91. package/src/group-messages.ts +297 -0
  92. package/src/health-redaction.test.ts +642 -0
  93. package/src/host-supervisor-identity-http.test.ts +152 -0
  94. package/src/host-supervisor-identity.ts +92 -0
  95. package/src/http-gzip.test.ts +94 -0
  96. package/src/http-gzip.ts +112 -0
  97. package/src/hub-catalog-read-http.test.ts +187 -0
  98. package/src/hub-provider-config.test.ts +139 -0
  99. package/src/hub-provider-config.ts +105 -0
  100. package/src/hub-timestamp.test.ts +38 -0
  101. package/src/hub-timestamp.ts +27 -0
  102. package/src/human-dm.ts +215 -0
  103. package/src/human-inbox.ts +42 -0
  104. package/src/inbox-caller.ts +49 -0
  105. package/src/inbox-count-sse.test.ts +310 -0
  106. package/src/inbox-count.ts +29 -0
  107. package/src/index.ts +15 -2362
  108. package/src/lifecycle-guard.ts +1 -1
  109. package/src/list-host-supervisors.test.ts +5 -4
  110. package/src/mcp-context-cost-http.test.ts +123 -0
  111. package/src/mcp-projects-events-http.test.ts +162 -0
  112. package/src/mcp-route-label-http.test.ts +120 -0
  113. package/src/mcp-write-network-resolution.test.ts +432 -0
  114. package/src/member-audit-network-id-http.test.ts +93 -0
  115. package/src/member-presence-http.test.ts +210 -0
  116. package/src/messages-ack-by-agent-http.test.ts +142 -0
  117. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  118. package/src/migrate-sqlite-to-pg.ts +263 -0
  119. package/src/model-auth-notify-http.test.ts +181 -0
  120. package/src/model-auth-notify.ts +116 -0
  121. package/src/network-name-http.test.ts +75 -0
  122. package/src/network-scope.ts +276 -0
  123. package/src/network-token-membership-http.test.ts +139 -0
  124. package/src/node-alias-control.ts +12 -0
  125. package/src/node-attrs-validate.ts +88 -0
  126. package/src/node-attrs.test.ts +217 -0
  127. package/src/node-caller-identity-http.test.ts +1083 -0
  128. package/src/node-create-request-status-http.test.ts +105 -0
  129. package/src/node-daemon-bindings.test.ts +132 -0
  130. package/src/node-daemon-bindings.ts +135 -0
  131. package/src/node-files-transport.test.ts +344 -0
  132. package/src/node-health-codex-login.test.ts +120 -0
  133. package/src/node-health-dispatch-http.test.ts +279 -0
  134. package/src/node-health-guard.ts +105 -0
  135. package/src/node-health-passthrough.test.ts +115 -0
  136. package/src/node-health-store.ts +101 -0
  137. package/src/node-id-alias.test.ts +190 -0
  138. package/src/node-identity-conflict-http.test.ts +269 -0
  139. package/src/node-identity-conflict.ts +142 -0
  140. package/src/node-identity-mismatch.test.ts +74 -0
  141. package/src/node-lifecycle-controllable.test.ts +64 -0
  142. package/src/node-lifecycle-controllable.ts +53 -0
  143. package/src/node-lifecycle-read-http.test.ts +245 -0
  144. package/src/node-lifecycle-read.ts +121 -0
  145. package/src/node-logs-transport.test.ts +263 -0
  146. package/src/node-permissions-http.test.ts +276 -0
  147. package/src/node-permissions.ts +285 -0
  148. package/src/node-request-retention.test.ts +281 -0
  149. package/src/node-request-retention.ts +120 -0
  150. package/src/node-secret-rest-http.test.ts +190 -0
  151. package/src/node-skills-transport.test.ts +209 -0
  152. package/src/node-token-identity.ts +35 -0
  153. package/src/node-token-ownership.ts +51 -0
  154. package/src/node-token-privilege-http.test.ts +158 -0
  155. package/src/observer-avatar-http.test.ts +442 -0
  156. package/src/observer-push.test.ts +252 -0
  157. package/src/opencode-v2-catalog.test.ts +268 -0
  158. package/src/opencode-v2-catalog.ts +352 -0
  159. package/src/package-version-consistency.test.ts +25 -0
  160. package/src/password-network-tokens.ts +25 -0
  161. package/src/peer-reply-atomic.test.ts +455 -0
  162. package/src/peer-reply-capability.test.ts +70 -0
  163. package/src/pg-worker.ts +87 -0
  164. package/src/project-dir-warn-clear-http.test.ts +132 -0
  165. package/src/project-viewer-can-http.test.ts +114 -0
  166. package/src/push.ts +429 -10
  167. package/src/redact-tokens.test.ts +65 -0
  168. package/src/redact-tokens.ts +40 -0
  169. package/src/rename.ts +38 -10
  170. package/src/reply-target-derivation.test.ts +96 -0
  171. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  172. package/src/report-status-host-schema.test.ts +235 -0
  173. package/src/report-status-resume-id-handover.test.ts +184 -0
  174. package/src/report-status-secret-keys.test.ts +140 -0
  175. package/src/require-explicit-test-db.ts +9 -0
  176. package/src/requirement-due-reminders-http.test.ts +620 -0
  177. package/src/requirement-due-reminders.ts +416 -0
  178. package/src/requirement-events-http.test.ts +268 -0
  179. package/src/requirement-events.test.ts +45 -0
  180. package/src/requirement-events.ts +144 -0
  181. package/src/requirement-notify.ts +165 -0
  182. package/src/requirement-participant-notify-http.test.ts +309 -0
  183. package/src/requirement-tag-ops-http.test.ts +266 -0
  184. package/src/requirement-tags.ts +89 -0
  185. package/src/requirements-abandoned-http.test.ts +236 -0
  186. package/src/requirements-comment-http.test.ts +167 -0
  187. package/src/requirements-completed-at-migration.test.ts +70 -0
  188. package/src/requirements-errors-mcp-http.test.ts +164 -0
  189. package/src/requirements-errors.ts +143 -0
  190. package/src/requirements-etag-http.test.ts +91 -0
  191. package/src/requirements-http.test.ts +709 -0
  192. package/src/requirements-last-event-http.test.ts +349 -0
  193. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  194. package/src/requirements-list-slim-http.test.ts +319 -0
  195. package/src/requirements-mcp.test.ts +163 -0
  196. package/src/requirements-migrate.ts +259 -0
  197. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  198. package/src/requirements-people-http.test.ts +114 -0
  199. package/src/requirements-people-mcp-http.test.ts +225 -0
  200. package/src/requirements-people.ts +131 -0
  201. package/src/requirements-priority-lowest.test.ts +114 -0
  202. package/src/requirements-search-http.test.ts +182 -0
  203. package/src/requirements-search.test.ts +32 -0
  204. package/src/requirements-search.ts +145 -0
  205. package/src/requirements-seq-http.test.ts +193 -0
  206. package/src/requirements-seq-migration.test.ts +63 -0
  207. package/src/requirements-start-date.test.ts +125 -0
  208. package/src/requirements-stats-http.test.ts +357 -0
  209. package/src/requirements-stats.ts +195 -0
  210. package/src/requirements-status-participants-http.test.ts +222 -0
  211. package/src/requirements-upgrade-e2e.test.ts +126 -0
  212. package/src/requirements-upgrade-migration.test.ts +98 -0
  213. package/src/requirements.ts +1806 -0
  214. package/src/resolve-port.test.ts +57 -0
  215. package/src/resolve-port.ts +44 -0
  216. package/src/response-charset.test.ts +130 -0
  217. package/src/rest-explicit-columns-http.test.ts +222 -0
  218. package/src/rest-identity.test.ts +130 -0
  219. package/src/rest-identity.ts +141 -0
  220. package/src/rest-projections.ts +70 -0
  221. package/src/rest-write-network-resolution.test.ts +133 -0
  222. package/src/restricted-files.ts +76 -0
  223. package/src/retention.ts +15 -3
  224. package/src/route-callers-http.test.ts +120 -0
  225. package/src/route-timing.test.ts +211 -0
  226. package/src/route-timing.ts +258 -0
  227. package/src/rules-file-duplicate-alias.test.ts +234 -0
  228. package/src/rules-file-session-target.test.ts +207 -0
  229. package/src/rules-file-transport.test.ts +237 -0
  230. package/src/runtime-label.test.ts +22 -0
  231. package/src/runtime-label.ts +21 -0
  232. package/src/runtime-readiness.ts +90 -0
  233. package/src/schedule-agent-mcp-http.test.ts +381 -0
  234. package/src/schedule-agent.ts +192 -0
  235. package/src/scheduled-backoff-http.test.ts +193 -0
  236. package/src/scheduled-backoff.ts +64 -0
  237. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  238. package/src/scheduled-failures-http.test.ts +233 -0
  239. package/src/scheduled-failures.ts +179 -0
  240. package/src/scheduled-offline.test.ts +131 -0
  241. package/src/scheduled-offline.ts +66 -0
  242. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  243. package/src/scheduled-outcome-guidance.ts +9 -0
  244. package/src/scheduled-reply-unread-http.test.ts +212 -0
  245. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  246. package/src/scheduled-requirement-wake.ts +125 -0
  247. package/src/scheduled-run-terminal.test.ts +268 -0
  248. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  249. package/src/scheduled-stuck-http.test.ts +312 -0
  250. package/src/scheduled-stuck.ts +186 -0
  251. package/src/scheduled-tasks-http.test.ts +638 -0
  252. package/src/scheduled-tasks.ts +819 -0
  253. package/src/scheduled-work-outcome-http.test.ts +154 -0
  254. package/src/scheduled-work-outcome.ts +50 -0
  255. package/src/scoped-secrets.ts +139 -0
  256. package/src/send-dedup-request-id.test.ts +229 -0
  257. package/src/send-reply-agent-warning.test.ts +202 -0
  258. package/src/send-reply-alias-optional.test.ts +131 -0
  259. package/src/send-reply-attachments.test.ts +458 -0
  260. package/src/send_dedup.test.ts +54 -0
  261. package/src/send_dedup.ts +52 -6
  262. package/src/serve-error.test.ts +38 -0
  263. package/src/serve-error.ts +27 -0
  264. package/src/server.ts +5398 -0
  265. package/src/session-task-on-dispatch.ts +14 -0
  266. package/src/session-token-ownership-http.test.ts +122 -0
  267. package/src/shared/codex-inventory.ts +41 -0
  268. package/src/shared/daemon-provider-login.ts +31 -0
  269. package/src/shared/daemon-provider-snapshot.ts +52 -0
  270. package/src/shared/external-schedule-contract.ts +60 -0
  271. package/src/shared/node-name.test.ts +62 -0
  272. package/src/shared/node-name.ts +161 -0
  273. package/src/shared/provider-application.ts +29 -0
  274. package/src/shared/provider-collection.ts +14 -0
  275. package/src/shared/provider-initialization.ts +8 -0
  276. package/src/shared/reserved-env-drift.test.ts +10 -0
  277. package/src/shared/scoped-secret-contract.ts +5 -0
  278. package/src/shared/task-content-limit-drift.test.ts +28 -0
  279. package/src/shared/task-content-limit.ts +25 -0
  280. package/src/side-thread-command-transport.test.ts +312 -0
  281. package/src/side-thread-command-transport.ts +318 -0
  282. package/src/side-thread-http-integration.test.ts +233 -0
  283. package/src/side-thread-http.ts +504 -0
  284. package/src/side-thread-production.test.ts +72 -0
  285. package/src/side-thread-production.ts +118 -0
  286. package/src/side-thread.test.ts +1067 -0
  287. package/src/side-thread.ts +2499 -0
  288. package/src/skillhub-http.test.ts +108 -0
  289. package/src/sqlite-close-checkpoint.test.ts +78 -0
  290. package/src/start-node.test.ts +264 -0
  291. package/src/status-alias-filter-http.test.ts +106 -0
  292. package/src/status-alias-resolver-http.test.ts +163 -0
  293. package/src/status-alias-resolver.ts +19 -0
  294. package/src/status-node-id-filter-http.test.ts +161 -0
  295. package/src/status-read-cache-http.test.ts +229 -0
  296. package/src/status-read-cache.ts +122 -0
  297. package/src/stop-delete-node.test.ts +509 -2
  298. package/src/task-access-http.test.ts +402 -0
  299. package/src/task-access.ts +420 -0
  300. package/src/task-activity.ts +60 -0
  301. package/src/task-auth-origin-http.test.ts +93 -0
  302. package/src/task-auth-origin.test.ts +24 -0
  303. package/src/task-auth-origin.ts +7 -0
  304. package/src/task-consumption.test.ts +482 -0
  305. package/src/task-diagnostic-http.test.ts +149 -0
  306. package/src/task-diagnostic.test.ts +115 -0
  307. package/src/task-diagnostic.ts +118 -0
  308. package/src/task-expiry-notice-http.test.ts +287 -0
  309. package/src/task-expiry-notice.ts +315 -0
  310. package/src/task-idempotency-mcp.test.ts +142 -0
  311. package/src/task-idempotency.test.ts +60 -0
  312. package/src/task-idempotency.ts +75 -0
  313. package/src/task-late-replies.ts +54 -0
  314. package/src/task-late-reply-http.test.ts +152 -0
  315. package/src/task-lifecycle-watcher.test.ts +204 -0
  316. package/src/task-lifecycle-watcher.ts +49 -0
  317. package/src/task-network-resolution-http.test.ts +140 -0
  318. package/src/task-orphan-http.test.ts +287 -0
  319. package/src/task-orphan.ts +119 -0
  320. package/src/task-queue-ahead-http.test.ts +309 -0
  321. package/src/task-queue-ahead-scale.test.ts +236 -0
  322. package/src/task-queue-ahead.ts +186 -0
  323. package/src/task-rejection-log-http.test.ts +160 -0
  324. package/src/task-rejection-log.ts +49 -0
  325. package/src/task-sender-http.test.ts +143 -0
  326. package/src/task-stale-open-http.test.ts +290 -0
  327. package/src/task-stale-open.ts +103 -0
  328. package/src/tasks-pagination-http.test.ts +141 -0
  329. package/src/tool-audience-http.test.ts +220 -0
  330. package/src/tool-audience.ts +118 -0
  331. package/src/tools-list-json-schema.test.ts +67 -0
  332. package/src/tools.ts +4008 -355
  333. package/src/upload-early-reject-guard.test.ts +155 -0
  334. package/src/uploads-http.test.ts +97 -15
  335. package/src/uploads.ts +103 -6
  336. package/src/user-avatar-http.test.ts +74 -0
  337. package/src/user-avatar-migration.test.ts +25 -0
  338. package/src/user-inbox-by-agent-http.test.ts +161 -0
  339. package/src/user-inbox-netid-notnull.test.ts +122 -0
  340. package/src/user-inbox-read-http.test.ts +223 -0
  341. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,312 @@
1
+ // #464 —— 定时任务被一次没结束的执行卡住:告警 + 超时放行。
2
+ //
3
+ // 钉住:
4
+ // 1. 默认值:超时 = clamp(6 × 间隔, 1h, 24h);环境变量能改。
5
+ // 2. 同一个挡路任务连续挡掉 3 次 → 给排程创建者发恰好 1 条通知(别的成员 0 条);第 4、5 次不再发;
6
+ // 挡路任务一换(下一次真派出去了)重新上膛。
7
+ // 3. 挡路任务开着超过超时 → 它变成 expired(行还在、收件箱行 acked、run 记 expired/task_expired、
8
+ // error_message 说明是调度器超时),这一次照常派发。超时前已发过「卡住」→ 不再发;没发过 → 发一条「已超时」。
9
+ // 4. 晚到的回复:send_reply 以 reply_task_terminal 拒掉,旧 run / 旧任务 / 新任务 / 新 run 都不变,创建者不多收东西。
10
+ // 5. GET /runs 带 blocked_by_task_id / blocked_by_state。
11
+ //
12
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/scheduled-stuck-http.test.ts
13
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
14
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
17
+ import { db } from "./db.js";
18
+ import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
19
+ import { blockerTimedOut, SCHEDULE_STUCK_NOTICE_KIND, stuckTimeoutMs } from "./scheduled-stuck.js";
20
+ import { registerTools } from "./tools.js";
21
+
22
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
23
+ if (!activeDbPath) throw new Error("scheduled-stuck requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
24
+
25
+ const stamp = Date.now();
26
+ const ALIAS = `stuck-node-${stamp}`;
27
+ const NODE_ID = `n_stuck_${stamp}`;
28
+ let NET = "", creatorId = "", otherId = "";
29
+ const ENV_KEYS = ["COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC"];
30
+
31
+ function seedSchedule(name = "日报汇总"): any {
32
+ const scheduleId = `sched_${crypto.randomUUID()}`;
33
+ db.run(
34
+ `INSERT INTO scheduled_tasks
35
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority,
36
+ schedule_type, schedule_json, timezone, status, next_run_at)
37
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled task', 'normal', 'interval',
38
+ '{"type":"interval","every_seconds":60}', 'Asia/Shanghai', 'active', datetime('now', '+1 minute'))`,
39
+ [scheduleId, NET, creatorId, name, NODE_ID, ALIAS],
40
+ );
41
+ return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
42
+ }
43
+
44
+ let slot = 0;
45
+ const nextSlot = () => new Date(Date.UTC(2026, 9, 2, 0, 0, 0) + (++slot) * 60_000).toISOString();
46
+ const fire = (schedule: any, now = new Date()) => dispatchScheduledOccurrence(schedule, nextSlot(), false, now);
47
+
48
+ const notices = (userId: string) => db.all<{ title: string; content: string; meta_json: string; from_session: string }>(
49
+ "SELECT title, content, meta_json, from_session FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
50
+ userId, NET, SCHEDULE_STUCK_NOTICE_KIND,
51
+ );
52
+ const runRow = (runId: string) => db.get<any>("SELECT * FROM scheduled_task_runs WHERE run_id = ?1", runId);
53
+ const taskRow = (taskId: string) => db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", taskId);
54
+ /** 把任务的开始时间往前拨(数据库里的 UTC 无时区串)。 */
55
+ const backdate = (taskId: string, ms: number) => db.run("UPDATE tasks SET created_at = ?1 WHERE task_id = ?2", [new Date(Date.now() - ms).toISOString().slice(0, 19).replace("T", " "), taskId]);
56
+
57
+ async function nodeReply(args: Record<string, unknown>): Promise<any> {
58
+ const mcp = new McpServer({ name: "sched-stuck", version: "0" }) as any;
59
+ const handlers: Record<string, (a: any) => Promise<any>> = {};
60
+ const original = mcp.tool.bind(mcp);
61
+ mcp.tool = (name: string, ...rest: any[]) => {
62
+ const handler = rest.at(-1);
63
+ if (typeof handler === "function") handlers[name] = handler;
64
+ return original(name, ...rest);
65
+ };
66
+ registerTools(mcp, undefined, NET, creatorId, ALIAS, true, null);
67
+ const out = await handlers.send_reply(args);
68
+ return JSON.parse(out.content[0].text);
69
+ }
70
+
71
+ beforeAll(() => {
72
+ const creator = register(`stuck_creator_${stamp}`, "StuckCreator123!", undefined, "seed");
73
+ expect(creator.ok).toBe(true);
74
+ NET = creator.network_id!; creatorId = creator.user!.user_id;
75
+ const other = register(`stuck_other_${stamp}`, "StuckOther123!", undefined, "seed");
76
+ otherId = other.user!.user_id;
77
+ expect(addNetworkMember(NET, otherId, "member", creatorId, { agentAccess: "all" }).ok).toBe(true);
78
+ expect(createNetworkTokenForNode(creatorId, NET, ALIAS).ok).toBe(true);
79
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)", [NODE_ID, ALIAS, NET]);
80
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_stuck_${stamp}`, ALIAS, NODE_ID, NET]);
81
+ });
82
+ beforeEach(() => {
83
+ for (const k of ENV_KEYS) delete process.env[k];
84
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_STUCK_NOTICE_KIND]);
85
+ // 上一个用例留下的开着的任务不许挡这一个(每个用例自己的排程,只按 schedule 查,保险起见也关掉)
86
+ db.run("UPDATE tasks SET status = 'replied' WHERE network_id = ?1 AND status IN ('created', 'delivered', 'acked', 'running')", [NET]);
87
+ });
88
+ afterEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
89
+ afterAll(() => { db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_STUCK_NOTICE_KIND]); });
90
+
91
+ describe("#464 defaults", () => {
92
+ test("timeout = clamp(6 × interval, 1h, 24h)", () => {
93
+ const iv = (s: number) => JSON.stringify({ type: "interval", every_seconds: s });
94
+ expect(stuckTimeoutMs(iv(60))).toBe(3_600_000);
95
+ expect(stuckTimeoutMs(iv(600))).toBe(3_600_000);
96
+ expect(stuckTimeoutMs(iv(1800))).toBe(3 * 3_600_000);
97
+ expect(stuckTimeoutMs(iv(3600))).toBe(6 * 3_600_000);
98
+ expect(stuckTimeoutMs(iv(86_400))).toBe(24 * 3_600_000);
99
+ expect(stuckTimeoutMs(JSON.stringify({ type: "daily", time: "09:00" }))).toBe(24 * 3_600_000);
100
+ expect(stuckTimeoutMs(JSON.stringify({ type: "once", run_at: "2026-10-02T00:00:00Z" }))).toBe(3_600_000);
101
+ process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR = "0";
102
+ process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC = "20";
103
+ expect(stuckTimeoutMs(iv(60))).toBe(20_000);
104
+ process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC = "junk";
105
+ expect(stuckTimeoutMs(iv(60))).toBe(3_600_000);
106
+ });
107
+ });
108
+
109
+ describe("#464 stuck notice", () => {
110
+ test("3rd consecutive skip behind the same task → exactly one notice to the creator, none to others", () => {
111
+ const s = seedSchedule();
112
+ const first = fire(s);
113
+ expect(first.status).toBe("delivered");
114
+ const skips = [fire(s), fire(s)];
115
+ expect(notices(creatorId).length).toBe(0);
116
+ skips.push(fire(s), fire(s), fire(s));
117
+ expect(skips.map((r) => r.status)).toEqual(["skipped", "skipped", "skipped", "skipped", "skipped"]);
118
+ const mine = notices(creatorId);
119
+ expect(mine.length).toBe(1);
120
+ expect(notices(otherId).length).toBe(0);
121
+ const n = mine[0];
122
+ expect(n.from_session).toBe(ALIAS);
123
+ expect(n.title).toBe("定时任务被卡住");
124
+ for (const frag of ["日报汇总", first.taskId!, ALIAS, "(Asia/Shanghai)", "3 次", "1 小时"]) expect(n.content).toContain(frag);
125
+ expect(JSON.parse(n.meta_json).schedule_stuck).toEqual({ reason: "stuck", schedule_id: s.schedule_id, blocked_by_task_id: first.taskId, skips: 3 });
126
+ });
127
+
128
+ test("re-armed once a run is dispatched again: a second episode notifies again", () => {
129
+ const s = seedSchedule();
130
+ const first = fire(s);
131
+ for (let i = 0; i < 4; i++) fire(s);
132
+ db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE task_id = ?1", [first.taskId]);
133
+ const second = fire(s);
134
+ expect(second.status).toBe("delivered");
135
+ for (let i = 0; i < 3; i++) fire(s);
136
+ const mine = notices(creatorId);
137
+ expect(mine.length).toBe(2);
138
+ // 两条通知同一秒写入,created_at 排不出先后:按挡路任务比集合。
139
+ expect(mine.map((m) => JSON.parse(m.meta_json).schedule_stuck.blocked_by_task_id).sort()).toEqual([first.taskId, second.taskId].sort());
140
+ });
141
+
142
+ test("COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS changes N", () => {
143
+ process.env.COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS = "1";
144
+ const s = seedSchedule();
145
+ fire(s); fire(s);
146
+ expect(notices(creatorId).length).toBe(1);
147
+ });
148
+
149
+ test("schedule without a creator → no notice, skips still recorded", () => {
150
+ const s = seedSchedule();
151
+ db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [s.schedule_id]);
152
+ const row = db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", s.schedule_id);
153
+ fire(row); for (let i = 0; i < 4; i++) expect(fire(row).status).toBe("skipped");
154
+ expect(notices(creatorId).length).toBe(0);
155
+ });
156
+ });
157
+
158
+ describe("#464 stuck timeout", () => {
159
+ test("blocker past the timeout is expired (row kept) and this occurrence dispatches; no second notice", () => {
160
+ const s = seedSchedule();
161
+ const first = fire(s);
162
+ db.run("UPDATE tasks SET status = 'running' WHERE task_id = ?1", [first.taskId]);
163
+ for (let i = 0; i < 3; i++) fire(s);
164
+ expect(notices(creatorId).length).toBe(1);
165
+
166
+ backdate(first.taskId!, 59 * 60_000);
167
+ expect(fire(s).status).toBe("skipped"); // 59 分钟 < 1 小时
168
+ backdate(first.taskId!, 61 * 60_000);
169
+ const next = fire(s);
170
+ expect(next.status).toBe("delivered");
171
+ expect(next.taskId).not.toBe(first.taskId);
172
+
173
+ const old = taskRow(first.taskId!);
174
+ expect(old.status).toBe("expired");
175
+ expect(old.content).toBe("scheduled task");
176
+ expect(db.get<any>("SELECT acked FROM inbox WHERE task_id = ?1", first.taskId).acked).toBeTruthy();
177
+ const oldRun = runRow(first.runId);
178
+ expect(oldRun.status).toBe("expired");
179
+ expect(oldRun.error_code).toBe("task_expired");
180
+ expect(oldRun.error_message).toContain("timed out by the scheduler");
181
+ expect(db.get<any>("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?1 AND to_status = 'expired' AND actor = 'hub-scheduler'", first.taskId).n).toBe(1);
182
+ expect(notices(creatorId).length).toBe(1); // 每段卡住恰好一条
183
+ });
184
+
185
+ test("timed out before N skips (long interval) → one 'timed out' notice instead", () => {
186
+ const s = seedSchedule("每日巡检");
187
+ const first = fire(s);
188
+ expect(fire(s).status).toBe("skipped");
189
+ backdate(first.taskId!, 2 * 3_600_000);
190
+ expect(fire(s).status).toBe("delivered");
191
+ const mine = notices(creatorId);
192
+ expect(mine.length).toBe(1);
193
+ expect(mine[0].title).toBe("定时任务已超时放行");
194
+ expect(mine[0].content).toContain(first.taskId!);
195
+ expect(JSON.parse(mine[0].meta_json).schedule_stuck.reason).toBe("timed_out");
196
+ });
197
+
198
+ test("a late reply to the expired task is refused and changes nothing", async () => {
199
+ const s = seedSchedule();
200
+ const first = fire(s);
201
+ backdate(first.taskId!, 2 * 3_600_000);
202
+ const next = fire(s);
203
+ expect(next.status).toBe("delivered");
204
+ const inboxBefore = db.get<any>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", NET).n;
205
+ const out = await nodeReply({ alias: "scheduler", text: "late answer", in_reply_to: first.taskId, status: "replied", from_session: ALIAS });
206
+ expect(out.ok).toBe(false);
207
+ expect(out.error).toBe("reply_task_terminal");
208
+ expect(out.reply_queued).toBe(false);
209
+ expect(taskRow(first.taskId!).status).toBe("expired");
210
+ expect(runRow(first.runId).status).toBe("expired");
211
+ expect(taskRow(next.taskId!).status).toBe("delivered");
212
+ expect(runRow(next.runId).status).toBe("delivered");
213
+ expect(db.get<any>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", NET).n).toBe(inboxBefore);
214
+ // 新那条照常能被回复,run 照常收尾
215
+ const ok = await nodeReply({ alias: "scheduler", text: "on time", in_reply_to: next.taskId, status: "replied", from_session: ALIAS });
216
+ expect(ok.ok).toBe(true);
217
+ expect(runRow(next.runId).status).toBe("replied");
218
+ });
219
+ });
220
+
221
+ describe("#464 history clarity", () => {
222
+ test("GET /runs exposes blocked_by_task_id and blocked_by_state on skipped rows", async () => {
223
+ const s = seedSchedule();
224
+ const first = fire(s);
225
+ const second = fire(s);
226
+ const url = new URL(`http://hub.test/api/scheduled-tasks/${s.schedule_id}/runs`);
227
+ const res = await handleScheduledTaskRequest({
228
+ req: new Request(url, { method: "GET" }), url,
229
+ auth: { userId: creatorId, networkId: NET, username: "creator" },
230
+ isAdmin: false, isNodeToken: false, scope: { networkId: NET, networkIds: [NET] } as any,
231
+ });
232
+ const body = await res!.json() as any;
233
+ const skipped = body.runs.find((r: any) => r.run_id === second.runId);
234
+ expect(skipped.status).toBe("skipped");
235
+ expect(skipped.blocked_by_task_id).toBe(first.taskId);
236
+ expect(skipped.blocked_by_state).toBe("not_received");
237
+ });
238
+ });
239
+
240
+
241
+ describe("#1112 execution clock", () => {
242
+ const ts = (now: Date, minutes: number) => new Date(now.getTime() - minutes * 60_000).toISOString();
243
+ test("queue budget: creation over one hour but consumption under one hour keeps the exact run open", () => {
244
+ const s = seedSchedule();
245
+ const first = fire(s);
246
+ const now = new Date();
247
+ db.run("UPDATE tasks SET status = 'running', created_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 61), ts(now, 49), first.taskId]);
248
+ expect(fire(s, now).status).toBe("skipped");
249
+ expect(taskRow(first.taskId!).status).toBe("running");
250
+ expect(runRow(first.runId).status).toBe("delivered");
251
+ expect(db.get<any>("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?1 AND to_status = 'expired'", first.taskId).n).toBe(0);
252
+ db.run("UPDATE tasks SET created_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 73), ts(now, 61), first.taskId]);
253
+ const later = fire(s, now);
254
+ expect(later.status).toBe("delivered");
255
+ expect(taskRow(first.taskId!).status).toBe("expired");
256
+ expect(runRow(first.runId).error_message).toContain("since consumed_at");
257
+ });
258
+ test("queue budget: legacy execution start protects the queued task, consumption takes precedence", () => {
259
+ const s = seedSchedule(); const first = fire(s); const now = new Date();
260
+ db.run("UPDATE tasks SET status = 'running', created_at = ?1, started_at = ?2 WHERE task_id = ?3", [ts(now, 75), ts(now, 40), first.taskId]);
261
+ expect(fire(s, now).status).toBe("skipped");
262
+ db.run("UPDATE tasks SET started_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 70), ts(now, 40), first.taskId]);
263
+ expect(fire(s, now).status).toBe("skipped");
264
+ });
265
+ test("submission and ACK alone do not renew queue time", () => {
266
+ const s = seedSchedule(); const first = fire(s); const now = new Date();
267
+ db.run("UPDATE tasks SET status = 'acked', created_at = ?1, runtime_submitted_at = ?2 WHERE task_id = ?3", [ts(now, 61), ts(now, 1), first.taskId]);
268
+ expect(fire(s, now).status).toBe("delivered");
269
+ expect(runRow(first.runId).error_message).toContain("since created_at");
270
+ });
271
+ test("total cap: a very late consumption cannot keep an occurrence open beyond 24 hours", () => {
272
+ const s = seedSchedule(); const first = fire(s); const now = new Date();
273
+ db.run("UPDATE tasks SET status = 'running', created_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 1441), ts(now, 1), first.taskId]);
274
+ expect(fire(s, now).status).toBe("delivered");
275
+ expect(runRow(first.runId).status).toBe("expired");
276
+ expect(runRow(first.runId).error_message).toContain("created_at (total cap)");
277
+ expect(runRow(first.runId).error_message).toContain("limit 1440 min");
278
+ });
279
+ test("invalid, future and pre-creation evidence falls back; UTC and exact execution boundary remain stable", () => {
280
+ const now = new Date("2026-10-13T03:00:00Z");
281
+ const schedule = JSON.stringify({ type: "interval", every_seconds: 60 });
282
+ for (const consumed_at of [null, "invalid", "2026-10-14T00:00:00Z", "2026-10-12T00:00:00Z"]) {
283
+ expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at }, schedule, now)).toBe(true);
284
+ }
285
+ expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at: "2026-10-13 02:00:01" }, schedule, now)).toBe(false);
286
+ expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at: "2026-10-13 02:00:00" }, schedule, now)).toBe(true);
287
+ });
288
+ });
289
+
290
+
291
+ describe("#1113 activity idle clock",()=>{
292
+ test("fresh exact-task activity protects a long consumed turn, silent activity still expires",()=>{
293
+ const s=seedSchedule(),first=fire(s),now=new Date(),at=(m:number)=>new Date(now.getTime()-m*60_000).toISOString();
294
+ db.run("UPDATE tasks SET status='running',created_at=?1,consumed_at=?2,last_activity_at=?3 WHERE task_id=?4",[at(180),at(120),at(1),first.taskId]);
295
+ expect(fire(s,now).status).toBe("skipped");expect(taskRow(first.taskId!).status).toBe("running");
296
+ db.run("UPDATE tasks SET last_activity_at=?1 WHERE task_id=?2",[at(61),first.taskId]);expect(fire(s,now).status).toBe("delivered");
297
+ expect(runRow(first.runId).error_message).toContain("since last_activity_at");
298
+ });
299
+ test("fresh activity cannot evade the absolute total cap",()=>{
300
+ const s=seedSchedule(),first=fire(s),now=new Date(),at=(m:number)=>new Date(now.getTime()-m*60_000).toISOString();
301
+ db.run("UPDATE tasks SET status='running',created_at=?1,consumed_at=?2,last_activity_at=?3 WHERE task_id=?4",[at(1441),at(1440),at(1),first.taskId]);
302
+ expect(fire(s,now).status).toBe("delivered");expect(runRow(first.runId).error_message).toContain("total cap");
303
+ });
304
+ test("activity without consumption, before consumption or in the future never renews",()=>{
305
+ const now=new Date("2026-10-13T04:00:00Z"),schedule=JSON.stringify({type:"interval",every_seconds:60});
306
+ for(const row of [
307
+ {created_at:"2026-10-13 01:00:00",last_activity_at:"2026-10-13 03:59:00"},
308
+ {created_at:"2026-10-13 01:00:00",consumed_at:"2026-10-13 02:00:00",last_activity_at:"2026-10-13 01:59:00"},
309
+ {created_at:"2026-10-13 01:00:00",consumed_at:"2026-10-13 02:00:00",last_activity_at:"2026-10-13 04:01:00"},
310
+ ])expect(blockerTimedOut(row,schedule,now)).toBe(true);
311
+ });
312
+ });
@@ -0,0 +1,186 @@
1
+ // #464 —— 定时任务被一次没结束的执行卡住:告警 + 超时放行。
2
+ //
3
+ // 现场:overlap=skip 的排程,上一次派出的任务节点一直没有终态回复 → 之后每一次都记成
4
+ // skipped / previous_run_active,没有超时、也没人被告诉(当天 ~25 分钟,10-01 ~7 小时)。
5
+ //
6
+ // 1. 告警:同一个「卡住的任务」(blocked_by_task_id)连续挡掉 N 次 → 给排程的创建者发**一条**通知
7
+ // (agent-notice.ts,与 #462 同一条路,出现在与目标节点的会话里)。按挡路的任务计数,所以
8
+ // 一段卡住只发一次;任务一换(下一次真派出去了)自然重新上膛。不依赖内存,Hub 重启不会重发。
9
+ // 2. 超时:挡路的任务开着超过 stuckTimeoutMs(间隔)就按**已有的** expired 状态结束它(同 TTL 巡检:
10
+ // tasks.status='expired' + 收件箱行置 acked + db.ts 的镜像把这次 run 记成 expired / task_expired),
11
+ // 然后这一次照常派发。任务行不删,照样能查;晚到的回复被 send_reply 以 reply_task_terminal 拒掉,
12
+ // 什么都不改(不重开 run、不碰新派出去的那条)。
13
+ // 如果超时发生在 N 次之前(长间隔的排程,比如每天一次),那一刻补发一条「已超时结束」—— 每段卡住
14
+ // 恰好一条通知。
15
+ //
16
+ // 默认值(PR 里有推导):N=3;超时 = clamp(6 × 间隔, 下限 1 小时, 上限 24 小时)。
17
+ // #1112:从 consumed_at 起算,旧节点回落 started_at,未执行回落 created_at;
18
+ // 自创建时间的总保护上限仍为 cap,节点心跳/提交/ACK 不刷新执行时钟。
19
+ // 下限 1 小时:一轮 agent 任务正常要跑几十分钟,不能因为排程间隔短就把还在干活的任务掐掉。
20
+ // 上限 24 小时:Hub 本来就给定时任务 24 小时的 expires_at(没被取走时由巡检过期),开着超过一天的
21
+ // 定时任务不是「还在做」。6 × 间隔:间隔长的排程(每小时一次)给它 6 小时,而不是 1 小时。
22
+ // 环境变量可改(运维 / 测试):COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS、COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR、
23
+ // COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC、COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC。
24
+
25
+ import { db, syncScheduledRunForTask } from "./db.js";
26
+ import { parseDbTimestampMs } from "./db-timestamp.js";
27
+ import { sendAgentNotice } from "./agent-notice.js";
28
+
29
+ export const SCHEDULE_STUCK_NOTICE_KIND = "schedule_stuck";
30
+
31
+ function envNumber(name: string, fallback: number, min: number): number {
32
+ const raw = process.env[name];
33
+ if (raw === undefined || raw.trim() === "") return fallback;
34
+ const n = Number(raw);
35
+ return Number.isFinite(n) && n >= min ? n : fallback;
36
+ }
37
+
38
+ export function stuckNoticeSkips(): number {
39
+ return Math.floor(envNumber("COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS", 3, 1));
40
+ }
41
+
42
+ /** 排程的名义间隔(秒):interval 取 every_seconds;daily / weekly 取一天(两次之间最短一天);once 取 0。 */
43
+ export function nominalIntervalSeconds(scheduleJson: string): number {
44
+ try {
45
+ const spec = JSON.parse(scheduleJson) as { type?: string; every_seconds?: number };
46
+ if (spec.type === "interval" && Number.isFinite(spec.every_seconds)) return Number(spec.every_seconds);
47
+ if (spec.type === "daily" || spec.type === "weekly") return 86_400;
48
+ } catch {}
49
+ return 0;
50
+ }
51
+
52
+ export function stuckTimeoutMs(scheduleJson: string): number {
53
+ const factor = envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR", 6, 0);
54
+ const floor = envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", 3_600, 1);
55
+ const cap = Math.max(floor, envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC", 86_400, 1));
56
+ return Math.min(cap, Math.max(floor, factor * nominalIntervalSeconds(scheduleJson))) * 1000;
57
+ }
58
+
59
+ export type StuckBlockerClock = {
60
+ created_at: string;
61
+ consumed_at?: string | null;
62
+ last_activity_at?: string | null;
63
+ started_at?: string | null;
64
+ };
65
+
66
+ /** #1105: queue time is not execution time. Submission/ACK/node heartbeat are not consumption. */
67
+ export function blockerTimeoutClock(blocker: StuckBlockerClock, scheduleJson: string, now: Date): { ageMs: number; limitMs: number; source: string } {
68
+ const created = parseDbTimestampMs(blocker.created_at);
69
+ const nowMs = now.getTime();
70
+ const floor = envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", 3_600, 1);
71
+ const capMs = Math.max(floor, envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC", 86_400, 1)) * 1000;
72
+ // Even a very late consumption cannot hold this occurrence beyond the total cap.
73
+ if (Number.isFinite(created) && nowMs - created >= capMs) {
74
+ return { ageMs: nowMs - created, limitMs: capMs, source: "created_at (total cap)" };
75
+ }
76
+ for (const source of ["consumed_at", "started_at"] as const) {
77
+ const value = blocker[source];
78
+ if (!value) continue;
79
+ const at = parseDbTimestampMs(value);
80
+ if (Number.isFinite(at) && at <= nowMs && (!Number.isFinite(created) || at >= created)) {
81
+ if (source === "consumed_at" && blocker.last_activity_at) {
82
+ const activity = parseDbTimestampMs(blocker.last_activity_at);
83
+ if (Number.isFinite(activity) && activity >= at && activity <= nowMs) {
84
+ return { ageMs: nowMs - activity, limitMs: stuckTimeoutMs(scheduleJson), source: "last_activity_at" };
85
+ }
86
+ }
87
+ return { ageMs: nowMs - at, limitMs: stuckTimeoutMs(scheduleJson), source };
88
+ }
89
+ }
90
+ return { ageMs: nowMs - created, limitMs: stuckTimeoutMs(scheduleJson), source: "created_at" };
91
+ }
92
+
93
+ /** 挡路的任务开了多久已经到超时 → true。读不出时间 = 不超时(宁可多挡一次,不错杀)。 */
94
+ export function blockerTimedOut(blocker: StuckBlockerClock, scheduleJson: string, now: Date): boolean {
95
+ const clock = blockerTimeoutClock(blocker, scheduleJson, now);
96
+ return Number.isFinite(clock.ageMs) && clock.ageMs >= clock.limitMs;
97
+ }
98
+
99
+ /**
100
+ * 在调用方的事务里按超时结束挡路的任务。只动还开着的任务(条件写),返回是否真的结束了它。
101
+ * 与 TTL 巡检(server.ts patrolExpiredTasks)同一套写法:status=expired、收件箱行置 acked、镜像到 run。
102
+ */
103
+ export function expireStuckBlocker(taskId: string, networkId: string, ageMs: number, limitMs: number, source = "created_at"): boolean {
104
+ const changed = db.run(
105
+ `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
106
+ WHERE task_id = ?1 AND network_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
107
+ [taskId, networkId],
108
+ );
109
+ if (changed.changes < 1) return false;
110
+ db.run("UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0", [taskId]);
111
+ syncScheduledRunForTask(taskId, networkId);
112
+ db.run(
113
+ "UPDATE scheduled_task_runs SET error_message = ?1 WHERE task_id = ?2 AND network_id = ?3",
114
+ [`timed out by the scheduler: ${Math.round(ageMs / 60_000)} min since ${source} (limit ${Math.round(limitMs / 60_000)} min); the next occurrence was dispatched`, taskId, networkId],
115
+ );
116
+ return true;
117
+ }
118
+
119
+ /** 这个任务已经挡掉了几次(只算 previous_run_active 的跳过)。 */
120
+ export function skipsBlockedBy(scheduleId: string, taskId: string): number {
121
+ return db.get<{ n: number }>(
122
+ "SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1 AND blocked_by_task_id = ?2 AND error_code = 'previous_run_active'",
123
+ scheduleId, taskId,
124
+ )?.n ?? 0;
125
+ }
126
+
127
+ export type StuckNotice = {
128
+ reason: "stuck" | "timed_out";
129
+ scheduleId: string;
130
+ scheduleName: string;
131
+ networkId: string;
132
+ createdBy: string | null;
133
+ alias: string;
134
+ timezone: string;
135
+ taskId: string;
136
+ taskCreatedAt: string;
137
+ skips: number;
138
+ timeoutMs: number;
139
+ };
140
+
141
+ function fmtTime(dbTs: string, timezone: string): string {
142
+ const ms = parseDbTimestampMs(dbTs);
143
+ if (!Number.isFinite(ms)) return dbTs;
144
+ try {
145
+ const s = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(ms));
146
+ return `${s} (${timezone})`;
147
+ } catch { return dbTs; }
148
+ }
149
+
150
+ function fmtDuration(ms: number): string {
151
+ const min = Math.round(ms / 60_000);
152
+ if (min < 60) return `${min} 分钟`;
153
+ const h = Math.floor(min / 60), m = min % 60;
154
+ return m ? `${h} 小时 ${m} 分钟` : `${h} 小时`;
155
+ }
156
+
157
+ export function stuckNoticeText(n: StuckNotice): { title: string; text: string } {
158
+ const started = fmtTime(n.taskCreatedAt, n.timezone);
159
+ if (n.reason === "stuck") {
160
+ return {
161
+ title: "定时任务被卡住",
162
+ text: `定时任务「${n.scheduleName}」被上一次的执行卡住了:任务 ${n.taskId} 于 ${started} 派给节点 ${n.alias},到现在还没有完成,之后的 ${n.skips} 次执行都被跳过。\n\n`
163
+ + `距上次任务活动或实际开始执行超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束;尚未开始的任务从派发起计时,所有执行仍受总时限保护。下一次照常派发。等不及可以在任务列表里取消这条任务。`,
164
+ };
165
+ }
166
+ return {
167
+ title: "定时任务已超时放行",
168
+ text: `定时任务「${n.scheduleName}」上一次的任务 ${n.taskId}(${started} 派给节点 ${n.alias})超过 ${fmtDuration(n.timeoutMs)} 的执行或总保护时限还没有完成,已按超时结束,不再挡后面的执行。`,
169
+ };
170
+ }
171
+
172
+ /** 提交之后调用。没有创建者 / 创建者不在网络里 → 不发。 */
173
+ export function sendStuckNotice(n: StuckNotice): string | null {
174
+ if (!n.createdBy) return null;
175
+ try {
176
+ const { title, text } = stuckNoticeText(n);
177
+ return sendAgentNotice({
178
+ networkId: n.networkId, userId: n.createdBy, fromAlias: n.alias, kind: SCHEDULE_STUCK_NOTICE_KIND, title, text,
179
+ meta: { schedule_stuck: { reason: n.reason, schedule_id: n.scheduleId, blocked_by_task_id: n.taskId, skips: n.skips } },
180
+ idPrefix: "dm_sched_",
181
+ });
182
+ } catch (e: any) {
183
+ console.error(`[scheduled-tasks] stuck notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
184
+ return null;
185
+ }
186
+ }