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

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 +1119 -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 +679 -0
  252. package/src/scheduled-tasks.ts +885 -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,179 @@
1
+ // #523 —— 定时任务连续失败:告诉创建者(可选自动暂停)。
2
+ //
3
+ // 现场:一个每 2 分钟一次的排程,每一次派出去的任务都被节点以 failed 结束(上游模型拒绝),一天 720 次,
4
+ // 排程照样 active,创建者什么都不知道。#464 只管「被上一次卡住」(skipped),不管「每次都失败」。
5
+ //
6
+ // 1. 计数:consecutive_failures = 最近一次成功(run.status='replied')之后,status='failed' 的 run 数。
7
+ // skipped / cancelled / expired / 还没结束的 run 不算失败,也不打断连续(expired 由 #464 的超时告警负责)。
8
+ // 失败有两种来源,都会走到这里:派发时就失败(节点不在 / 被停用 / 降级 / 创建者无权限,
9
+ // scheduled-tasks.ts 提交后调用),以及派出去的任务以 failed 结束(db.ts 的 run 镜像触发钩子)。
10
+ // 2. 告警:连续失败 ≥ N(默认 5)→ 给排程创建者发**一条**通知(agent-notice.ts,与 #462/#464 同一条路,
11
+ // 出现在与目标节点的会话里)。去重落在 scheduled_tasks 两列上(条件写,一次只有一个调用方抢得到):
12
+ // failure_alert_key = 告警那一刻「最近一次成功」的 run_id —— 之后又成功过,key 就变了,下一段失败重新上膛;
13
+ // failure_alert_at = 告警时间 —— 一直没成功过,24 小时后再提醒一次。Hub 重启不会重发。
14
+ // 3. 自动暂停:默认关。COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=M(≥1)时,连续失败 ≥ M 的 active 排程
15
+ // 被置为 paused(next_run_at 清空,revision+1,与用户手动暂停同形),并通知创建者。
16
+ //
17
+ // 环境变量:COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS(N,默认 5)、COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC
18
+ // (默认 86400)、COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS(M,默认 0 = 关)。
19
+
20
+ import { db, setScheduledRunTerminalHook } from "./db.js";
21
+ import { sendAgentNotice } from "./agent-notice.js";
22
+
23
+ export const SCHEDULE_FAILING_NOTICE_KIND = "schedule_failing";
24
+ const ERROR_PREVIEW_CHARS = 300;
25
+
26
+ function envInt(name: string, fallback: number, min: number): number {
27
+ const raw = process.env[name];
28
+ if (raw === undefined || raw.trim() === "") return fallback;
29
+ const n = Number(raw);
30
+ return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
31
+ }
32
+
33
+ export function failureNoticeRuns(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS", 5, 1); }
34
+ export function failureRenoticeMs(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC", 86_400, 1) * 1000; }
35
+ /** 0 = 不自动暂停(默认)。 */
36
+ export function failureAutoPauseRuns(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS", 0, 0); }
37
+
38
+ export type FailureStreak = { consecutiveFailures: number; lastSuccessRunId: string | null };
39
+
40
+ /** 最近一次成功之后的失败 run 数。scheduled_for 每个排程唯一(UNIQUE),按它排序是确定的。 */
41
+ export function failureStreak(scheduleId: string): FailureStreak {
42
+ const lastSuccess = db.get<{ run_id: string; scheduled_for: string }>(
43
+ "SELECT run_id, scheduled_for FROM scheduled_task_runs WHERE schedule_id = ?1 AND status = 'replied' ORDER BY scheduled_for DESC LIMIT 1",
44
+ scheduleId,
45
+ );
46
+ const n = db.get<{ n: number }>(
47
+ "SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1 AND status = 'failed' AND scheduled_for > ?2",
48
+ scheduleId, lastSuccess?.scheduled_for ?? "",
49
+ )?.n ?? 0;
50
+ return { consecutiveFailures: Number(n), lastSuccessRunId: lastSuccess?.run_id ?? null };
51
+ }
52
+
53
+ type ScheduleForAlert = {
54
+ schedule_id: string;
55
+ network_id: string;
56
+ created_by: string | null;
57
+ name: string;
58
+ target_alias: string;
59
+ status: string;
60
+ };
61
+
62
+ export type FailureNotice = {
63
+ reason: "failing" | "paused";
64
+ scheduleId: string;
65
+ scheduleName: string;
66
+ networkId: string;
67
+ createdBy: string;
68
+ alias: string;
69
+ failures: number;
70
+ errorCode: string | null;
71
+ errorMessage: string | null;
72
+ };
73
+
74
+ function latestFailure(scheduleId: string): { error_code: string | null; error_message: string | null } {
75
+ const row = db.get<{ error_code: string | null; error_message: string | null; result: string | null }>(
76
+ `SELECT r.error_code, r.error_message, t.result
77
+ FROM scheduled_task_runs r
78
+ LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
79
+ WHERE r.schedule_id = ?1 AND r.status = 'failed'
80
+ ORDER BY r.scheduled_for DESC LIMIT 1`,
81
+ scheduleId,
82
+ );
83
+ // 任务以 failed 结束时 run.error_message 为空,原因在任务的回复正文里。
84
+ return { error_code: row?.error_code ?? null, error_message: row?.error_message || row?.result || null };
85
+ }
86
+
87
+ function truncate(s: string, max: number): string {
88
+ const flat = s.replace(/\s+/g, " ").trim();
89
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
90
+ }
91
+
92
+ export function failureNoticeText(n: FailureNotice): { title: string; text: string } {
93
+ const err = n.errorMessage ? truncate(n.errorMessage, ERROR_PREVIEW_CHARS) : "(没有错误详情)";
94
+ const code = n.errorCode ? ` [${n.errorCode}]` : "";
95
+ const head = `定时任务「${n.scheduleName}」(派给节点 ${n.alias})已经连续 ${n.failures} 次执行失败。\n\n最近一次的错误${code}:${err}\n\n`;
96
+ if (n.reason === "paused") {
97
+ return {
98
+ title: "定时任务连续失败,已自动暂停",
99
+ text: `${head}按 Hub 的设置,它已被自动暂停,不会再派发。处理好原因后,在定时任务里把它恢复为「启用」即可。`,
100
+ };
101
+ }
102
+ return {
103
+ title: "定时任务连续失败",
104
+ text: `${head}它仍在按计划运行。如需停止,在定时任务里暂停它(或 PATCH /api/scheduled-tasks/${n.scheduleId},body 带 {"status":"paused","revision":<当前 revision>})。`
105
+ + `在它重新成功之前,24 小时内不会再提醒。`,
106
+ };
107
+ }
108
+
109
+ export function sendFailureNotice(n: FailureNotice): string | null {
110
+ try {
111
+ const { title, text } = failureNoticeText(n);
112
+ return sendAgentNotice({
113
+ networkId: n.networkId, userId: n.createdBy, fromAlias: n.alias, kind: SCHEDULE_FAILING_NOTICE_KIND, title, text, severity: "error",
114
+ meta: { schedule_failing: { reason: n.reason, schedule_id: n.scheduleId, consecutive_failures: n.failures, error_code: n.errorCode } },
115
+ idPrefix: "dm_sched_",
116
+ });
117
+ } catch (e: any) {
118
+ console.error(`[scheduled-tasks] failure notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 一个 run 以 failed 结束之后调用(派发时失败 / 任务 failed 都算)。返回发出的通知(测试用),没有则 null。
125
+ * 告警与暂停都是条件写:并发的两个调用方只有一个写得进去,所以每段失败恰好一条通知。
126
+ */
127
+ export function evaluateScheduleFailures(scheduleId: string, now = new Date()): FailureNotice | null {
128
+ const row = db.get<ScheduleForAlert>(
129
+ "SELECT schedule_id, network_id, created_by, name, target_alias, status FROM scheduled_tasks WHERE schedule_id = ?1",
130
+ scheduleId,
131
+ );
132
+ if (!row) return null;
133
+ const streak = failureStreak(scheduleId);
134
+ const noticeAt = failureNoticeRuns();
135
+ const pauseAt = failureAutoPauseRuns();
136
+ const wantsPause = pauseAt > 0 && streak.consecutiveFailures >= pauseAt && row.status === "active";
137
+ if (streak.consecutiveFailures < noticeAt && !wantsPause) return null;
138
+
139
+ let paused = false;
140
+ if (wantsPause) {
141
+ paused = db.run(
142
+ "UPDATE scheduled_tasks SET status = 'paused', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1 AND status = 'active'",
143
+ [scheduleId],
144
+ ).changes === 1;
145
+ }
146
+ const key = streak.lastSuccessRunId ?? "";
147
+ const cutoff = new Date(now.getTime() - failureRenoticeMs()).toISOString();
148
+ // 去重:同一段失败(key 没变)且上次告警不到 24 小时 → 不写、不发。暂停是另一件事,照发。
149
+ const claimed = db.run(
150
+ `UPDATE scheduled_tasks SET failure_alert_at = ?1, failure_alert_key = ?2
151
+ WHERE schedule_id = ?3
152
+ AND (failure_alert_at IS NULL OR COALESCE(failure_alert_key, '') <> ?2 OR failure_alert_at <= ?4)`,
153
+ [now.toISOString(), key, scheduleId, cutoff],
154
+ ).changes === 1;
155
+ if (paused && !claimed) {
156
+ db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1, failure_alert_key = ?2 WHERE schedule_id = ?3", [now.toISOString(), key, scheduleId]);
157
+ }
158
+ if (!paused && !claimed) return null;
159
+ if (!row.created_by) return null;
160
+ const latest = latestFailure(scheduleId);
161
+ const notice: FailureNotice = {
162
+ reason: paused ? "paused" : "failing",
163
+ scheduleId, scheduleName: row.name, networkId: row.network_id, createdBy: row.created_by, alias: row.target_alias,
164
+ failures: streak.consecutiveFailures, errorCode: latest.error_code, errorMessage: latest.error_message,
165
+ };
166
+ sendFailureNotice(notice);
167
+ return notice;
168
+ }
169
+
170
+ /** GET /runs 的附加字段。 */
171
+ export function failureSummary(scheduleId: string): { consecutive_failures: number; failure_alert_threshold: number; last_failure_alert_at: string | null } {
172
+ const streak = failureStreak(scheduleId);
173
+ const alertAt = db.get<{ failure_alert_at: string | null }>("SELECT failure_alert_at FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId)?.failure_alert_at ?? null;
174
+ return { consecutive_failures: streak.consecutiveFailures, failure_alert_threshold: failureNoticeRuns(), last_failure_alert_at: alertAt };
175
+ }
176
+
177
+ setScheduledRunTerminalHook((scheduleId, status) => {
178
+ if (status === "failed") evaluateScheduleFailures(scheduleId);
179
+ });
@@ -0,0 +1,131 @@
1
+ // Board #757 — an offline scheduled target produces an auditable skip, not a
2
+ // doomed task; each offline episode warns its human/Agent creator once.
3
+ // Run: COMMHUB_DB=/tmp/board757.db bun test src/scheduled-offline.test.ts
4
+ // PG: registered in tests/test2123-hub-postgres-ladder/run.sh.
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { register } from "./auth.js";
7
+ import { db } from "./db.js";
8
+ import { dispatchScheduledOccurrence, getScheduleRow } from "./scheduled-tasks.js";
9
+ import { SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, scheduledTargetOffline } from "./scheduled-offline.js";
10
+
11
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
12
+ if (!activeDbPath) throw new Error("scheduled-offline requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
13
+
14
+ const stamp = Date.now();
15
+ const TARGET_ID = `n_offline_target_${stamp}`;
16
+ const TARGET = `offline-target-${stamp}`;
17
+ const CREATOR_ID = `n_offline_creator_${stamp}`;
18
+ const CREATOR = `offline-creator-${stamp}`;
19
+ let NET = "", USER = "", slot = 0;
20
+
21
+ function seedSchedule(createdByNodeId: string | null = null): any {
22
+ const id = `sched_offline_${crypto.randomUUID()}`;
23
+ db.run(
24
+ `INSERT INTO scheduled_tasks
25
+ (schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias,
26
+ task_content, priority, schedule_type, schedule_json, timezone, status, next_run_at)
27
+ VALUES (?1, ?2, ?3, ?4, '离线巡检', ?5, ?6, 'ping', 'normal', 'interval',
28
+ '{"type":"interval","every_seconds":60}', 'Asia/Shanghai', 'active', datetime('now', '+1 minute'))`,
29
+ [id, NET, USER, createdByNodeId, TARGET_ID, TARGET],
30
+ );
31
+ return getScheduleRow(id)!;
32
+ }
33
+
34
+ function fire(schedule: any) {
35
+ const at = new Date(Date.UTC(2026, 9, 8, 0, 0, 0) + (++slot) * 60_000).toISOString();
36
+ return dispatchScheduledOccurrence(schedule, at, false, new Date());
37
+ }
38
+
39
+ function setTarget(status: "idle" | "offline", minutesAgo = 0) {
40
+ const seen = new Date(Date.now() - minutesAgo * 60_000).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
41
+ db.run("UPDATE sessions SET status = ?1, updated_at = ?2, last_seen_at = ?2 WHERE node_id = ?3 AND network_id = ?4", [status, seen, TARGET_ID, NET]);
42
+ }
43
+
44
+ const taskCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND to_node_id = ?2", NET, TARGET_ID)!.n;
45
+ const humanNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
46
+ "SELECT meta_json, content FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
47
+ NET, USER, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND,
48
+ ).filter((row) => JSON.parse(row.meta_json).schedule_target_offline.schedule_id === scheduleId);
49
+ const agentNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
50
+ "SELECT meta_json, content FROM inbox WHERE network_id = ?1 AND node_id = ?2 AND from_session = 'hub' ORDER BY created_at, id",
51
+ NET, CREATOR_ID,
52
+ ).filter((row) => JSON.parse(row.meta_json).schedule_target_offline?.schedule_id === scheduleId);
53
+
54
+ beforeAll(() => {
55
+ const owner = register(`offline_owner_${stamp}`, "OfflineOwner123!", undefined, "seed");
56
+ expect(owner.ok).toBe(true);
57
+ NET = owner.network_id!; USER = owner.user!.user_id;
58
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [TARGET_ID, TARGET, NET, USER]);
59
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [CREATOR_ID, CREATOR, NET, USER]);
60
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_target_${stamp}`, TARGET, TARGET_ID, NET]);
61
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_creator_${stamp}`, CREATOR, CREATOR_ID, NET]);
62
+ });
63
+
64
+ afterAll(() => {
65
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND]);
66
+ });
67
+
68
+ describe("#757 scheduled target offline", () => {
69
+ test("offline skips without a task, warns a human once, and online recovery dispatches normally", () => {
70
+ const schedule = seedSchedule();
71
+ const online = fire(schedule);
72
+ expect(online.status).toBe("delivered");
73
+ db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [online.taskId]);
74
+
75
+ setTarget("offline");
76
+ const before = taskCount();
77
+ const first = fire(schedule), second = fire(schedule);
78
+ expect([first.status, second.status]).toEqual(["skipped", "skipped"]);
79
+ expect(taskCount()).toBe(before);
80
+ for (const run of [first, second]) {
81
+ expect(db.get("SELECT status, error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", run.runId))
82
+ .toEqual({ status: "skipped", error_code: "target_offline", task_id: null });
83
+ }
84
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(1);
85
+ expect(humanNotices(schedule.schedule_id)[0].content).toContain("恢复在线后");
86
+
87
+ setTarget("idle");
88
+ const recovered = fire(schedule);
89
+ expect(recovered.status).toBe("delivered");
90
+ expect(recovered.taskId).toBeTruthy();
91
+ db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [recovered.taskId]);
92
+
93
+ setTarget("offline");
94
+ expect(fire(schedule).status).toBe("skipped");
95
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(2);
96
+ });
97
+
98
+ test("an Agent-created schedule warns the creator inbox, never the owner's user inbox", () => {
99
+ const schedule = seedSchedule(CREATOR_ID);
100
+ setTarget("offline");
101
+ expect(fire(schedule).status).toBe("skipped");
102
+ expect(fire(schedule).status).toBe("skipped");
103
+ expect(agentNotices(schedule.schedule_id)).toHaveLength(1);
104
+ expect(agentNotices(schedule.schedule_id)[0].content).toContain("不需要回复");
105
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(0);
106
+ });
107
+
108
+ test("an idle session older than the existing five-minute cutoff is offline", () => {
109
+ const schedule = seedSchedule();
110
+ setTarget("idle", 6);
111
+ const before = taskCount();
112
+ const run = fire(schedule);
113
+ expect(run.status).toBe("skipped");
114
+ expect(taskCount()).toBe(before);
115
+ expect(db.get<{ error_code: string }>("SELECT error_code FROM scheduled_task_runs WHERE run_id = ?1", run.runId)?.error_code).toBe("target_offline");
116
+ });
117
+
118
+ test("recent SQLite and ISO timestamps all keep an idle target online", () => {
119
+ const now = Date.now();
120
+ const isoMs = new Date(now - 1_000).toISOString();
121
+ const values = [
122
+ isoMs.slice(0, 19).replace("T", " "),
123
+ `${isoMs.slice(0, 19)}Z`,
124
+ isoMs,
125
+ ];
126
+ for (const value of values) {
127
+ db.run("UPDATE sessions SET status = 'idle', last_seen_at = ?1 WHERE node_id = ?2 AND network_id = ?3", [value, TARGET_ID, NET]);
128
+ expect(scheduledTargetOffline(TARGET_ID, TARGET, NET, now)).toBe(false);
129
+ }
130
+ });
131
+ });
@@ -0,0 +1,66 @@
1
+ import { sendAgentNotice } from "./agent-notice.js";
2
+ import { db, uuidv4 } from "./db.js";
3
+ import { parseDbTimestampMs } from "./db-timestamp.js";
4
+ import { pendingInboxCount } from "./inbox-count.js";
5
+ import { pushEvent } from "./push.js";
6
+
7
+ export const SCHEDULE_TARGET_OFFLINE_NOTICE_KIND = "schedule_target_offline";
8
+ const SESSION_STALE_MS = 5 * 60_000;
9
+
10
+ type SessionRow = { status: string | null; updated_at: string | null; last_seen_at: string | null };
11
+
12
+ /** Same status + five-minute timestamp rule as the existing task delivery resolver. */
13
+ export function scheduledTargetOffline(nodeId: string, alias: string, networkId: string, nowMs = Date.now()): boolean {
14
+ const session = db.get<SessionRow>(
15
+ "SELECT status, updated_at, last_seen_at FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
16
+ nodeId, networkId,
17
+ ) ?? db.get<SessionRow>(
18
+ "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
19
+ alias, networkId,
20
+ );
21
+ if (!session) return true;
22
+ const raw = session.last_seen_at || session.updated_at;
23
+ const seen = raw ? parseDbTimestampMs(raw) : 0;
24
+ return String(session.status || "").toLowerCase() === "offline" || !Number.isFinite(seen) || nowMs - seen > SESSION_STALE_MS;
25
+ }
26
+
27
+ export type OfflineNotice = {
28
+ scheduleId: string; scheduleName: string; networkId: string;
29
+ createdBy: string | null; createdByNodeId: string | null;
30
+ targetAlias: string;
31
+ };
32
+
33
+ function noticeText(n: OfflineNotice): string {
34
+ return `定时任务「${n.scheduleName}」的目标节点 ${n.targetAlias} 已离线,本轮已跳过且没有创建任务。节点恢复在线后,后续轮次会自动照常派发。`;
35
+ }
36
+
37
+ /** Post-commit delivery. The transaction has already claimed this offline episode. */
38
+ export function sendOfflineNotice(n: OfflineNotice): string | null {
39
+ try {
40
+ const text = noticeText(n);
41
+ const meta = { schedule_target_offline: { schedule_id: n.scheduleId, target_alias: n.targetAlias, reason: "target_offline" } };
42
+ if (!n.createdByNodeId) {
43
+ if (!n.createdBy) return null;
44
+ return sendAgentNotice({
45
+ networkId: n.networkId, userId: n.createdBy, fromAlias: n.targetAlias,
46
+ kind: SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, title: "定时任务目标节点离线", text,
47
+ severity: "warning", meta, idPrefix: "dm_sched_offline_",
48
+ });
49
+ }
50
+ const creator = db.get<{ alias: string | null }>(
51
+ "SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", n.createdByNodeId, n.networkId,
52
+ );
53
+ if (!creator?.alias) return null;
54
+ const id = `sched_offline_${uuidv4()}`;
55
+ db.run(
56
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id, meta_json)
57
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'hub', ?5, ?6)`,
58
+ [id, creator.alias, n.createdByNodeId, `[定时任务目标节点离线] ${text}\n(这是告警,不需要回复。)`, n.networkId, JSON.stringify(meta)],
59
+ );
60
+ pushEvent(creator.alias, { type: "new_message", inbox_count: pendingInboxCount(creator.alias, n.networkId), from: "hub", message_id: id }, n.networkId);
61
+ return id;
62
+ } catch (e: any) {
63
+ console.error(`[scheduled-tasks] offline notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
64
+ return null;
65
+ }
66
+ }
@@ -0,0 +1,139 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createNetworkTokenForNode, register } from "./auth.js";
3
+ import { db, syncScheduledRunForTask } from "./db.js";
4
+ import { refreshScheduledBackoff } from "./scheduled-backoff.js";
5
+ import { getScheduleRow, dispatchScheduledOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
6
+
7
+ if (!process.env.COMMHUB_DB) throw new Error("test1284 requires an explicit disposable SQLite COMMHUB_DB");
8
+ let server: any, base = "", network = "", ownerToken = "", ownerId = "";
9
+ const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
10
+ async function rest(token: string, method: string, path: string, body?: unknown) {
11
+ const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
12
+ "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
13
+ return { status: r.status, body: await r.json() as any };
14
+ }
15
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
16
+ const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
17
+ "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
18
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
19
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
20
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
21
+ try { return JSON.parse(envelope.result.content[0].text); }
22
+ catch { return { ok: false, transportError: true }; }
23
+ }
24
+ const readRun = (taskId: string) => db.get<any>(
25
+ "SELECT run_id, schedule_id, status, work_outcome, work_outcome_node_id, schedule_revision FROM scheduled_task_runs WHERE task_id = ?1 AND network_id = ?2", taskId, network)!;
26
+ const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
27
+ const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
28
+ async function fire(enabled = true, interval = 120) {
29
+ const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
30
+ name: `test1284-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "核对实际需求;Owner 原文包含 no_changes 字样。",
31
+ schedule: { type: "interval", every_seconds: interval }, timezone: "UTC", no_change_backoff: enabled });
32
+ expect(created.status).toBe(201);
33
+ const scheduleId = created.body.schedule.schedule_id;
34
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
35
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
36
+ await runDueScheduledTasks();
37
+ const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
38
+ expect(run.task_id).toBeTruthy();
39
+ return { taskId: run.task_id as string, scheduleId };
40
+ }
41
+ const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
42
+ mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
43
+ const cadence = (id: string) => db.get<any>("SELECT next_run_at, last_run_at, revision, schedule_json, target_node_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
44
+
45
+ beforeAll(async () => {
46
+ const owner = register(`guidance_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
47
+ network = owner.network_id!; ownerId = owner.user!.user_id;
48
+ for (const key of ["a", "b"]) {
49
+ const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
50
+ const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
51
+ nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
52
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
53
+ }
54
+ const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
55
+ base = `http://127.0.0.1:${server.port}`;
56
+ }, 30_000);
57
+ afterAll(() => server?.stop?.(true));
58
+
59
+
60
+ const state = (id: string) => db.get<any>("SELECT no_change_backoff, backoff_streak, backoff_effective_seconds, backoff_last_run_id, next_run_at, revision, schedule_json, status FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
61
+ let occurrence = 0;
62
+ async function nextRun(id: string) {
63
+ // Isolated fixture accelerates the clock, without touching live schedules.
64
+ const date = new Date(Date.now() + (++occurrence) * 1000).toISOString();
65
+ const out = dispatchScheduledOccurrence(getScheduleRow(id)!, date, true);
66
+ expect(out.taskId).toBeTruthy(); return out.taskId!;
67
+ }
68
+
69
+ const marker = "[Agent Network / scheduled work outcome]";
70
+ const original = "核对实际需求;Owner 原文包含 no_changes 字样。";
71
+ async function received(taskId: string) {
72
+ const out = await mcp(nodes.a.token,"get_task",{task_id:taskId});
73
+ expect(out.ok).toBe(true); expect(out.task.task_id).toBe(taskId); return out.task;
74
+ }
75
+ async function syntheticProducer(taskId: string, verified: "changed" | "no_changes") {
76
+ const task = await received(taskId);
77
+ expect(task.content).toContain(marker);
78
+ expect(task.content).toContain(`in_reply_to="${taskId}"`);
79
+ // This fixture chooses a known synthetic observation. It is not an LLM
80
+ // classifier and never parses reply prose to manufacture an outcome.
81
+ return reply(taskId,{scheduled_work_outcome:verified});
82
+ }
83
+ describe("#1284 opted-in dispatch guidance and explicit synthetic producer",()=>{
84
+ test("disabled schedules retain exact Owner task bytes in both task and inbox",async()=>{
85
+ const {taskId,scheduleId}=await fire(false), task=await received(taskId);
86
+ expect(task.content).toBe(original);
87
+ expect(db.get<any>("SELECT content FROM inbox WHERE task_id=?1",taskId).content).toBe(original);
88
+ expect(getScheduleRow(scheduleId)!.task_content).toBe(original);
89
+ });
90
+ test("human opt-in dispatch exposes exact task binding but does not annotate automatically",async()=>{
91
+ const {taskId,scheduleId}=await fire(),task=await received(taskId);
92
+ expect(task.content.startsWith(original)).toBe(true);
93
+ expect(task.content).toContain(`in_reply_to="${taskId}"`);
94
+ expect(task.content).toContain('"scheduled_work_outcome":"changed"');
95
+ expect(task.content).toContain('"scheduled_work_outcome":"no_changes"');
96
+ expect(task.content).toContain("未核实、失败、超时、过期、跳过");
97
+ expect(task.content).toContain("保留未知");
98
+ expect(db.get<any>("SELECT content FROM inbox WHERE task_id=?1",taskId).content).toBe(task.content);
99
+ expect(getScheduleRow(scheduleId)!.task_content).toBe(original);
100
+ expect(readRun(taskId).work_outcome).toBeNull(); expect(countReplies(taskId)).toBe(0);
101
+ });
102
+ test("three read-guidance explicit empty reports back off, a changed report restores base",async()=>{
103
+ const {taskId,scheduleId}=await fire();expect((await syntheticProducer(taskId,"no_changes")).ok).toBe(true);
104
+ for(let i=0;i<2;i++)expect((await syntheticProducer(await nextRun(scheduleId),"no_changes")).ok).toBe(true);
105
+ expect(state(scheduleId)).toMatchObject({backoff_streak:3,backoff_effective_seconds:600});
106
+ const changed=await nextRun(scheduleId);expect((await syntheticProducer(changed,"changed")).ok).toBe(true);
107
+ expect(readRun(changed).work_outcome).toBe("changed");
108
+ expect(state(scheduleId)).toMatchObject({backoff_streak:0,backoff_effective_seconds:120});
109
+ });
110
+ test("normal successful prose-only reply stays unknown despite prompt and no_changes words",async()=>{
111
+ const {taskId,scheduleId}=await fire();await received(taskId);
112
+ expect((await reply(taskId)).ok).toBe(true);expect(readRun(taskId).work_outcome).toBeNull();expect(state(scheduleId).backoff_streak).toBe(0);
113
+ });
114
+ test("guidance cannot turn a failed result into successful empty",async()=>{
115
+ const {taskId,scheduleId}=await fire();await received(taskId);
116
+ expect((await reply(taskId,{scheduled_work_outcome:"no_changes"},"failed")).ok).not.toBe(true);
117
+ expect(readTask(taskId).status).toBe("delivered");expect(countReplies(taskId)).toBe(0);
118
+ expect((await reply(taskId,undefined,"failed")).ok).toBe(true);expect(readRun(taskId).work_outcome).toBeNull();expect(state(scheduleId).backoff_streak).toBe(0);
119
+ });
120
+ test("revalidated persisted opt-in wins over a stale caller row",async()=>{
121
+ const {scheduleId,taskId}=await fire();expect((await reply(taskId)).ok).toBe(true);
122
+ const edit=await rest(ownerToken,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,no_change_backoff:false});expect(edit.status).toBe(200);
123
+ const row={...getScheduleRow(scheduleId)!,no_change_backoff:1};
124
+ const out=dispatchScheduledOccurrence(row,new Date(Date.now()+(++occurrence)*1000).toISOString(),true);expect(out.taskId).toBeTruthy();
125
+ expect((await received(out.taskId!)).content).toBe(original);
126
+ });
127
+ test("agent-created schedules keep creator provenance ahead of Owner content and guidance",async()=>{
128
+ const made=await mcp(nodes.b.token,"schedule_create",{name:"agent guidance1284",task:original,target_node_id:nodes.a.id,no_change_backoff:true,schedule:{type:"interval",every_seconds:120}});expect(made.ok).toBe(true);
129
+ const id=made.schedule.schedule_id,taskId=await nextRun(id),task=await received(taskId);
130
+ expect(task.content).toContain(nodes.b.alias);expect(task.content.indexOf(nodes.b.alias)).toBeLessThan(task.content.indexOf(original));expect(task.content.indexOf(original)).toBeLessThan(task.content.indexOf(marker));
131
+ expect((await syntheticProducer(taskId,"changed")).ok).toBe(true);expect(readRun(taskId).work_outcome).toBe("changed");
132
+ });
133
+ test("another target cannot reuse the advertised binding; duplicate normal reply never counts twice",async()=>{
134
+ const {taskId,scheduleId}=await fire();await received(taskId);
135
+ expect((await reply(taskId,{scheduled_work_outcome:"no_changes"},"replied",nodes.b.token)).ok).not.toBe(true);
136
+ expect(readRun(taskId).work_outcome).toBeNull();expect((await syntheticProducer(taskId,"no_changes")).ok).toBe(true);
137
+ const before=state(scheduleId);expect((await reply(taskId,{scheduled_work_outcome:"no_changes"})).ok).not.toBe(true);expect(state(scheduleId)).toEqual(before);expect(countReplies(taskId)).toBe(1);
138
+ });
139
+ });
@@ -0,0 +1,9 @@
1
+ /** Dispatch guidance is not a classifier. Only an authenticated, successful
2
+ * producer's explicit reply may record an outcome; omissions stay unknown. */
3
+ export function scheduledOutcomeGuidance(taskId: string): string {
4
+ return `\n\n[Agent Network / scheduled work outcome]\n` +
5
+ `此计划已显式启用空转退避。完成本轮实际工作并核验范围后,正常 send_reply 回原任务时使用 in_reply_to="${taskId}"。保留工具要求的原收件人、认证身份及 thread/turn 绑定。\n` +
6
+ `仅当本轮正常成功且确认有实质变更,添加 meta: {"scheduled_work_outcome":"changed"};仅当正常成功且完成范围检查、确认没有实质变更,添加 meta: {"scheduled_work_outcome":"no_changes"}。\n` +
7
+ `未核实、失败、超时、过期、跳过或仅在等待输入/权限时,不自报 no_changes;不要从回复措辞或没有工具调用推断结果。缺少支持该字段的正常回复工具时保留未知,由原回复链路收口。\n` +
8
+ `此字段是本节点自报,不是独立完成证明。不要用 send_task/send_message 或额外消息补记结果,不要为了退避伪造成功或改动计划频率。`;
9
+ }