discoclaw 0.1.0

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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,96 @@
1
+ import { noopTaskMetrics } from './metrics-types.js';
2
+ import { runTaskSync } from './task-sync-engine.js';
3
+ import { reloadTagMapInPlace } from './tag-map.js';
4
+ import { taskThreadCache } from './thread-cache.js';
5
+ import { classifySyncError, recordSyncFailureMetrics, recordSyncSuccessMetrics, } from './sync-coordinator-metrics.js';
6
+ import { cancelDeferredCloseRetry, cancelFailureRetry, createTaskSyncRetryState, scheduleDeferredCloseRetry, scheduleFailureRetry, } from './sync-coordinator-retries.js';
7
+ export class TaskSyncCoordinator {
8
+ opts;
9
+ syncing = false;
10
+ pendingStatusPoster = false;
11
+ retryState = createTaskSyncRetryState();
12
+ constructor(opts) {
13
+ this.opts = opts;
14
+ }
15
+ retryControls(metrics) {
16
+ return {
17
+ state: this.retryState,
18
+ metrics,
19
+ log: this.opts.log,
20
+ runSync: () => this.sync(),
21
+ enableFailureRetry: this.opts.enableFailureRetry,
22
+ failureRetryDelayMs: this.opts.failureRetryDelayMs,
23
+ deferredRetryDelayMs: this.opts.deferredRetryDelayMs,
24
+ };
25
+ }
26
+ async sync(statusPoster) {
27
+ const metrics = this.opts.metrics ?? noopTaskMetrics;
28
+ const retries = this.retryControls(metrics);
29
+ if (this.syncing) {
30
+ metrics.increment('tasks.sync.coalesced');
31
+ // Preserve the most specific statusPoster from coalesced callers:
32
+ // if any caller passes one, use it for the follow-up.
33
+ if (statusPoster || this.pendingStatusPoster === false) {
34
+ this.pendingStatusPoster = statusPoster;
35
+ }
36
+ return null; // coalesced into the running sync's follow-up
37
+ }
38
+ this.syncing = true;
39
+ metrics.increment('tasks.sync.started');
40
+ const startedAtMs = Date.now();
41
+ try {
42
+ // Reload tag map if path is configured
43
+ if (this.opts.tagMapPath) {
44
+ metrics.increment('tasks.sync.tag_map_reload.attempted');
45
+ try {
46
+ await reloadTagMapInPlace(this.opts.tagMapPath, this.opts.tagMap);
47
+ metrics.increment('tasks.sync.tag_map_reload.succeeded');
48
+ }
49
+ catch (err) {
50
+ metrics.increment('tasks.sync.tag_map_reload.failed');
51
+ this.opts.log?.warn({ err, tagMapPath: this.opts.tagMapPath }, 'tasks:tag-map reload failed; using cached map');
52
+ }
53
+ }
54
+ // Snapshot tagMap for deterministic behavior within this sync run
55
+ const tagMapSnapshot = { ...this.opts.tagMap };
56
+ const result = await runTaskSync({ ...this.opts, tagMap: tagMapSnapshot, statusPoster });
57
+ taskThreadCache.invalidate();
58
+ this.opts.forumCountSync?.requestUpdate();
59
+ recordSyncSuccessMetrics(metrics, result, Date.now() - startedAtMs);
60
+ cancelFailureRetry(retries);
61
+ if (result.closesDeferred && result.closesDeferred > 0) {
62
+ scheduleDeferredCloseRetry(retries, result.closesDeferred);
63
+ }
64
+ else {
65
+ cancelDeferredCloseRetry(retries);
66
+ }
67
+ return result;
68
+ }
69
+ catch (err) {
70
+ recordSyncFailureMetrics(metrics, err, Date.now() - startedAtMs);
71
+ cancelDeferredCloseRetry(retries);
72
+ scheduleFailureRetry(retries);
73
+ throw err;
74
+ }
75
+ finally {
76
+ this.syncing = false;
77
+ if (this.pendingStatusPoster !== false) {
78
+ const pendingPoster = this.pendingStatusPoster;
79
+ this.pendingStatusPoster = false;
80
+ // Fire-and-forget follow-up for coalesced triggers.
81
+ metrics.increment('tasks.sync.follow_up.scheduled');
82
+ metrics.increment('tasks.sync.follow_up.triggered');
83
+ this.sync(pendingPoster)
84
+ .then(() => {
85
+ metrics.increment('tasks.sync.follow_up.succeeded');
86
+ })
87
+ .catch((err) => {
88
+ metrics.increment('tasks.sync.follow_up.failed');
89
+ const message = err instanceof Error ? err.message : String(err ?? '');
90
+ metrics.increment(`tasks.sync.follow_up.error_class.${classifySyncError(message)}`);
91
+ this.opts.log?.warn({ err }, 'tasks:coordinator follow-up sync failed');
92
+ });
93
+ }
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,501 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('./task-sync-engine.js', () => {
3
+ const runTaskSync = vi.fn(async () => ({
4
+ threadsCreated: 0,
5
+ emojisUpdated: 0,
6
+ starterMessagesUpdated: 0,
7
+ threadsArchived: 0,
8
+ statusesUpdated: 0,
9
+ tagsUpdated: 0,
10
+ warnings: 0,
11
+ closesDeferred: 0,
12
+ }));
13
+ return { runTaskSync };
14
+ });
15
+ vi.mock('./thread-cache.js', () => ({
16
+ taskThreadCache: { invalidate: vi.fn() },
17
+ }));
18
+ vi.mock('./tag-map.js', () => ({
19
+ reloadTagMapInPlace: vi.fn(async () => 2),
20
+ }));
21
+ import { TaskSyncCoordinator } from './sync-coordinator.js';
22
+ import { reloadTagMapInPlace } from './tag-map.js';
23
+ function makeOpts() {
24
+ return {
25
+ client: {},
26
+ guild: {},
27
+ forumId: 'forum-1',
28
+ tagMap: {},
29
+ store: {},
30
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
31
+ metrics: { increment: vi.fn() },
32
+ };
33
+ }
34
+ describe('TaskSyncCoordinator', () => {
35
+ beforeEach(() => vi.clearAllMocks());
36
+ it('calls runTaskSync and returns result', async () => {
37
+ const { runTaskSync } = await import('./task-sync-engine.js');
38
+ const opts = makeOpts();
39
+ const coord = new TaskSyncCoordinator(opts);
40
+ const result = await coord.sync();
41
+ expect(runTaskSync).toHaveBeenCalledOnce();
42
+ expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
43
+ });
44
+ it('runs without an injected metrics sink', async () => {
45
+ const { runTaskSync } = await import('./task-sync-engine.js');
46
+ const opts = makeOpts();
47
+ delete opts.metrics;
48
+ const coord = new TaskSyncCoordinator(opts);
49
+ const result = await coord.sync();
50
+ expect(runTaskSync).toHaveBeenCalledOnce();
51
+ expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
52
+ });
53
+ it('records success metrics and transition counters', async () => {
54
+ const { runTaskSync } = await import('./task-sync-engine.js');
55
+ runTaskSync.mockResolvedValueOnce({
56
+ threadsCreated: 2,
57
+ emojisUpdated: 3,
58
+ starterMessagesUpdated: 4,
59
+ threadsArchived: 5,
60
+ statusesUpdated: 6,
61
+ tagsUpdated: 7,
62
+ warnings: 1,
63
+ threadsReconciled: 8,
64
+ orphanThreadsFound: 9,
65
+ closesDeferred: 10,
66
+ });
67
+ const opts = makeOpts();
68
+ const coord = new TaskSyncCoordinator(opts);
69
+ await coord.sync();
70
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.started');
71
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.succeeded');
72
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.duration_ms.samples');
73
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.threads_created', 2);
74
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.thread_names_updated', 3);
75
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.starter_messages_updated', 4);
76
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.threads_archived', 5);
77
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.statuses_updated', 6);
78
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.tags_updated', 7);
79
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.warnings', 1);
80
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.threads_reconciled', 8);
81
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.orphan_threads_found', 9);
82
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.transition.closes_deferred', 10);
83
+ const durationCall = opts.metrics.increment.mock.calls.find(([name]) => name === 'tasks.sync.duration_ms.total');
84
+ expect(durationCall).toBeTruthy();
85
+ expect(typeof durationCall[1]).toBe('number');
86
+ });
87
+ it('invalidates cache after sync', async () => {
88
+ const { taskThreadCache } = await import('./thread-cache.js');
89
+ const opts = makeOpts();
90
+ const coord = new TaskSyncCoordinator(opts);
91
+ await coord.sync();
92
+ expect(taskThreadCache.invalidate).toHaveBeenCalledOnce();
93
+ });
94
+ it('passes statusPoster through to runTaskSync', async () => {
95
+ const { runTaskSync } = await import('./task-sync-engine.js');
96
+ const statusPoster = { taskSyncComplete: vi.fn() };
97
+ const opts = makeOpts();
98
+ const coord = new TaskSyncCoordinator(opts);
99
+ await coord.sync(statusPoster);
100
+ expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ statusPoster }));
101
+ });
102
+ it('omits statusPoster when not provided', async () => {
103
+ const { runTaskSync } = await import('./task-sync-engine.js');
104
+ const opts = makeOpts();
105
+ const coord = new TaskSyncCoordinator(opts);
106
+ await coord.sync();
107
+ expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ statusPoster: undefined }));
108
+ });
109
+ it('returns null for concurrent call and triggers follow-up', async () => {
110
+ const { runTaskSync } = await import('./task-sync-engine.js');
111
+ // Make the first sync take a while
112
+ let resolveFirst;
113
+ const firstPromise = new Promise((r) => { resolveFirst = r; });
114
+ runTaskSync.mockImplementationOnce(async () => {
115
+ await firstPromise;
116
+ return { threadsCreated: 1, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0 };
117
+ });
118
+ const opts = makeOpts();
119
+ const coord = new TaskSyncCoordinator(opts);
120
+ // Start first sync (will block)
121
+ const first = coord.sync();
122
+ // Second call while first is running should return null
123
+ const second = await coord.sync();
124
+ expect(second).toBeNull();
125
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.coalesced');
126
+ // Complete the first sync
127
+ resolveFirst();
128
+ const firstResult = await first;
129
+ expect(firstResult).toEqual(expect.objectContaining({ threadsCreated: 1 }));
130
+ // Wait a tick for the fire-and-forget follow-up to start
131
+ await new Promise((r) => setTimeout(r, 10));
132
+ // runTaskSync should have been called at least twice (first + follow-up)
133
+ expect(runTaskSync.mock.calls.length).toBeGreaterThanOrEqual(2);
134
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.follow_up.scheduled');
135
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.follow_up.triggered');
136
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.follow_up.succeeded');
137
+ });
138
+ it('propagates runTaskSync errors and remains usable', async () => {
139
+ const { runTaskSync } = await import('./task-sync-engine.js');
140
+ const { taskThreadCache } = await import('./thread-cache.js');
141
+ runTaskSync.mockRejectedValueOnce(new Error('Discord API down'));
142
+ const opts = makeOpts();
143
+ const coord = new TaskSyncCoordinator(opts);
144
+ // First call should throw
145
+ await expect(coord.sync()).rejects.toThrow('Discord API down');
146
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failed');
147
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.error_class.other');
148
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.scheduled');
149
+ // Cache should not be invalidated on failure
150
+ expect(taskThreadCache.invalidate).not.toHaveBeenCalled();
151
+ // Coordinator should still be usable for subsequent calls
152
+ const result = await coord.sync();
153
+ expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
154
+ expect(taskThreadCache.invalidate).toHaveBeenCalledOnce();
155
+ });
156
+ it('follow-up uses the coalesced caller statusPoster, not the running one', async () => {
157
+ const { runTaskSync } = await import('./task-sync-engine.js');
158
+ let resolveFirst;
159
+ const firstPromise = new Promise((r) => { resolveFirst = r; });
160
+ runTaskSync.mockImplementationOnce(async () => {
161
+ await firstPromise;
162
+ return { threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, warnings: 0 };
163
+ });
164
+ const coord = new TaskSyncCoordinator(makeOpts());
165
+ const statusPoster = { taskSyncComplete: vi.fn() };
166
+ // Watcher triggers sync without statusPoster
167
+ const first = coord.sync();
168
+ // User action triggers sync with statusPoster — coalesced
169
+ const second = await coord.sync(statusPoster);
170
+ expect(second).toBeNull();
171
+ // Complete the first sync
172
+ resolveFirst();
173
+ await first;
174
+ // Wait for fire-and-forget follow-up
175
+ await new Promise((r) => setTimeout(r, 10));
176
+ // The follow-up (second call to runTaskSync) should have the user's statusPoster
177
+ const followUpCall = runTaskSync.mock.calls[1];
178
+ expect(followUpCall[0].statusPoster).toBe(statusPoster);
179
+ });
180
+ it('logs warning when follow-up sync fails', async () => {
181
+ const { runTaskSync } = await import('./task-sync-engine.js');
182
+ let resolveFirst;
183
+ const firstPromise = new Promise((r) => { resolveFirst = r; });
184
+ runTaskSync
185
+ .mockImplementationOnce(async () => {
186
+ await firstPromise;
187
+ return { threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, warnings: 0 };
188
+ })
189
+ .mockRejectedValueOnce(new Error('follow-up boom'));
190
+ const opts = makeOpts();
191
+ const coord = new TaskSyncCoordinator(opts);
192
+ const first = coord.sync();
193
+ await coord.sync(); // coalesce
194
+ resolveFirst();
195
+ await first;
196
+ // Wait for follow-up to fail and log
197
+ await new Promise((r) => setTimeout(r, 10));
198
+ expect(opts.log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'tasks:coordinator follow-up sync failed');
199
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.follow_up.triggered');
200
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.follow_up.failed');
201
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.follow_up.error_class.other');
202
+ });
203
+ it('classifies sync failure metrics', async () => {
204
+ const { runTaskSync } = await import('./task-sync-engine.js');
205
+ runTaskSync.mockRejectedValueOnce(new Error('missing permissions on thread close'));
206
+ const opts = makeOpts();
207
+ const coord = new TaskSyncCoordinator(opts);
208
+ await expect(coord.sync()).rejects.toThrow('missing permissions on thread close');
209
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failed');
210
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.error_class.discord_permissions');
211
+ });
212
+ it('reloads tag map before runTaskSync when tagMapPath is set', async () => {
213
+ const { runTaskSync } = await import('./task-sync-engine.js');
214
+ reloadTagMapInPlace.mockClear();
215
+ const opts = makeOpts();
216
+ opts.tagMapPath = '/tmp/tag-map.json';
217
+ opts.tagMap = { bug: '111' };
218
+ const coord = new TaskSyncCoordinator(opts);
219
+ await coord.sync();
220
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.tag_map_reload.attempted');
221
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.tag_map_reload.succeeded');
222
+ expect(reloadTagMapInPlace).toHaveBeenCalledWith('/tmp/tag-map.json', opts.tagMap);
223
+ // reloadTagMapInPlace called before runTaskSync
224
+ const reloadOrder = reloadTagMapInPlace.mock.invocationCallOrder[0];
225
+ const syncOrder = runTaskSync.mock.invocationCallOrder[0];
226
+ expect(reloadOrder).toBeLessThan(syncOrder);
227
+ });
228
+ it('preserves existing map and continues sync when tag-map reload fails', async () => {
229
+ const { runTaskSync } = await import('./task-sync-engine.js');
230
+ reloadTagMapInPlace.mockRejectedValueOnce(new Error('bad json'));
231
+ const opts = makeOpts();
232
+ opts.tagMapPath = '/tmp/tag-map.json';
233
+ opts.tagMap = { bug: '111' };
234
+ const coord = new TaskSyncCoordinator(opts);
235
+ const result = await coord.sync();
236
+ // Sync still runs despite reload failure
237
+ expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
238
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.tag_map_reload.attempted');
239
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.tag_map_reload.failed');
240
+ expect(runTaskSync).toHaveBeenCalled();
241
+ expect(opts.log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error), tagMapPath: '/tmp/tag-map.json' }), 'tasks:tag-map reload failed; using cached map');
242
+ });
243
+ it('does not attempt reload when tagMapPath is not set', async () => {
244
+ reloadTagMapInPlace.mockClear();
245
+ const opts = makeOpts();
246
+ // No tagMapPath set
247
+ const coord = new TaskSyncCoordinator(opts);
248
+ await coord.sync();
249
+ expect(reloadTagMapInPlace).not.toHaveBeenCalled();
250
+ });
251
+ it('passes a tagMap snapshot to runTaskSync', async () => {
252
+ const { runTaskSync } = await import('./task-sync-engine.js');
253
+ reloadTagMapInPlace.mockClear();
254
+ const tagMap = { bug: '111' };
255
+ const opts = makeOpts();
256
+ opts.tagMapPath = '/tmp/tag-map.json';
257
+ opts.tagMap = tagMap;
258
+ const coord = new TaskSyncCoordinator(opts);
259
+ await coord.sync();
260
+ // runTaskSync should receive a snapshot (different object reference)
261
+ const passedOpts = runTaskSync.mock.calls[0][0];
262
+ expect(passedOpts.tagMap).toEqual(tagMap);
263
+ expect(passedOpts.tagMap).not.toBe(tagMap);
264
+ });
265
+ });
266
+ describe('TaskSyncCoordinator deferred-close retry', () => {
267
+ beforeEach(() => {
268
+ vi.clearAllMocks();
269
+ vi.useFakeTimers();
270
+ });
271
+ afterEach(() => {
272
+ vi.useRealTimers();
273
+ });
274
+ it('does not schedule retry when closesDeferred is 0', async () => {
275
+ const { runTaskSync } = await import('./task-sync-engine.js');
276
+ const coord = new TaskSyncCoordinator(makeOpts());
277
+ await coord.sync();
278
+ expect(runTaskSync).toHaveBeenCalledOnce();
279
+ await vi.advanceTimersByTimeAsync(35_000);
280
+ // No retry — only the original call.
281
+ expect(runTaskSync.mock.calls.length).toBe(1);
282
+ });
283
+ it('schedules a retry sync after 30s when closesDeferred > 0', async () => {
284
+ const { runTaskSync } = await import('./task-sync-engine.js');
285
+ runTaskSync.mockResolvedValueOnce({
286
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
287
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
288
+ closesDeferred: 1,
289
+ });
290
+ const opts = makeOpts();
291
+ const coord = new TaskSyncCoordinator(opts);
292
+ await coord.sync();
293
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.scheduled');
294
+ expect(runTaskSync).toHaveBeenCalledOnce();
295
+ await vi.advanceTimersByTimeAsync(30_000);
296
+ // Retry should have fired.
297
+ expect(runTaskSync.mock.calls.length).toBe(2);
298
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.triggered');
299
+ });
300
+ it('coalesces deferred-close retry scheduling while a retry is pending', async () => {
301
+ const { runTaskSync } = await import('./task-sync-engine.js');
302
+ runTaskSync
303
+ .mockResolvedValueOnce({
304
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
305
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
306
+ closesDeferred: 1,
307
+ })
308
+ .mockResolvedValueOnce({
309
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
310
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
311
+ closesDeferred: 1,
312
+ })
313
+ .mockResolvedValueOnce({
314
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
315
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
316
+ closesDeferred: 0,
317
+ });
318
+ const opts = makeOpts();
319
+ opts.deferredRetryDelayMs = 1_000;
320
+ const coord = new TaskSyncCoordinator(opts);
321
+ await coord.sync();
322
+ await coord.sync();
323
+ const scheduledCalls = opts.metrics.increment.mock.calls
324
+ .filter(([name]) => name === 'tasks.sync.retry.scheduled');
325
+ expect(scheduledCalls).toHaveLength(1);
326
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.coalesced');
327
+ await vi.advanceTimersByTimeAsync(1_000);
328
+ expect(runTaskSync).toHaveBeenCalledTimes(3);
329
+ const triggeredCalls = opts.metrics.increment.mock.calls
330
+ .filter(([name]) => name === 'tasks.sync.retry.triggered');
331
+ expect(triggeredCalls).toHaveLength(1);
332
+ });
333
+ it('cancels pending deferred-close retry after a successful no-deferred sync', async () => {
334
+ const { runTaskSync } = await import('./task-sync-engine.js');
335
+ runTaskSync
336
+ .mockResolvedValueOnce({
337
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
338
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
339
+ closesDeferred: 1,
340
+ })
341
+ .mockResolvedValueOnce({
342
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
343
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
344
+ closesDeferred: 0,
345
+ });
346
+ const opts = makeOpts();
347
+ opts.deferredRetryDelayMs = 1_000;
348
+ const coord = new TaskSyncCoordinator(opts);
349
+ await coord.sync();
350
+ await coord.sync();
351
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.canceled');
352
+ await vi.advanceTimersByTimeAsync(1_000);
353
+ expect(runTaskSync).toHaveBeenCalledTimes(2);
354
+ expect(opts.metrics.increment).not.toHaveBeenCalledWith('tasks.sync.retry.triggered');
355
+ });
356
+ it('deferred-close retry failure is logged', async () => {
357
+ const { runTaskSync } = await import('./task-sync-engine.js');
358
+ runTaskSync
359
+ .mockResolvedValueOnce({
360
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
361
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
362
+ closesDeferred: 1,
363
+ })
364
+ .mockRejectedValueOnce(new Error('retry boom'));
365
+ const opts = makeOpts();
366
+ const coord = new TaskSyncCoordinator(opts);
367
+ await coord.sync();
368
+ await vi.advanceTimersByTimeAsync(30_000);
369
+ expect(opts.log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'tasks:coordinator deferred-close retry failed');
370
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.triggered');
371
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.failed');
372
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.error_class.other');
373
+ });
374
+ });
375
+ describe('TaskSyncCoordinator failure retry', () => {
376
+ beforeEach(() => {
377
+ vi.clearAllMocks();
378
+ vi.useFakeTimers();
379
+ });
380
+ afterEach(() => {
381
+ vi.useRealTimers();
382
+ });
383
+ it('schedules a retry after sync failure', async () => {
384
+ const { runTaskSync } = await import('./task-sync-engine.js');
385
+ runTaskSync
386
+ .mockRejectedValueOnce(new Error('primary boom'))
387
+ .mockResolvedValueOnce({
388
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
389
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
390
+ });
391
+ const opts = makeOpts();
392
+ opts.failureRetryDelayMs = 1_000;
393
+ const coord = new TaskSyncCoordinator(opts);
394
+ await expect(coord.sync()).rejects.toThrow('primary boom');
395
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.scheduled');
396
+ expect(runTaskSync).toHaveBeenCalledTimes(1);
397
+ await vi.advanceTimersByTimeAsync(1_000);
398
+ expect(runTaskSync).toHaveBeenCalledTimes(2);
399
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.triggered');
400
+ });
401
+ it('does not schedule failure retry when disabled', async () => {
402
+ const { runTaskSync } = await import('./task-sync-engine.js');
403
+ runTaskSync.mockRejectedValueOnce(new Error('primary boom'));
404
+ const opts = makeOpts();
405
+ opts.enableFailureRetry = false;
406
+ opts.failureRetryDelayMs = 1_000;
407
+ const coord = new TaskSyncCoordinator(opts);
408
+ await expect(coord.sync()).rejects.toThrow('primary boom');
409
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.disabled');
410
+ expect(opts.metrics.increment).not.toHaveBeenCalledWith('tasks.sync.failure_retry.scheduled');
411
+ await vi.advanceTimersByTimeAsync(1_000);
412
+ expect(runTaskSync).toHaveBeenCalledTimes(1);
413
+ expect(opts.metrics.increment).not.toHaveBeenCalledWith('tasks.sync.failure_retry.triggered');
414
+ });
415
+ it('logs and increments metrics when failure retry also fails', async () => {
416
+ const { runTaskSync } = await import('./task-sync-engine.js');
417
+ runTaskSync
418
+ .mockRejectedValueOnce(new Error('primary boom'))
419
+ .mockRejectedValueOnce(new Error('retry boom'));
420
+ const opts = makeOpts();
421
+ opts.failureRetryDelayMs = 1_000;
422
+ const coord = new TaskSyncCoordinator(opts);
423
+ await expect(coord.sync()).rejects.toThrow('primary boom');
424
+ await vi.advanceTimersByTimeAsync(1_000);
425
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.triggered');
426
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.failed');
427
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.error_class.other');
428
+ expect(opts.log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'tasks:coordinator failure retry sync failed');
429
+ });
430
+ it('does not schedule duplicate failure retries while one is pending', async () => {
431
+ const { runTaskSync } = await import('./task-sync-engine.js');
432
+ runTaskSync
433
+ .mockRejectedValueOnce(new Error('boom 1'))
434
+ .mockRejectedValueOnce(new Error('boom 2'))
435
+ .mockResolvedValueOnce({
436
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
437
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
438
+ });
439
+ const opts = makeOpts();
440
+ opts.failureRetryDelayMs = 1_000;
441
+ const coord = new TaskSyncCoordinator(opts);
442
+ await expect(coord.sync()).rejects.toThrow('boom 1');
443
+ await expect(coord.sync()).rejects.toThrow('boom 2');
444
+ const scheduledCalls = opts.metrics.increment.mock.calls
445
+ .filter(([name]) => name === 'tasks.sync.failure_retry.scheduled');
446
+ expect(scheduledCalls).toHaveLength(1);
447
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.coalesced');
448
+ await vi.advanceTimersByTimeAsync(1_000);
449
+ expect(runTaskSync).toHaveBeenCalledTimes(3);
450
+ const triggeredCalls = opts.metrics.increment.mock.calls
451
+ .filter(([name]) => name === 'tasks.sync.failure_retry.triggered');
452
+ expect(triggeredCalls).toHaveLength(1);
453
+ });
454
+ it('cancels pending failure retry after a successful sync before retry fires', async () => {
455
+ const { runTaskSync } = await import('./task-sync-engine.js');
456
+ runTaskSync
457
+ .mockRejectedValueOnce(new Error('primary boom'))
458
+ .mockResolvedValueOnce({
459
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
460
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
461
+ });
462
+ const opts = makeOpts();
463
+ opts.failureRetryDelayMs = 1_000;
464
+ const coord = new TaskSyncCoordinator(opts);
465
+ await expect(coord.sync()).rejects.toThrow('primary boom');
466
+ await coord.sync();
467
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.canceled');
468
+ await vi.advanceTimersByTimeAsync(1_000);
469
+ expect(runTaskSync).toHaveBeenCalledTimes(2);
470
+ expect(opts.metrics.increment).not.toHaveBeenCalledWith('tasks.sync.failure_retry.triggered');
471
+ });
472
+ it('cancels pending deferred-close retry when failure retry path takes over', async () => {
473
+ const { runTaskSync } = await import('./task-sync-engine.js');
474
+ runTaskSync
475
+ .mockResolvedValueOnce({
476
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
477
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
478
+ closesDeferred: 1,
479
+ })
480
+ .mockRejectedValueOnce(new Error('boom after deferred retry scheduled'))
481
+ .mockResolvedValueOnce({
482
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
483
+ threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
484
+ closesDeferred: 0,
485
+ });
486
+ const opts = makeOpts();
487
+ opts.deferredRetryDelayMs = 2_000;
488
+ opts.failureRetryDelayMs = 1_000;
489
+ const coord = new TaskSyncCoordinator(opts);
490
+ await coord.sync();
491
+ await expect(coord.sync()).rejects.toThrow('boom after deferred retry scheduled');
492
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.retry.canceled');
493
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.scheduled');
494
+ await vi.advanceTimersByTimeAsync(1_000);
495
+ expect(runTaskSync).toHaveBeenCalledTimes(3);
496
+ expect(opts.metrics.increment).toHaveBeenCalledWith('tasks.sync.failure_retry.triggered');
497
+ expect(opts.metrics.increment).not.toHaveBeenCalledWith('tasks.sync.retry.triggered');
498
+ await vi.advanceTimersByTimeAsync(1_000);
499
+ expect(runTaskSync).toHaveBeenCalledTimes(3);
500
+ });
501
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Subscribe to TaskStore mutation events and trigger coordinator.sync() on
3
+ * each write. Replaces the former fs.watch-based file watcher.
4
+ *
5
+ * Concurrency is handled by TaskSyncCoordinator's internal guard — concurrent
6
+ * syncs are coalesced into a follow-up run, so there is no need for a debounce.
7
+ */
8
+ export function startTaskSyncWatcher(opts) {
9
+ const { coordinator, store, log } = opts;
10
+ const triggerSync = () => {
11
+ coordinator.sync().catch((err) => {
12
+ log?.warn({ err }, 'tasks:watcher sync failed');
13
+ });
14
+ };
15
+ store.on('created', triggerSync);
16
+ store.on('updated', triggerSync);
17
+ store.on('closed', triggerSync);
18
+ store.on('labeled', triggerSync);
19
+ return {
20
+ stop() {
21
+ store.off('created', triggerSync);
22
+ store.off('updated', triggerSync);
23
+ store.off('closed', triggerSync);
24
+ store.off('labeled', triggerSync);
25
+ },
26
+ };
27
+ }