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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,268 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db, chainReplyToParent } from "./db.js";
4
+ import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { patrolExpiredTasks } from "./server.js";
7
+
8
+ const NET = "net_690_scheduler_terminal";
9
+ const USER = "u_690_owner";
10
+ const NODE = "n_690_grok";
11
+ const ALIAS = "A站GrokTUI-690";
12
+
13
+ type Handler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
14
+
15
+ function cleanup(): void {
16
+ for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes", "network_members", "networks", "users"]) {
17
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
18
+ }
19
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
20
+ }
21
+
22
+ function seedIdentity(): void {
23
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [USER, USER]);
24
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
25
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
26
+ db.run(
27
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
28
+ VALUES (?1, ?2, ?2, 'grok-build-acp', ?3, 'active')`,
29
+ [NODE, ALIAS, NET],
30
+ );
31
+ db.run(
32
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
33
+ VALUES ('r_690', ?1, 'idle', ?2, ?3, datetime('now'), datetime('now'))`,
34
+ [ALIAS, NODE, NET],
35
+ );
36
+ }
37
+
38
+ function handlers(): Record<string, Handler> {
39
+ const server = new McpServer({ name: "test690", version: "0" }) as any;
40
+ const result: Record<string, Handler> = {};
41
+ const original = server.tool.bind(server);
42
+ server.tool = (name: string, ...args: any[]) => {
43
+ const handler = args.at(-1);
44
+ if (typeof handler === "function") result[name] = handler;
45
+ return original(name, ...args);
46
+ };
47
+ registerTools(server, undefined, NET, USER, USER, false, null);
48
+ return result;
49
+ }
50
+
51
+ async function call(handler: Handler, args: any): Promise<any> {
52
+ const reply = await handler(args);
53
+ return JSON.parse(reply.content[0].text);
54
+ }
55
+
56
+ function seedScheduledTask(status = "delivered", suffix = crypto.randomUUID()): { taskId: string; runId: string; scheduleId: string } {
57
+ const taskId = `task_${suffix}`;
58
+ const runId = `srun_${suffix}`;
59
+ const scheduleId = `sched_${suffix}`;
60
+ const meta = JSON.stringify({ scheduled_task_id: scheduleId, scheduled_run_id: runId, auth_origin: "hub_scheduler" });
61
+ db.run(
62
+ `INSERT INTO scheduled_tasks
63
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
64
+ schedule_type, schedule_json, timezone, status, next_run_at)
65
+ VALUES (?1, ?2, 'test690', ?3, ?4, 'test690 task', 'normal', 'interval',
66
+ '{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
67
+ [scheduleId, NET, NODE, ALIAS],
68
+ );
69
+ db.run(
70
+ `INSERT INTO tasks
71
+ (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response,
72
+ created_at, delivered_at, expires_at, network_id, meta_json)
73
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', ?4, 'test690 task', 'reply',
74
+ datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?5, ?6)`,
75
+ [taskId, NODE, ALIAS, status, NET, meta],
76
+ );
77
+ db.run(
78
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
79
+ VALUES (?1, ?1, ?2, ?3, 'task', 'normal', 'test690 task', 'scheduler', 'reply', ?4, ?5)`,
80
+ [taskId, ALIAS, NODE, NET, meta],
81
+ );
82
+ db.run(
83
+ `INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, task_id, status, completed_at)
84
+ VALUES (?1, ?2, ?3, datetime('now'), ?4, ?5, datetime('now'))`,
85
+ [runId, scheduleId, NET, taskId, status],
86
+ );
87
+ return { taskId, runId, scheduleId };
88
+ }
89
+
90
+ const readRun = (runId: string) => db.get<any>(
91
+ "SELECT run_id, task_id, network_id, status, error_code, completed_at FROM scheduled_task_runs WHERE run_id = ?1",
92
+ runId,
93
+ );
94
+
95
+ beforeEach(() => { cleanup(); seedIdentity(); });
96
+ afterAll(cleanup);
97
+
98
+ describe("#690 scheduled run follows the real task lifecycle", () => {
99
+ test("dispatch is open until the task reaches a terminal state", () => {
100
+ const scheduleId = `sched_dispatch_${crypto.randomUUID()}`;
101
+ const row: any = {
102
+ schedule_id: scheduleId,
103
+ network_id: NET,
104
+ name: "dispatch open",
105
+ target_node_id: NODE,
106
+ target_alias: ALIAS,
107
+ task_content: "dispatch open",
108
+ priority: "normal",
109
+ schedule_type: "interval",
110
+ schedule_json: JSON.stringify({ type: "interval", every_seconds: 3600 }),
111
+ timezone: "UTC",
112
+ overlap_policy: "skip",
113
+ misfire_policy: "catch_up_once",
114
+ status: "active",
115
+ next_run_at: new Date(Date.now() + 3600_000).toISOString(),
116
+ last_run_at: null,
117
+ revision: 1,
118
+ };
119
+ db.run(
120
+ `INSERT INTO scheduled_tasks
121
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
122
+ schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, status, next_run_at)
123
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)`,
124
+ [scheduleId, NET, row.name, NODE, ALIAS, row.task_content, row.priority, row.schedule_type,
125
+ row.schedule_json, row.timezone, row.overlap_policy, row.misfire_policy, row.status, row.next_run_at],
126
+ );
127
+ const dispatched = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
128
+ const run = readRun(dispatched.runId);
129
+ expect(["delivered", "queued"]).toContain(run.status);
130
+ expect(run.completed_at).toBeNull();
131
+ });
132
+
133
+ test("send_reply terminalizes the exact bound run", async () => {
134
+ const { taskId, runId } = seedScheduledTask();
135
+ const reply = await call(handlers().send_reply, {
136
+ alias: ALIAS,
137
+ text: "verified",
138
+ in_reply_to: taskId,
139
+ status: "replied",
140
+ network_id: NET,
141
+ attachments: [{ type: "file", file_id: "file_690_reply", name: "evidence.json", size: 12, mime: "application/json" }],
142
+ });
143
+ expect(reply.ok).toBe(true);
144
+ expect(readRun(runId).status).toBe("replied");
145
+ expect(readRun(runId).completed_at).toBeTruthy();
146
+ });
147
+
148
+ test("task and run terminal transition roll back together on an injected run-write failure", async () => {
149
+ const { taskId, runId } = seedScheduledTask();
150
+ db.exec(
151
+ `CREATE TRIGGER test690_fail_run_terminal
152
+ BEFORE UPDATE OF status ON scheduled_task_runs
153
+ WHEN NEW.run_id = '${runId}' AND NEW.status = 'replied'
154
+ BEGIN
155
+ SELECT RAISE(ABORT, 'test690_injected_run_failure');
156
+ END`,
157
+ );
158
+ let threw = false;
159
+ try {
160
+ await call(handlers().send_reply, {
161
+ alias: ALIAS,
162
+ text: "must roll back",
163
+ in_reply_to: taskId,
164
+ status: "replied",
165
+ network_id: NET,
166
+ });
167
+ } catch (error: any) {
168
+ threw = /test690_injected_run_failure/.test(String(error?.message || error));
169
+ } finally {
170
+ db.exec("DROP TRIGGER IF EXISTS test690_fail_run_terminal");
171
+ }
172
+ expect(threw).toBe(true);
173
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("delivered");
174
+ expect(readRun(runId).status).toBe("delivered");
175
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE in_reply_to = ?1", taskId)?.count).toBe(0);
176
+ });
177
+
178
+ test("cancel terminalizes, retry reopens, and the retry result closes the same run", async () => {
179
+ const { taskId, runId } = seedScheduledTask();
180
+ const tools = handlers();
181
+ expect((await call(tools.cancel_task, { task_id: taskId, reason: "operator", network_id: NET })).ok).toBe(true);
182
+ expect(readRun(runId).status).toBe("cancelled");
183
+ expect(readRun(runId).completed_at).toBeTruthy();
184
+
185
+ expect((await call(tools.retry_task, { task_id: taskId, network_id: NET })).ok).toBe(true);
186
+ expect(readRun(runId).status).toBe("delivered");
187
+ expect(readRun(runId).completed_at).toBeNull();
188
+
189
+ expect((await call(tools.send_reply, {
190
+ alias: ALIAS,
191
+ text: "runtime failed",
192
+ in_reply_to: taskId,
193
+ status: "failed",
194
+ network_id: NET,
195
+ })).ok).toBe(true);
196
+ expect(readRun(runId).status).toBe("failed");
197
+ expect(readRun(runId).error_code).toBe("task_failed");
198
+ });
199
+
200
+ test("report_completion terminalizes a scheduler run", async () => {
201
+ const { taskId, runId } = seedScheduledTask();
202
+ const reply = await call(handlers().report_completion, {
203
+ alias: ALIAS,
204
+ task: taskId,
205
+ result: "done",
206
+ network_id: NET,
207
+ });
208
+ expect(reply.ok).toBe(true);
209
+ expect(readRun(runId).status).toBe("replied");
210
+ });
211
+
212
+ test("expiration patrol closes the exact run in the same lifecycle pass", () => {
213
+ const { taskId, runId } = seedScheduledTask();
214
+ db.run("UPDATE tasks SET expires_at = datetime('now', '-1 minute') WHERE task_id = ?1", [taskId]);
215
+ patrolExpiredTasks();
216
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("expired");
217
+ expect(readRun(runId).status).toBe("expired");
218
+ expect(readRun(runId).error_code).toBe("task_expired");
219
+ expect(readRun(runId).completed_at).toBeTruthy();
220
+ });
221
+
222
+ test("child auto-chain terminalizes a scheduled parent", () => {
223
+ const { taskId: parentId, runId } = seedScheduledTask();
224
+ const childId = `child_${crypto.randomUUID()}`;
225
+ db.run(
226
+ `INSERT INTO tasks
227
+ (task_id, parent_task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
228
+ VALUES (?1, ?2, ?3, 'A站负责人', 'normal', 'replied', 'verify candidate', 'reply', datetime('now'), ?4)`,
229
+ [childId, parentId, ALIAS, NET],
230
+ );
231
+ const result = chainReplyToParent(childId, "news_id=14519", "replied", 5, NET);
232
+ expect(result.chained).toBe(true);
233
+ expect(readRun(runId).status).toBe("replied");
234
+ });
235
+
236
+ test("forged metadata cannot cross-bind another task or network run", async () => {
237
+ const victim = seedScheduledTask("delivered", "victim");
238
+ const attackerTaskId = "task_attacker";
239
+ const forgedMeta = JSON.stringify({ scheduled_task_id: victim.scheduleId, scheduled_run_id: victim.runId, auth_origin: "hub_scheduler" });
240
+ db.run(
241
+ `INSERT INTO tasks
242
+ (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, network_id, meta_json)
243
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'delivered', 'forged', 'reply', datetime('now'), ?4, ?5)`,
244
+ [attackerTaskId, NODE, ALIAS, NET, forgedMeta],
245
+ );
246
+ await call(handlers().send_reply, {
247
+ alias: ALIAS,
248
+ text: "forged close",
249
+ in_reply_to: attackerTaskId,
250
+ status: "replied",
251
+ network_id: NET,
252
+ });
253
+ expect(readRun(victim.runId).status).toBe("delivered");
254
+ });
255
+
256
+ test("a task cannot close a run whose persisted network binding differs", async () => {
257
+ const victim = seedScheduledTask("delivered", "foreign_network");
258
+ db.run("UPDATE scheduled_task_runs SET network_id = 'net_690_foreign' WHERE run_id = ?1", [victim.runId]);
259
+ await call(handlers().send_reply, {
260
+ alias: ALIAS,
261
+ text: "must not cross network",
262
+ in_reply_to: victim.taskId,
263
+ status: "replied",
264
+ network_id: NET,
265
+ });
266
+ expect(readRun(victim.runId).status).toBe("delivered");
267
+ });
268
+ });
@@ -0,0 +1,120 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
4
+
5
+ // A skip used to record only error_code=previous_run_active. The owner saw
6
+ // dozens of identical 「已跳过」 rows while the node sat idle, because the
7
+ // open task had never reached it. The skip record now names the blocking task
8
+ // and whether the node had picked it up.
9
+
10
+ const NET = "net_sched_skip_blocking";
11
+ const NODE = "n_sched_skip_blocking";
12
+ const ALIAS = "sched-skip-blocking";
13
+
14
+ function cleanup(): void {
15
+ for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes"]) {
16
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
17
+ }
18
+ }
19
+
20
+ function seedSchedule(): any {
21
+ const scheduleId = `sched_${crypto.randomUUID()}`;
22
+ db.run(
23
+ `INSERT INTO scheduled_tasks
24
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
25
+ schedule_type, schedule_json, timezone, status, next_run_at)
26
+ VALUES (?1, ?2, 'skip detail', ?3, ?4, 'scheduled task', 'normal', 'interval',
27
+ '{"type":"interval","every_seconds":60}', 'UTC', 'active', datetime('now', '+1 minute'))`,
28
+ [scheduleId, NET, NODE, ALIAS],
29
+ );
30
+ return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
31
+ }
32
+
33
+ function run(runId: string): any {
34
+ return db.get(
35
+ "SELECT status, error_code, error_message, blocked_by_task_id, blocked_by_state FROM scheduled_task_runs WHERE run_id = ?1",
36
+ runId,
37
+ );
38
+ }
39
+
40
+ beforeEach(() => {
41
+ cleanup();
42
+ db.run(
43
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
44
+ VALUES (?1, ?2, ?2, 'codex-sdk', ?3, 'active')`,
45
+ [NODE, ALIAS, NET],
46
+ );
47
+ db.run(
48
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
49
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
50
+ [`r_${crypto.randomUUID()}`, ALIAS, NODE, NET],
51
+ );
52
+ });
53
+
54
+ afterAll(cleanup);
55
+
56
+ describe("skipped run names the task that blocked it", () => {
57
+ test("open task still unacked in the inbox → not_received", () => {
58
+ const schedule = seedSchedule();
59
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T08:00:00.000Z", false);
60
+ expect(first.status).toBe("delivered");
61
+
62
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T08:01:00.000Z", false);
63
+ expect(second.status).toBe("skipped");
64
+ const row = run(second.runId);
65
+ expect(row.error_code).toBe("previous_run_active");
66
+ expect(row.blocked_by_task_id).toBe(first.taskId);
67
+ expect(row.blocked_by_state).toBe("not_received");
68
+ expect(row.error_message).toContain(first.taskId!);
69
+ expect(row.error_message).toContain("not been received");
70
+ });
71
+
72
+ test("open task whose inbox row the node acked → in_progress", () => {
73
+ const schedule = seedSchedule();
74
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T09:00:00.000Z", false);
75
+ db.run("UPDATE inbox SET acked = 1 WHERE task_id = ?1", [first.taskId]);
76
+
77
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T09:01:00.000Z", false);
78
+ expect(second.status).toBe("skipped");
79
+ const row = run(second.runId);
80
+ expect(row.blocked_by_task_id).toBe(first.taskId);
81
+ expect(row.blocked_by_state).toBe("in_progress");
82
+ expect(row.error_message).toContain("still in progress");
83
+ });
84
+
85
+ test("task status running counts as received even without an acked inbox row", () => {
86
+ const schedule = seedSchedule();
87
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T10:00:00.000Z", false);
88
+ db.run("UPDATE tasks SET status = 'running' WHERE task_id = ?1", [first.taskId]);
89
+
90
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T10:01:00.000Z", false);
91
+ expect(run(second.runId).blocked_by_state).toBe("in_progress");
92
+ });
93
+
94
+ test("GET /runs exposes the blocking task and state", async () => {
95
+ const schedule = seedSchedule();
96
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T12:00:00.000Z", false);
97
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T12:01:00.000Z", false);
98
+ const url = new URL(`http://hub.test/api/scheduled-tasks/${schedule.schedule_id}/runs`);
99
+ const res = await handleScheduledTaskRequest({
100
+ req: new Request(url, { method: "GET" }),
101
+ url,
102
+ auth: { userId: "u_skip_detail", networkId: NET, username: "skip-detail" },
103
+ isAdmin: false,
104
+ isNodeToken: false,
105
+ scope: { networkId: NET, networkIds: [NET] },
106
+ });
107
+ const body = await res!.json() as any;
108
+ const skipped = body.runs.find((r: any) => r.run_id === second.runId);
109
+ expect(skipped.blocked_by_task_id).toBe(first.taskId);
110
+ expect(skipped.blocked_by_state).toBe("not_received");
111
+ });
112
+
113
+ test("a delivered run carries no blocking fields", () => {
114
+ const schedule = seedSchedule();
115
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T11:00:00.000Z", false);
116
+ const row = run(first.runId);
117
+ expect(row.blocked_by_task_id).toBeNull();
118
+ expect(row.blocked_by_state).toBeNull();
119
+ });
120
+ });
@@ -0,0 +1,238 @@
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 { 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
+ });