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,934 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { runTaskSync } from './task-sync-engine.js';
3
+ import { withDirectTaskLifecycle } from './task-lifecycle.js';
4
+ var discordSyncMock;
5
+ function makeDiscordSyncMock() {
6
+ if (!discordSyncMock) {
7
+ const resolveTasksForum = vi.fn(async () => ({ threads: { fetchActive: vi.fn(async () => ({ threads: new Map() })), fetchArchived: vi.fn(async () => ({ threads: new Map() })) } }));
8
+ const createTaskThread = vi.fn(async () => 'thread-new');
9
+ const closeTaskThread = vi.fn(async () => { });
10
+ const isThreadArchived = vi.fn(async () => false);
11
+ const isTaskThreadAlreadyClosed = vi.fn(async () => false);
12
+ const updateTaskThreadName = vi.fn(async () => true);
13
+ const updateTaskStarterMessage = vi.fn(async () => true);
14
+ const updateTaskThreadTags = vi.fn(async () => false);
15
+ const getThreadIdFromTask = vi.fn((task) => {
16
+ const ref = (task.external_ref ?? '').trim();
17
+ if (!ref)
18
+ return null;
19
+ if (ref.startsWith('discord:'))
20
+ return ref.slice('discord:'.length);
21
+ if (/^\\d+$/.test(ref))
22
+ return ref;
23
+ return null;
24
+ });
25
+ const ensureUnarchived = vi.fn(async () => { });
26
+ const findExistingThreadForTask = vi.fn(async () => null);
27
+ discordSyncMock = {
28
+ resolveTasksForum,
29
+ createTaskThread,
30
+ closeTaskThread,
31
+ isThreadArchived,
32
+ isTaskThreadAlreadyClosed,
33
+ updateTaskThreadName,
34
+ updateTaskStarterMessage,
35
+ updateTaskThreadTags,
36
+ getThreadIdFromTask,
37
+ ensureUnarchived,
38
+ findExistingThreadForTask,
39
+ };
40
+ }
41
+ return discordSyncMock;
42
+ }
43
+ vi.mock('./thread-ops.js', makeDiscordSyncMock);
44
+ function makeThreadHelpersMock() {
45
+ const discordSync = makeDiscordSyncMock();
46
+ return {
47
+ getThreadIdFromTask: discordSync.getThreadIdFromTask,
48
+ extractShortIdFromThreadName: vi.fn((name) => {
49
+ const m = name.match(/\[(\d+)\]/);
50
+ return m ? m[1] : null;
51
+ }),
52
+ shortTaskId: vi.fn((id) => {
53
+ const idx = id.indexOf('-');
54
+ return idx >= 0 ? id.slice(idx + 1) : id;
55
+ }),
56
+ };
57
+ }
58
+ vi.mock('./thread-helpers.js', makeThreadHelpersMock);
59
+ function makeStore(tasks = []) {
60
+ const byId = new Map(tasks.map((task) => [task.id, { ...task }]));
61
+ return {
62
+ list: vi.fn(() => [...byId.values()]),
63
+ get: vi.fn((id) => byId.get(id)),
64
+ update: vi.fn((id, params) => {
65
+ const existing = byId.get(id);
66
+ if (!existing)
67
+ return;
68
+ const updated = {
69
+ ...existing,
70
+ ...(params.status !== undefined ? { status: params.status } : {}),
71
+ ...(params.externalRef !== undefined ? { external_ref: params.externalRef } : {}),
72
+ };
73
+ byId.set(id, updated);
74
+ return updated;
75
+ }),
76
+ };
77
+ }
78
+ function makeClient() {
79
+ return { channels: { cache: { get: () => undefined } } };
80
+ }
81
+ function makeGuild() {
82
+ return {};
83
+ }
84
+ describe('runTaskSync', () => {
85
+ beforeEach(() => vi.clearAllMocks());
86
+ it('skips no-thread tasks in phase 1', async () => {
87
+ const { createTaskThread } = await import('./thread-ops.js');
88
+ const store = makeStore([
89
+ { id: 'ws-001', title: 'A', status: 'open', labels: ['no-thread'], external_ref: '' },
90
+ ]);
91
+ const result = await runTaskSync({
92
+ client: makeClient(),
93
+ guild: makeGuild(),
94
+ forumId: 'forum',
95
+ tagMap: {},
96
+ store,
97
+ throttleMs: 0,
98
+ });
99
+ expect(result.threadsCreated).toBe(0);
100
+ expect(createTaskThread).not.toHaveBeenCalled();
101
+ });
102
+ it('dedupes by backfilling external_ref when a matching thread exists', async () => {
103
+ const { createTaskThread, findExistingThreadForTask } = await import('./thread-ops.js');
104
+ const store = makeStore([
105
+ { id: 'ws-002', title: 'B', status: 'open', labels: [], external_ref: '' },
106
+ ]);
107
+ findExistingThreadForTask.mockResolvedValueOnce('thread-existing');
108
+ const result = await runTaskSync({
109
+ client: makeClient(),
110
+ guild: makeGuild(),
111
+ forumId: 'forum',
112
+ tagMap: {},
113
+ store,
114
+ throttleMs: 0,
115
+ });
116
+ expect(result.threadsCreated).toBe(0);
117
+ expect(createTaskThread).not.toHaveBeenCalled();
118
+ expect(store.update).toHaveBeenCalledWith('ws-002', { externalRef: 'discord:thread-existing' });
119
+ });
120
+ it('re-checks latest phase 1 task state after lock wait and skips create when already linked', async () => {
121
+ const { createTaskThread } = await import('./thread-ops.js');
122
+ const store = makeStore([
123
+ { id: 'ws-014', title: 'N', status: 'open', labels: [], external_ref: '' },
124
+ ]);
125
+ let applyUpdate;
126
+ const updateGate = new Promise((resolve) => {
127
+ applyUpdate = resolve;
128
+ });
129
+ let releaseOwner;
130
+ const ownerGate = new Promise((resolve) => {
131
+ releaseOwner = resolve;
132
+ });
133
+ const owner = withDirectTaskLifecycle('ws-014', async () => {
134
+ await updateGate;
135
+ store.update('ws-014', { externalRef: 'discord:thread-linked' });
136
+ await ownerGate;
137
+ });
138
+ const syncRun = runTaskSync({
139
+ client: makeClient(),
140
+ guild: makeGuild(),
141
+ forumId: 'forum',
142
+ tagMap: {},
143
+ store,
144
+ throttleMs: 0,
145
+ });
146
+ await Promise.resolve();
147
+ applyUpdate();
148
+ await Promise.resolve();
149
+ releaseOwner();
150
+ const result = await syncRun;
151
+ await owner;
152
+ expect(result.threadsCreated).toBe(0);
153
+ expect(createTaskThread).not.toHaveBeenCalled();
154
+ });
155
+ it('fixes open+blocked-label to blocked in phase 2', async () => {
156
+ const store = makeStore([
157
+ { id: 'ws-003', title: 'C', status: 'open', labels: ['blocked-waiting-on'], external_ref: 'discord:1' },
158
+ ]);
159
+ const result = await runTaskSync({
160
+ client: makeClient(),
161
+ guild: makeGuild(),
162
+ forumId: 'forum',
163
+ tagMap: {},
164
+ store,
165
+ throttleMs: 0,
166
+ });
167
+ expect(result.statusesUpdated).toBe(1);
168
+ expect(store.update).toHaveBeenCalledWith('ws-003', { status: 'blocked' });
169
+ });
170
+ it('applies phase1 thread-link mutation before phase2 blocked-status mutation for the same task', async () => {
171
+ const store = makeStore([
172
+ { id: 'ws-041', title: 'Order test', status: 'open', labels: ['blocked-api'], external_ref: '' },
173
+ ]);
174
+ const result = await runTaskSync({
175
+ client: makeClient(),
176
+ guild: makeGuild(),
177
+ forumId: 'forum',
178
+ tagMap: {},
179
+ store,
180
+ throttleMs: 0,
181
+ skipPhase5: true,
182
+ });
183
+ expect(result.threadsCreated).toBe(1);
184
+ expect(result.statusesUpdated).toBe(1);
185
+ expect(store.update).toHaveBeenCalledTimes(2);
186
+ expect(store.update.mock.calls[0]).toEqual(['ws-041', { externalRef: 'discord:thread-new' }]);
187
+ expect(store.update.mock.calls[1]).toEqual(['ws-041', { status: 'blocked' }]);
188
+ });
189
+ it('phase 3 skips tasks whose thread is already archived', async () => {
190
+ const { isThreadArchived, ensureUnarchived, updateTaskThreadName } = await import('./thread-ops.js');
191
+ const store = makeStore([
192
+ { id: 'ws-030', title: 'Archived active', status: 'in_progress', labels: [], external_ref: 'discord:300' },
193
+ ]);
194
+ isThreadArchived.mockResolvedValueOnce(true);
195
+ const result = await runTaskSync({
196
+ client: makeClient(),
197
+ guild: makeGuild(),
198
+ forumId: 'forum',
199
+ tagMap: {},
200
+ store,
201
+ throttleMs: 0,
202
+ });
203
+ expect(isThreadArchived).toHaveBeenCalledWith(expect.anything(), '300');
204
+ expect(ensureUnarchived).not.toHaveBeenCalled();
205
+ expect(updateTaskThreadName).not.toHaveBeenCalled();
206
+ expect(result.emojisUpdated).toBe(0);
207
+ });
208
+ it('phase 3 processes non-archived tasks through the guard', async () => {
209
+ const { isThreadArchived, ensureUnarchived, updateTaskThreadName } = await import('./thread-ops.js');
210
+ const store = makeStore([
211
+ { id: 'ws-031', title: 'Active task', status: 'open', labels: [], external_ref: 'discord:301' },
212
+ ]);
213
+ isThreadArchived.mockResolvedValueOnce(false);
214
+ updateTaskThreadName.mockResolvedValueOnce(true);
215
+ const result = await runTaskSync({
216
+ client: makeClient(),
217
+ guild: makeGuild(),
218
+ forumId: 'forum',
219
+ tagMap: {},
220
+ store,
221
+ throttleMs: 0,
222
+ });
223
+ expect(isThreadArchived).toHaveBeenCalledWith(expect.anything(), '301');
224
+ expect(ensureUnarchived).toHaveBeenCalledWith(expect.anything(), '301');
225
+ expect(updateTaskThreadName).toHaveBeenCalled();
226
+ expect(result.emojisUpdated).toBe(1);
227
+ });
228
+ it('renames threads for active tasks in phase 3 and counts changes', async () => {
229
+ const { ensureUnarchived, updateTaskThreadName } = await import('./thread-ops.js');
230
+ const store = makeStore([
231
+ { id: 'ws-004', title: 'D', status: 'in_progress', labels: [], external_ref: 'discord:123' },
232
+ ]);
233
+ updateTaskThreadName.mockResolvedValueOnce(true);
234
+ const result = await runTaskSync({
235
+ client: makeClient(),
236
+ guild: makeGuild(),
237
+ forumId: 'forum',
238
+ tagMap: {},
239
+ store,
240
+ throttleMs: 0,
241
+ });
242
+ expect(ensureUnarchived).toHaveBeenCalledWith(expect.anything(), '123');
243
+ expect(updateTaskThreadName).toHaveBeenCalled();
244
+ expect(result.emojisUpdated).toBe(1);
245
+ });
246
+ it('calls updateTaskStarterMessage for active tasks with threads in phase 3', async () => {
247
+ const { updateTaskStarterMessage } = await import('./thread-ops.js');
248
+ const store = makeStore([
249
+ { id: 'ws-010', title: 'J', status: 'in_progress', labels: [], external_ref: 'discord:456' },
250
+ ]);
251
+ updateTaskStarterMessage.mockResolvedValueOnce(true);
252
+ const result = await runTaskSync({
253
+ client: makeClient(),
254
+ guild: makeGuild(),
255
+ forumId: 'forum',
256
+ tagMap: {},
257
+ store,
258
+ throttleMs: 0,
259
+ });
260
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '456', expect.objectContaining({ id: 'ws-010' }), undefined);
261
+ expect(result.starterMessagesUpdated).toBe(1);
262
+ });
263
+ it('passes mentionUserId through to updateTaskStarterMessage in phase 3', async () => {
264
+ const { updateTaskStarterMessage } = await import('./thread-ops.js');
265
+ const store = makeStore([
266
+ { id: 'ws-012', title: 'L', status: 'in_progress', labels: [], external_ref: 'discord:456' },
267
+ ]);
268
+ updateTaskStarterMessage.mockResolvedValueOnce(true);
269
+ await runTaskSync({
270
+ client: makeClient(),
271
+ guild: makeGuild(),
272
+ forumId: 'forum',
273
+ tagMap: {},
274
+ store,
275
+ throttleMs: 0,
276
+ mentionUserId: '999',
277
+ });
278
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '456', expect.objectContaining({ id: 'ws-012' }), '999');
279
+ });
280
+ it('passes mentionUserId through to createTaskThread in phase 1', async () => {
281
+ const { createTaskThread } = await import('./thread-ops.js');
282
+ const store = makeStore([
283
+ { id: 'ws-013', title: 'M', status: 'open', labels: [], external_ref: '' },
284
+ ]);
285
+ await runTaskSync({
286
+ client: makeClient(),
287
+ guild: makeGuild(),
288
+ forumId: 'forum',
289
+ tagMap: {},
290
+ store,
291
+ throttleMs: 0,
292
+ mentionUserId: '999',
293
+ });
294
+ expect(createTaskThread).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 'ws-013' }), {}, '999');
295
+ });
296
+ it('starterMessagesUpdated stays 0 when updateTaskStarterMessage returns false', async () => {
297
+ const { updateTaskStarterMessage } = await import('./thread-ops.js');
298
+ const store = makeStore([
299
+ { id: 'ws-011', title: 'K', status: 'open', labels: [], external_ref: 'discord:789' },
300
+ ]);
301
+ updateTaskStarterMessage.mockResolvedValueOnce(false);
302
+ const result = await runTaskSync({
303
+ client: makeClient(),
304
+ guild: makeGuild(),
305
+ forumId: 'forum',
306
+ tagMap: {},
307
+ store,
308
+ throttleMs: 0,
309
+ });
310
+ expect(result.starterMessagesUpdated).toBe(0);
311
+ });
312
+ it('archives threads for closed tasks in phase 4', async () => {
313
+ const { closeTaskThread } = await import('./thread-ops.js');
314
+ const store = makeStore([
315
+ { id: 'ws-005', title: 'E', status: 'closed', labels: [], external_ref: 'discord:999' },
316
+ ]);
317
+ const result = await runTaskSync({
318
+ client: makeClient(),
319
+ guild: makeGuild(),
320
+ forumId: 'forum',
321
+ tagMap: {},
322
+ store,
323
+ throttleMs: 0,
324
+ });
325
+ expect(closeTaskThread).toHaveBeenCalled();
326
+ expect(result.threadsArchived).toBe(1);
327
+ });
328
+ it('skips fully-closed task threads in phase 4', async () => {
329
+ const { closeTaskThread, isTaskThreadAlreadyClosed } = await import('./thread-ops.js');
330
+ const store = makeStore([
331
+ { id: 'ws-006', title: 'F', status: 'closed', labels: [], external_ref: 'discord:888' },
332
+ ]);
333
+ isTaskThreadAlreadyClosed.mockResolvedValueOnce(true);
334
+ const result = await runTaskSync({
335
+ client: makeClient(),
336
+ guild: makeGuild(),
337
+ forumId: 'forum',
338
+ tagMap: {},
339
+ store,
340
+ throttleMs: 0,
341
+ });
342
+ expect(isTaskThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), '888', expect.objectContaining({ id: 'ws-006' }), {});
343
+ expect(closeTaskThread).not.toHaveBeenCalled();
344
+ expect(result.threadsArchived).toBe(0);
345
+ });
346
+ it('phase 4 uses isTaskThreadAlreadyClosed for full state check', async () => {
347
+ const { isTaskThreadAlreadyClosed, closeTaskThread } = await import('./thread-ops.js');
348
+ const store = makeStore([
349
+ { id: 'ws-040', title: 'Closed task', status: 'closed', labels: [], external_ref: 'discord:400' },
350
+ ]);
351
+ isTaskThreadAlreadyClosed.mockResolvedValueOnce(false);
352
+ await runTaskSync({
353
+ client: makeClient(),
354
+ guild: makeGuild(),
355
+ forumId: 'forum',
356
+ tagMap: {},
357
+ store,
358
+ throttleMs: 0,
359
+ });
360
+ expect(isTaskThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), '400', expect.objectContaining({ id: 'ws-040' }), {});
361
+ expect(closeTaskThread).toHaveBeenCalled();
362
+ });
363
+ it('phase 4 recovers archived thread with wrong name/tags', async () => {
364
+ const { isTaskThreadAlreadyClosed, closeTaskThread } = await import('./thread-ops.js');
365
+ const store = makeStore([
366
+ { id: 'ws-050', title: 'Stale name', status: 'closed', labels: [], external_ref: 'discord:500' },
367
+ ]);
368
+ // Thread is archived but has wrong name — isTaskThreadAlreadyClosed returns false
369
+ isTaskThreadAlreadyClosed.mockResolvedValueOnce(false);
370
+ const result = await runTaskSync({
371
+ client: makeClient(),
372
+ guild: makeGuild(),
373
+ forumId: 'forum',
374
+ tagMap: {},
375
+ store,
376
+ throttleMs: 0,
377
+ });
378
+ expect(isTaskThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), '500', expect.objectContaining({ id: 'ws-050' }), {});
379
+ expect(closeTaskThread).toHaveBeenCalled();
380
+ expect(result.threadsArchived).toBe(1);
381
+ });
382
+ it('calls statusPoster.taskSyncComplete with the result when provided', async () => {
383
+ const store = makeStore([]);
384
+ const statusPoster = { taskSyncComplete: vi.fn(async () => { }) };
385
+ const result = await runTaskSync({
386
+ client: makeClient(),
387
+ guild: makeGuild(),
388
+ forumId: 'forum',
389
+ tagMap: {},
390
+ store,
391
+ throttleMs: 0,
392
+ statusPoster,
393
+ });
394
+ expect(statusPoster.taskSyncComplete).toHaveBeenCalledOnce();
395
+ expect(statusPoster.taskSyncComplete).toHaveBeenCalledWith(result);
396
+ });
397
+ it('works fine without statusPoster', async () => {
398
+ const store = makeStore([]);
399
+ const result = await runTaskSync({
400
+ client: makeClient(),
401
+ guild: makeGuild(),
402
+ forumId: 'forum',
403
+ tagMap: {},
404
+ store,
405
+ throttleMs: 0,
406
+ });
407
+ expect(result.warnings).toBe(0);
408
+ });
409
+ it('tagsUpdated counter increments when updateTaskThreadTags returns true', async () => {
410
+ const { updateTaskThreadTags } = await import('./thread-ops.js');
411
+ const store = makeStore([
412
+ { id: 'ws-020', title: 'T', status: 'open', labels: [], external_ref: 'discord:777' },
413
+ ]);
414
+ updateTaskThreadTags.mockResolvedValueOnce(true);
415
+ const result = await runTaskSync({
416
+ client: makeClient(),
417
+ guild: makeGuild(),
418
+ forumId: 'forum',
419
+ tagMap: { open: 's1' },
420
+ store,
421
+ throttleMs: 0,
422
+ });
423
+ expect(updateTaskThreadTags).toHaveBeenCalledWith(expect.anything(), '777', expect.objectContaining({ id: 'ws-020' }), { open: 's1' });
424
+ expect(result.tagsUpdated).toBe(1);
425
+ });
426
+ it('warnings increment when updateTaskThreadTags throws', async () => {
427
+ const { updateTaskThreadTags } = await import('./thread-ops.js');
428
+ const store = makeStore([
429
+ { id: 'ws-021', title: 'U', status: 'open', labels: [], external_ref: 'discord:888' },
430
+ ]);
431
+ updateTaskThreadTags.mockRejectedValueOnce(new Error('Discord API failure'));
432
+ const result = await runTaskSync({
433
+ client: makeClient(),
434
+ guild: makeGuild(),
435
+ forumId: 'forum',
436
+ tagMap: {},
437
+ store,
438
+ throttleMs: 0,
439
+ });
440
+ expect(result.warnings).toBeGreaterThanOrEqual(1);
441
+ });
442
+ it('increments warnings counter on phase failures', async () => {
443
+ const { updateTaskThreadName } = await import('./thread-ops.js');
444
+ const store = makeStore([
445
+ { id: 'ws-008', title: 'H', status: 'in_progress', labels: [], external_ref: 'discord:555' },
446
+ ]);
447
+ updateTaskThreadName.mockRejectedValueOnce(new Error('Discord API failure'));
448
+ const result = await runTaskSync({
449
+ client: makeClient(),
450
+ guild: makeGuild(),
451
+ forumId: 'forum',
452
+ tagMap: {},
453
+ store,
454
+ throttleMs: 0,
455
+ });
456
+ expect(result.warnings).toBe(1);
457
+ });
458
+ it('warnings counter increments when forum is not found', async () => {
459
+ const { resolveTasksForum } = await import('./thread-ops.js');
460
+ resolveTasksForum.mockResolvedValueOnce(null);
461
+ const result = await runTaskSync({
462
+ client: makeClient(),
463
+ guild: makeGuild(),
464
+ forumId: 'forum',
465
+ tagMap: {},
466
+ store: makeStore([]),
467
+ throttleMs: 0,
468
+ });
469
+ expect(result.warnings).toBe(1);
470
+ });
471
+ it('accepts skipPhase5 option without error and skips phase 5', async () => {
472
+ const store = makeStore([
473
+ { id: 'ws-001', title: 'A', status: 'closed', labels: [], external_ref: '' },
474
+ ]);
475
+ const result = await runTaskSync({
476
+ client: makeClient(),
477
+ guild: makeGuild(),
478
+ forumId: 'forum',
479
+ tagMap: {},
480
+ store,
481
+ throttleMs: 0,
482
+ skipPhase5: true,
483
+ });
484
+ expect(result.threadsReconciled).toBe(0);
485
+ expect(result.orphanThreadsFound).toBe(0);
486
+ });
487
+ it('skipPhase5 does not fetch phase5 thread sources', async () => {
488
+ const { resolveTasksForum } = await import('./thread-ops.js');
489
+ const store = makeStore([]);
490
+ const fetchActive = vi.fn(async () => ({ threads: new Map() }));
491
+ const fetchArchived = vi.fn(async () => ({ threads: new Map() }));
492
+ const mockForum = {
493
+ threads: {
494
+ create: vi.fn(async () => ({ id: 'thread-new' })),
495
+ fetchActive,
496
+ fetchArchived,
497
+ },
498
+ };
499
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
500
+ const result = await runTaskSync({
501
+ client: makeClient(),
502
+ guild: makeGuild(),
503
+ forumId: 'forum',
504
+ tagMap: {},
505
+ store,
506
+ throttleMs: 0,
507
+ skipPhase5: true,
508
+ });
509
+ expect(result.threadsReconciled).toBe(0);
510
+ expect(result.orphanThreadsFound).toBe(0);
511
+ expect(fetchActive).not.toHaveBeenCalled();
512
+ expect(fetchArchived).not.toHaveBeenCalled();
513
+ });
514
+ it('phase 5 archives non-archived thread for closed task and backfills external_ref', async () => {
515
+ const { resolveTasksForum, closeTaskThread } = await import('./thread-ops.js');
516
+ const store = makeStore([
517
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: '' },
518
+ ]);
519
+ const mockForum = {
520
+ threads: {
521
+ create: vi.fn(async () => ({ id: 'thread-new' })),
522
+ fetchActive: vi.fn(async () => ({
523
+ threads: new Map([
524
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed task', archived: false }],
525
+ ]),
526
+ })),
527
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
528
+ },
529
+ };
530
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
531
+ const result = await runTaskSync({
532
+ client: makeClient(),
533
+ guild: makeGuild(),
534
+ forumId: 'forum',
535
+ tagMap: {},
536
+ store,
537
+ throttleMs: 0,
538
+ });
539
+ expect(result.threadsReconciled).toBe(1);
540
+ expect(store.update).toHaveBeenCalledWith('ws-001', { externalRef: 'discord:thread-100' });
541
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
542
+ });
543
+ it('phase 5 detects orphan threads with no matching task', async () => {
544
+ const { resolveTasksForum } = await import('./thread-ops.js');
545
+ const store = makeStore([]);
546
+ const mockForum = {
547
+ threads: {
548
+ create: vi.fn(async () => ({ id: 'thread-new' })),
549
+ fetchActive: vi.fn(async () => ({
550
+ threads: new Map([
551
+ ['thread-200', { id: 'thread-200', name: '\u{1F7E2} [999] Unknown task', archived: false }],
552
+ ]),
553
+ })),
554
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
555
+ },
556
+ };
557
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
558
+ const result = await runTaskSync({
559
+ client: makeClient(),
560
+ guild: makeGuild(),
561
+ forumId: 'forum',
562
+ tagMap: {},
563
+ store,
564
+ throttleMs: 0,
565
+ });
566
+ expect(result.orphanThreadsFound).toBe(1);
567
+ expect(result.threadsReconciled).toBe(0);
568
+ });
569
+ it('phase 5 skips threads with short-id collision (multiple tasks)', async () => {
570
+ const { resolveTasksForum, closeTaskThread } = await import('./thread-ops.js');
571
+ const store = makeStore([
572
+ { id: 'ws-001', title: 'First', status: 'closed', labels: [], external_ref: '' },
573
+ { id: 'other-001', title: 'Second', status: 'open', labels: [], external_ref: '' },
574
+ ]);
575
+ const mockForum = {
576
+ threads: {
577
+ create: vi.fn(async () => ({ id: 'thread-new' })),
578
+ fetchActive: vi.fn(async () => ({
579
+ threads: new Map([
580
+ ['thread-300', { id: 'thread-300', name: '\u{1F7E2} [001] First', archived: false }],
581
+ ]),
582
+ })),
583
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
584
+ },
585
+ };
586
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
587
+ const result = await runTaskSync({
588
+ client: makeClient(),
589
+ guild: makeGuild(),
590
+ forumId: 'forum',
591
+ tagMap: {},
592
+ store,
593
+ throttleMs: 0,
594
+ });
595
+ // Collision: two tasks with short ID "001" — should skip, not archive or count as orphan
596
+ expect(result.threadsReconciled).toBe(0);
597
+ expect(result.orphanThreadsFound).toBe(0);
598
+ // closeTaskThread should not be called from phase 5 (may be called from phase 4)
599
+ });
600
+ it('phase 5 skips thread when task external_ref points to a different thread', async () => {
601
+ const { resolveTasksForum, closeTaskThread, isTaskThreadAlreadyClosed } = await import('./thread-ops.js');
602
+ const store = makeStore([
603
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: 'discord:thread-OTHER' },
604
+ ]);
605
+ // Phase 4 will try to archive thread-OTHER — let it skip via already-closed check.
606
+ isTaskThreadAlreadyClosed.mockResolvedValueOnce(true);
607
+ const mockForum = {
608
+ threads: {
609
+ create: vi.fn(async () => ({ id: 'thread-new' })),
610
+ fetchActive: vi.fn(async () => ({
611
+ threads: new Map([
612
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed task', archived: false }],
613
+ ]),
614
+ })),
615
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
616
+ },
617
+ };
618
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
619
+ const result = await runTaskSync({
620
+ client: makeClient(),
621
+ guild: makeGuild(),
622
+ forumId: 'forum',
623
+ tagMap: {},
624
+ store,
625
+ throttleMs: 0,
626
+ });
627
+ // Thread should be skipped by Phase 5 — external_ref points elsewhere.
628
+ expect(result.threadsReconciled).toBe(0);
629
+ // closeTaskThread should not have been called for thread-100 (Phase 5 skipped it).
630
+ expect(closeTaskThread).not.toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.anything(), expect.anything(), expect.anything());
631
+ });
632
+ it('phase 5 archives thread when task external_ref matches this thread', async () => {
633
+ const { resolveTasksForum, closeTaskThread } = await import('./thread-ops.js');
634
+ const store = makeStore([
635
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
636
+ ]);
637
+ const mockForum = {
638
+ threads: {
639
+ create: vi.fn(async () => ({ id: 'thread-new' })),
640
+ fetchActive: vi.fn(async () => ({
641
+ threads: new Map([
642
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed task', archived: false }],
643
+ ]),
644
+ })),
645
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
646
+ },
647
+ };
648
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
649
+ const result = await runTaskSync({
650
+ client: makeClient(),
651
+ guild: makeGuild(),
652
+ forumId: 'forum',
653
+ tagMap: {},
654
+ store,
655
+ throttleMs: 0,
656
+ });
657
+ expect(result.threadsReconciled).toBe(1);
658
+ // No backfill needed — external_ref already set.
659
+ expect(store.update).not.toHaveBeenCalledWith('ws-001', { externalRef: expect.anything() });
660
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
661
+ });
662
+ it('phase 5 still archives thread when external_ref backfill fails', async () => {
663
+ const { resolveTasksForum, closeTaskThread } = await import('./thread-ops.js');
664
+ const store = makeStore([
665
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: '' },
666
+ ]);
667
+ store.update.mockImplementationOnce(() => { throw new Error('store failure'); });
668
+ const mockForum = {
669
+ threads: {
670
+ create: vi.fn(async () => ({ id: 'thread-new' })),
671
+ fetchActive: vi.fn(async () => ({
672
+ threads: new Map([
673
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed task', archived: false }],
674
+ ]),
675
+ })),
676
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
677
+ },
678
+ };
679
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
680
+ const result = await runTaskSync({
681
+ client: makeClient(),
682
+ guild: makeGuild(),
683
+ forumId: 'forum',
684
+ tagMap: {},
685
+ store,
686
+ throttleMs: 0,
687
+ });
688
+ // Backfill failed but archive should still proceed.
689
+ expect(result.warnings).toBeGreaterThanOrEqual(1);
690
+ expect(result.threadsReconciled).toBe(1);
691
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
692
+ });
693
+ it('phase 5 skips already-archived thread for closed task when fully reconciled', async () => {
694
+ const { resolveTasksForum, closeTaskThread, isTaskThreadAlreadyClosed } = await import('./thread-ops.js');
695
+ const store = makeStore([
696
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
697
+ ]);
698
+ // Phase 4 checks isTaskThreadAlreadyClosed → true (skip).
699
+ // Phase 5 also checks isTaskThreadAlreadyClosed for the archived thread → true (skip).
700
+ isTaskThreadAlreadyClosed.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
701
+ const mockForum = {
702
+ threads: {
703
+ create: vi.fn(async () => ({ id: 'thread-new' })),
704
+ fetchActive: vi.fn(async () => ({ threads: new Map() })),
705
+ fetchArchived: vi.fn(async () => ({
706
+ threads: new Map([
707
+ ['thread-100', { id: 'thread-100', name: '\u2705 [001] Closed task', archived: true }],
708
+ ]),
709
+ })),
710
+ },
711
+ };
712
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
713
+ const result = await runTaskSync({
714
+ client: makeClient(),
715
+ guild: makeGuild(),
716
+ forumId: 'forum',
717
+ tagMap: {},
718
+ store,
719
+ throttleMs: 0,
720
+ });
721
+ // Thread is already fully reconciled — no work from Phase 5.
722
+ expect(result.threadsReconciled).toBe(0);
723
+ // closeTaskThread should not be called (Phase 4 skipped, Phase 5 skipped via isTaskThreadAlreadyClosed).
724
+ expect(closeTaskThread).not.toHaveBeenCalled();
725
+ });
726
+ it('phase 5 reconciles stale archived thread for closed task via unarchive→edit→re-archive', async () => {
727
+ const { resolveTasksForum, closeTaskThread, isTaskThreadAlreadyClosed } = await import('./thread-ops.js');
728
+ const store = makeStore([
729
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
730
+ ]);
731
+ // Phase 4 checks isTaskThreadAlreadyClosed → true (skip Phase 4 archive).
732
+ // Phase 5 checks isTaskThreadAlreadyClosed → false (thread is stale, needs reconcile).
733
+ isTaskThreadAlreadyClosed.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
734
+ const mockForum = {
735
+ threads: {
736
+ create: vi.fn(async () => ({ id: 'thread-new' })),
737
+ fetchActive: vi.fn(async () => ({ threads: new Map() })),
738
+ fetchArchived: vi.fn(async () => ({
739
+ threads: new Map([
740
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E0} [001] Old stale name', archived: true }],
741
+ ]),
742
+ })),
743
+ },
744
+ };
745
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
746
+ const result = await runTaskSync({
747
+ client: makeClient(),
748
+ guild: makeGuild(),
749
+ forumId: 'forum',
750
+ tagMap: {},
751
+ store,
752
+ throttleMs: 0,
753
+ });
754
+ // Phase 5 should have reconciled the stale archived thread.
755
+ expect(result.threadsReconciled).toBe(1);
756
+ expect(isTaskThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {});
757
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
758
+ });
759
+ it('phase 5 no-ops gracefully when forum has 0 threads', async () => {
760
+ const { resolveTasksForum } = await import('./thread-ops.js');
761
+ const store = makeStore([
762
+ { id: 'ws-001', title: 'Some task', status: 'open', labels: [], external_ref: '' },
763
+ ]);
764
+ const mockForum = {
765
+ threads: {
766
+ create: vi.fn(async () => ({ id: 'thread-new' })),
767
+ fetchActive: vi.fn(async () => ({ threads: new Map() })),
768
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
769
+ },
770
+ };
771
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
772
+ const result = await runTaskSync({
773
+ client: makeClient(),
774
+ guild: makeGuild(),
775
+ forumId: 'forum',
776
+ tagMap: {},
777
+ store,
778
+ throttleMs: 0,
779
+ });
780
+ expect(result.threadsReconciled).toBe(0);
781
+ expect(result.orphanThreadsFound).toBe(0);
782
+ });
783
+ it('phase 5 handles fetchActive API error gracefully', async () => {
784
+ const { resolveTasksForum } = await import('./thread-ops.js');
785
+ const store = makeStore([]);
786
+ const mockForum = {
787
+ threads: {
788
+ create: vi.fn(async () => ({ id: 'thread-new' })),
789
+ fetchActive: vi.fn(async () => { throw new Error('Discord API failure'); }),
790
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
791
+ },
792
+ };
793
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
794
+ const result = await runTaskSync({
795
+ client: makeClient(),
796
+ guild: makeGuild(),
797
+ forumId: 'forum',
798
+ tagMap: {},
799
+ store,
800
+ throttleMs: 0,
801
+ });
802
+ expect(result.warnings).toBeGreaterThanOrEqual(1);
803
+ expect(result.threadsReconciled).toBe(0);
804
+ expect(result.orphanThreadsFound).toBe(0);
805
+ });
806
+ it('phase 5 continues using active threads when fetchArchived fails', async () => {
807
+ const { resolveTasksForum, closeTaskThread } = await import('./thread-ops.js');
808
+ const store = makeStore([
809
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: '' },
810
+ ]);
811
+ const mockForum = {
812
+ threads: {
813
+ create: vi.fn(async () => ({ id: 'thread-new' })),
814
+ fetchActive: vi.fn(async () => ({
815
+ threads: new Map([
816
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed task', archived: false }],
817
+ ]),
818
+ })),
819
+ fetchArchived: vi.fn(async () => { throw new Error('archived fetch failed'); }),
820
+ },
821
+ };
822
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
823
+ const result = await runTaskSync({
824
+ client: makeClient(),
825
+ guild: makeGuild(),
826
+ forumId: 'forum',
827
+ tagMap: {},
828
+ store,
829
+ throttleMs: 0,
830
+ });
831
+ expect(result.warnings).toBeGreaterThanOrEqual(1);
832
+ expect(result.threadsReconciled).toBe(1);
833
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
834
+ });
835
+ it('calls statusPoster.taskSyncComplete in forum-not-found early return', async () => {
836
+ const { resolveTasksForum } = await import('./thread-ops.js');
837
+ resolveTasksForum.mockResolvedValueOnce(null);
838
+ const statusPoster = { taskSyncComplete: vi.fn(async () => { }) };
839
+ const result = await runTaskSync({
840
+ client: makeClient(),
841
+ guild: makeGuild(),
842
+ forumId: 'forum',
843
+ tagMap: {},
844
+ store: makeStore([]),
845
+ throttleMs: 0,
846
+ statusPoster,
847
+ });
848
+ expect(statusPoster.taskSyncComplete).toHaveBeenCalledOnce();
849
+ expect(statusPoster.taskSyncComplete).toHaveBeenCalledWith(result);
850
+ expect(result.warnings).toBe(1);
851
+ });
852
+ it('phase 4 defers close when in-flight reply is active for that thread', async () => {
853
+ const { closeTaskThread } = await import('./thread-ops.js');
854
+ const store = makeStore([
855
+ { id: 'ws-005', title: 'E', status: 'closed', labels: [], external_ref: 'discord:999' },
856
+ ]);
857
+ const result = await runTaskSync({
858
+ client: makeClient(),
859
+ guild: makeGuild(),
860
+ forumId: 'forum',
861
+ tagMap: {},
862
+ store,
863
+ throttleMs: 0,
864
+ hasInFlightForChannel: () => true,
865
+ });
866
+ expect(closeTaskThread).not.toHaveBeenCalled();
867
+ expect(result.threadsArchived).toBe(0);
868
+ expect(result.closesDeferred).toBe(1);
869
+ });
870
+ it('phase 5 defers close when in-flight reply is active for non-archived thread', async () => {
871
+ const { resolveTasksForum, closeTaskThread } = await import('./thread-ops.js');
872
+ const store = makeStore([
873
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: '' },
874
+ ]);
875
+ // Phase 4 sees no thread (no external_ref), so hasInFlightForChannel is not called there.
876
+ // Phase 5 finds the thread and checks in-flight.
877
+ const mockForum = {
878
+ threads: {
879
+ create: vi.fn(async () => ({ id: 'thread-new' })),
880
+ fetchActive: vi.fn(async () => ({
881
+ threads: new Map([
882
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed task', archived: false }],
883
+ ]),
884
+ })),
885
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
886
+ },
887
+ };
888
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
889
+ const result = await runTaskSync({
890
+ client: makeClient(),
891
+ guild: makeGuild(),
892
+ forumId: 'forum',
893
+ tagMap: {},
894
+ store,
895
+ throttleMs: 0,
896
+ hasInFlightForChannel: () => true,
897
+ });
898
+ expect(closeTaskThread).not.toHaveBeenCalled();
899
+ expect(result.threadsReconciled).toBe(0);
900
+ expect(result.closesDeferred).toBeGreaterThanOrEqual(1);
901
+ });
902
+ it('phase 5 defers close when in-flight reply is active for archived stale thread', async () => {
903
+ const { resolveTasksForum, closeTaskThread, isTaskThreadAlreadyClosed } = await import('./thread-ops.js');
904
+ const store = makeStore([
905
+ { id: 'ws-001', title: 'Closed task', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
906
+ ]);
907
+ // Phase 4: already closed → skip (no hasInFlightForChannel call). Phase 5: stale → in-flight → defer.
908
+ isTaskThreadAlreadyClosed.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
909
+ const mockForum = {
910
+ threads: {
911
+ create: vi.fn(async () => ({ id: 'thread-new' })),
912
+ fetchActive: vi.fn(async () => ({ threads: new Map() })),
913
+ fetchArchived: vi.fn(async () => ({
914
+ threads: new Map([
915
+ ['thread-100', { id: 'thread-100', name: '\u{1F7E0} [001] Old stale name', archived: true }],
916
+ ]),
917
+ })),
918
+ },
919
+ };
920
+ resolveTasksForum.mockResolvedValueOnce(mockForum);
921
+ const result = await runTaskSync({
922
+ client: makeClient(),
923
+ guild: makeGuild(),
924
+ forumId: 'forum',
925
+ tagMap: {},
926
+ store,
927
+ throttleMs: 0,
928
+ hasInFlightForChannel: () => true,
929
+ });
930
+ expect(closeTaskThread).not.toHaveBeenCalled();
931
+ expect(result.threadsReconciled).toBe(0);
932
+ expect(result.closesDeferred).toBe(1);
933
+ });
934
+ });