collavre 0.24.0 → 0.25.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -12,23 +12,47 @@ module Collavre
12
12
  # AgentOrchestrator.dispatch("comment_created", context)
13
13
  #
14
14
  class AgentOrchestrator
15
- def self.dispatch(event_name, context)
16
- new(event_name: event_name, context: context).dispatch
15
+ def self.dispatch(event_name, context, **options)
16
+ new(event_name: event_name, context: context).dispatch(**options)
17
17
  end
18
18
 
19
+ def self.select(event_name, context) = new(event_name: event_name, context: context).select
20
+
21
+ def self.prepare_selection(event_name, context, **options) = new(event_name: event_name, context: context).prepare_selection(**options)
22
+
19
23
  def self.dequeue_next_for_topic(topic_id, creative_id = nil)
20
- task = Task.queued_for_topic(topic_id, creative_id).first
21
- return unless task
24
+ task = claim_next_waiter(topic_id, creative_id)
25
+ if task
26
+ # Fold the waiters left behind this one into it. dequeue promotes the
27
+ # oldest *eligible* queued task, so its same-agent siblings are all
28
+ # newer — they would each be promoted in turn and, because
29
+ # refresh_deferred_context! points every one of them at the same
30
+ # latest comment, replay the same answer. Absorb them here instead;
31
+ # the refresh below then moves the anchor forward and keeps the
32
+ # absorbed triggers in "merged_comment_ids".
33
+ coalesce_promoted!(task)
22
34
 
23
- updated = Task.where(id: task.id, status: "queued").update_all(status: "pending")
24
- if updated > 0
25
- task.reload
26
- cleanup_waiting_notices!(task)
27
- refresh_deferred_context!(task)
35
+ # `task` is the snapshot claim_next_waiter took, and the row can be
36
+ # cancelled from outside between that claim and here: deleting the
37
+ # comment it answers cancels a `pending` task through
38
+ # Comment#cancel_pending_tasks, which holds no lock this path waits
39
+ # on. TaskCoalescer re-reads the survivor under its own lock and
40
+ # declines to fold onto a cancelled row — but declining leaves this
41
+ # object still saying `pending`, and everything below reads it: the
42
+ # refresh would write a payload onto a dead row and the check at the
43
+ # bottom would enqueue it, where AiAgentJob reloads and returns
44
+ # without draining. Re-read once, here, rather than at each of them.
45
+ unless task.reload.status == "cancelled"
46
+ cleanup_waiting_notices_if_drained!(task)
47
+ refresh_deferred_context!(task)
48
+ revalidate_assignment!(task) unless task.status == "cancelled"
49
+ end
28
50
 
29
51
  if task.status == "cancelled"
30
- # refresh_deferred_context! cancelled this task (no eligible comment),
31
- # try the next queued task for this topic.
52
+ # The anchor was deleted under it, refresh_deferred_context! found
53
+ # no eligible comment, or revalidate_assignment! found the topic now
54
+ # assigned to another agent. Either way this waiter is dead — try
55
+ # the next queued task.
32
56
  dequeue_next_for_topic(topic_id, creative_id)
33
57
  else
34
58
  AiAgentJob.perform_later(task)
@@ -36,6 +60,313 @@ module Collavre
36
60
  end
37
61
  end
38
62
 
63
+ # Promote the oldest waiter this topic can actually run, moving it
64
+ # queued -> pending (a status that occupies the slot) so the claim is
65
+ # visible to every other admission the moment it commits.
66
+ #
67
+ # Promotion is a slot hand-out just like AiAgentJob's admission, so it
68
+ # takes the same lock and asks the same question. Without that, a caller
69
+ # that merely observed *a* task finish would promote into a topic that is
70
+ # still full (every terminal task in the topic calls here, including ones
71
+ # that never held this slot), or hand a second concurrent turn to an agent
72
+ # that is already running one — which TaskCoalescer, folding only `queued`
73
+ # rows, could never merge after the fact.
74
+ #
75
+ # The oldest waiter is not always eligible: with topic_max > 1 the head of
76
+ # the queue may belong to a busy agent while a later waiter can run now.
77
+ # Stopping at the queue head would strand the whole queue behind it, so
78
+ # scan in order for the first waiter that fits.
79
+ def self.claim_next_waiter(topic_id, creative_id)
80
+ claimed = nil
81
+
82
+ Task.transaction do
83
+ TopicSlot.lock!(topic_id, creative_id)
84
+
85
+ candidate = Task.queued_for_topic(topic_id, creative_id).find do |waiter|
86
+ TopicSlot.available_for?(
87
+ waiter.agent_id, topic_id, creative_id, waiter.trigger_event_payload
88
+ )
89
+ end
90
+ next if candidate.nil?
91
+
92
+ updated = Task.where(id: candidate.id, status: "queued").update_all(status: "pending")
93
+ claimed = candidate.reload if updated > 0
94
+ end
95
+
96
+ claimed
97
+ end
98
+ private_class_method :claim_next_waiter
99
+
100
+ # Human-readable reason for the "⏳" waiting notice. For topic-concurrency
101
+ # deferrals, name the agent(s) actually holding the topic's running slot so
102
+ # a waiting user can see *who* is blocking them (and reach that task's stop
103
+ # button) rather than an anonymous "another task is running" dead end.
104
+ def self.waiting_reason_text(reason_key, topic_id, creative_id)
105
+ if reason_key == :topic_concurrency && topic_id
106
+ names = Task.running_for_topic(topic_id, creative_id)
107
+ .includes(:agent).filter_map { |t| t.agent&.name }.uniq
108
+ if names.any?
109
+ return I18n.t(
110
+ "collavre.orchestration.waiting_reasons.topic_concurrency_with_agent",
111
+ agent: names.join(", ")
112
+ )
113
+ end
114
+ end
115
+
116
+ I18n.t(
117
+ "collavre.orchestration.waiting_reasons.#{reason_key}",
118
+ default: reason_key.to_s.humanize
119
+ )
120
+ end
121
+
122
+ # Is there already a "⏳" topic-concurrency waiting notice on this
123
+ # creative/topic that stands for the topic as a whole? Shared with
124
+ # AiAgentJob's late slot check so both defer paths post at most one shared
125
+ # notice per topic.
126
+ #
127
+ # A per-deferral notice does not count. It speaks for one waiter, so
128
+ # letting it suppress the shared one would leave a coalescing agent's
129
+ # waiters with no notice at all whenever an opted-out agent happened to
130
+ # defer into the topic first. Notices from before the mode was recorded do
131
+ # count: they are the topic's only signal, and posting a second one beside
132
+ # them is the duplication this guard exists to prevent.
133
+ def self.topic_concurrency_notice_exists?(creative_id, topic_id)
134
+ Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil,
135
+ topic_concurrency_defer: true)
136
+ .where(waiting_notice_scope: [ nil, Comment::WAITING_NOTICE_TOPIC ])
137
+ .where("content LIKE ?", "#{Comment::WAITING_NOTICE_PREFIX}%")
138
+ .exists?
139
+ end
140
+
141
+ # Check-then-insert the one waiting notice a topic is allowed, as a single
142
+ # step. Both defer paths run for a *burst* — the case where every worker
143
+ # reads "no notice yet" before any of them inserts — so an unserialized
144
+ # check leaves N dead-end notices pointing at one blocker, and deleting one
145
+ # of them cancels the waiters while the others linger.
146
+ #
147
+ # Serialize on the same row admission locks (TopicSlot.lock!): the workers
148
+ # that compete for a notice are exactly the ones that competed for the
149
+ # slot, so the loser reads the winner's committed notice.
150
+ #
151
+ # The same lock also decides whether a notice is still warranted at all.
152
+ # The waiter commits before its notice goes up, so the blocker can finish
153
+ # in between, promote it, and run cleanup_waiting_notices! before any
154
+ # notice exists. A notice posted after that cleanup describes a wait that
155
+ # is already over, and nothing will ever take it down: removal only
156
+ # happens when a promotion drains a *queued* waiter, and there is none
157
+ # left. Promotion takes this same row, so "still queued" read here is the
158
+ # promotion's own before-or-after, not a guess.
159
+ #
160
+ # Yields only when a waiter is still queued and no notice exists yet;
161
+ # returns nil otherwise.
162
+ def self.with_deduped_topic_notice(creative_id, topic_id)
163
+ with_live_topic_wait(creative_id, topic_id) do
164
+ next nil if topic_concurrency_notice_exists?(creative_id, topic_id)
165
+
166
+ yield
167
+ end
168
+ end
169
+
170
+ # The "is anyone still waiting?" half on its own, without the "only one
171
+ # notice per topic" half.
172
+ #
173
+ # With coalesce_pending_tasks off, each deferral keeps its own waiter, so
174
+ # each one needs its own notice: a single notice standing for N
175
+ # independent waiters turns its stop button into "cancel everyone's work"
176
+ # (Comment#cancel_queued_tasks_for_waiting_notice reads "no sibling notice
177
+ # left" as "this notice spoke for the topic"). The drain guard still
178
+ # applies either way — a notice explaining a wait that is already over is
179
+ # never removed by anything.
180
+ def self.with_live_topic_wait(creative_id, topic_id)
181
+ Comment.transaction do
182
+ TopicSlot.lock!(topic_id, creative_id)
183
+ next nil unless Task.queued_for_topic(topic_id, creative_id).exists?
184
+
185
+ yield
186
+ end
187
+ end
188
+
189
+ # The same guard for a notice that speaks for exactly one waiter.
190
+ #
191
+ # "Is anyone still waiting?" is the *shared* notice's question, and it
192
+ # stays true for as long as any waiter is queued. A per-deferral notice
193
+ # answers for one — and that one can be promoted between its row
194
+ # committing and this lock, since the waiter is created and the notice
195
+ # posted in two steps on both doors. With another deferral parked in the
196
+ # same burst the topic-wide question then says yes while this waiter's
197
+ # says no, and the notice goes up offering a stop button for a turn that
198
+ # is already running.
199
+ #
200
+ # Nothing takes it back down. cleanup_waiter_notice! is the only path that
201
+ # removes one, it runs during the promotion that just happened, and it
202
+ # matched nothing because the notice did not exist yet; deleting it by
203
+ # hand cancels nothing either, since a task-scoped notice selects its own
204
+ # waiter and that waiter is no longer queued.
205
+ #
206
+ # Asked under the admission lock, so "still queued" is the promotion's own
207
+ # before-or-after rather than a guess.
208
+ def self.with_live_waiter(waiter, creative_id, topic_id, &block)
209
+ # No waiter to speak for: the topic-wide question is all a caller
210
+ # without one can be asked.
211
+ return with_live_topic_wait(creative_id, topic_id, &block) if waiter.nil?
212
+
213
+ Comment.transaction do
214
+ TopicSlot.lock!(topic_id, creative_id)
215
+ next nil unless Task.where(id: waiter.id, status: "queued").exists?
216
+
217
+ yield
218
+ end
219
+ end
220
+
221
+ def self.coalesce_promoted!(task)
222
+ return unless PolicyResolver.new(task.trigger_event_payload || {})
223
+ .coalesce_pending_tasks_for?(task.agent)
224
+
225
+ TaskCoalescer.coalesce!(task, scope: :all)
226
+ end
227
+ private_class_method :coalesce_promoted!
228
+
229
+ # Fold one last time, at the moment execution actually begins.
230
+ #
231
+ # Promotion claims the slot (queued -> pending) and folds the waiters that
232
+ # exist at that instant, but the task then sits `pending` until its
233
+ # AiAgentJob runs. A comment arriving in that gap parks a waiter that
234
+ # enqueue-time coalescing cannot reach: TaskCoalescer supersedes only
235
+ # `queued` rows, and the claimed task has already left that status. Both
236
+ # turns were still un-started — which is the invariant coalescing is
237
+ # defined over, not "present when the slot was claimed" — so the last
238
+ # un-started moment has to fold too.
239
+ #
240
+ # Serialized on the row admission locks. A concurrent dispatch either
241
+ # commits its waiter before this reads the siblings, and is folded, or
242
+ # after, and keeps its own turn against a task that is by then executing.
243
+ # The re-anchor runs inside the same lock for the same reason: outside it,
244
+ # the refresh could move the anchor onto a comment whose waiter is still
245
+ # queued, and that comment would be answered twice.
246
+ #
247
+ # Only a `pending` task qualifies. That is the claimed-but-not-started
248
+ # status; a resumed pending_approval task has already delivered its
249
+ # trigger and run part of its turn, so folding new comments into it would
250
+ # swallow them rather than answer them.
251
+ def self.coalesce_at_start!(task)
252
+ return unless task.status == "pending"
253
+
254
+ context = task.trigger_event_payload || {}
255
+ return unless context.key?("topic")
256
+ return unless PolicyResolver.new(context).coalesce_pending_tasks_for?(task.agent)
257
+
258
+ absorbed = []
259
+ Task.transaction do
260
+ TopicSlot.lock!(task.topic_id, task.creative_id)
261
+ absorbed = TaskCoalescer.coalesce!(task, scope: :all)
262
+ refresh_deferred_context!(task, absorbed_only: true) if absorbed.any?
263
+ end
264
+ return if absorbed.empty?
265
+
266
+ # The folded waiters may have been everything the topic's "⏳" notice
267
+ # described, and nothing else will take it down: removal happens when a
268
+ # promotion drains a *queued* waiter, and this fold left none.
269
+ cleanup_waiting_notices_if_drained!(task)
270
+ end
271
+
272
+ # Take the topic's "⏳" notice down, but only once nothing is waiting on
273
+ # the slot any more.
274
+ #
275
+ # "A waiter left the queue" is not the same question. A topic gets exactly
276
+ # one deduplicated notice, and with topic_max > 1 a promotion can leave
277
+ # other agents queued — coalesce_promoted! absorbs same-agent siblings
278
+ # only, and the queue head may be ineligible while a later waiter runs. So
279
+ # an unconditional cleanup strips the wait/stop signal off a wait that is
280
+ # still real, and nothing reposts it until the next deferral happens by.
281
+ #
282
+ # Asked under the lock post_waiting_notice takes, so "nobody is queued" is
283
+ # that path's own before-or-after rather than a guess: a deferral either
284
+ # commits its waiter before this reads, and keeps its notice, or after, and
285
+ # posts its own.
286
+ # …but that guard is about the *shared* notice, which is still describing a
287
+ # real wait for as long as anyone is queued. A per-deferral notice speaks
288
+ # for one waiter, so it comes down when that waiter leaves the queue and
289
+ # not a moment later: with the opt-out and two waiters, promoting one
290
+ # leaves the queue non-empty, and the notice for the task now running would
291
+ # stay on screen with a stop button for a wait that is over.
292
+ def self.cleanup_waiting_notices_if_drained!(task)
293
+ Comment.transaction do
294
+ TopicSlot.lock!(task.topic_id, task.creative_id)
295
+ cleanup_waiter_notice!(task)
296
+
297
+ # "Is anyone queued?" is the topic's question, not any one notice's.
298
+ # A shared notice speaks for the queued waiters no per-deferral notice
299
+ # claims, so a promotion that leaves only claimed waiters behind
300
+ # leaves it representing nobody — a second "⏳" line whose stop button
301
+ # selects nothing, kept up by the very waiter that is not its to
302
+ # speak for. Ask each notice what it still stands for.
303
+ Comment.remove_stranded_waiting_notices!(
304
+ creative_id: task.creative_id, topic_id: task.topic_id
305
+ )
306
+ next if Task.queued_for_topic(task.topic_id, task.creative_id).exists?
307
+
308
+ cleanup_waiting_notices!(task)
309
+ end
310
+ end
311
+ private_class_method :cleanup_waiting_notices_if_drained!
312
+
313
+ # Remove the per-deferral notice posted for this particular waiter, if it
314
+ # had one. A shared notice is left alone — it is not this task's to take
315
+ # down, and the drained check above is the question that governs it.
316
+ def self.cleanup_waiter_notice!(task)
317
+ Comment.remove_waiter_notices!(
318
+ creative_id: task.creative_id, topic_id: task.topic_id, task_ids: task.id
319
+ )
320
+ end
321
+ private_class_method :cleanup_waiter_notice!
322
+
323
+ # Post the "⏳ waiting on the topic slot" notice for a deferral raised
324
+ # outside #enqueue_jobs — AiAgentJob's late slot check, which catches
325
+ # dispatches that passed the Scheduler before any Task row existed.
326
+ # No-op when a notice for this creative/topic is already up — unless
327
+ # coalescing is off for this dispatch, in which case its waiter is nobody
328
+ # else's to speak for and gets a notice of its own, exactly as
329
+ # #post_waiting_notice does on the enqueue door. Leaving one door
330
+ # deduplicated and the other not is what breaks the opt-out's 1:1.
331
+ def self.post_topic_concurrency_notice(creative_id, topic_id, context = nil, agent: nil, waiter: nil)
332
+ return if creative_id.nil?
333
+
334
+ creative = Creative.find_by(id: creative_id)
335
+ return unless creative
336
+
337
+ reason_text = waiting_reason_text(:topic_concurrency, topic_id, creative_id)
338
+ # The waiter recorded this when it was parked. Reading it back rather than
339
+ # resolving the policy again keeps the notice, the fold and the shared
340
+ # notice's stop button on one answer even if the policy changes mid-wait.
341
+ # Falling back for a caller without a waiter: there is no row to ask.
342
+ shared =
343
+ if waiter
344
+ waiter.waiting_notice_scope == Comment::WAITING_NOTICE_TOPIC
345
+ else
346
+ PolicyResolver.new(context || {}).coalesce_pending_tasks_for?(agent)
347
+ end
348
+ post = lambda do
349
+ creative.comments.create!(
350
+ content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
351
+ topic_id: topic_id,
352
+ private: false,
353
+ skip_default_user: true,
354
+ topic_concurrency_defer: true,
355
+ # What this notice speaks for, recorded rather than reconstructed
356
+ # later from whichever siblings survive. See
357
+ # Comment#cancel_queued_tasks_for_waiting_notice.
358
+ waiting_notice_scope: shared ? Comment::WAITING_NOTICE_TOPIC : Comment::WAITING_NOTICE_TASK,
359
+ waiting_notice_task_id: shared ? nil : waiter&.id
360
+ )
361
+ end
362
+
363
+ if shared
364
+ with_deduped_topic_notice(creative_id, topic_id, &post)
365
+ else
366
+ with_live_waiter(waiter, creative_id, topic_id, &post)
367
+ end
368
+ end
369
+
39
370
  # Remove waiting notice comments (system messages) for this task's creative/topic.
40
371
  def self.cleanup_waiting_notices!(task)
41
372
  context = task.trigger_event_payload
@@ -44,7 +375,7 @@ module Collavre
44
375
  return unless creative_id
45
376
 
46
377
  Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil)
47
- .where("content LIKE ?", "⏳%")
378
+ .where("content LIKE ?", "#{Comment::WAITING_NOTICE_PREFIX}%")
48
379
  .find_each do |notice|
49
380
  # System promotion, not user abandonment: do not let the destroy
50
381
  # callback cancel other still-queued waiters in this topic.
@@ -58,16 +389,99 @@ module Collavre
58
389
  # conversation state instead of the stale snapshot from enqueue time.
59
390
  # Skips AI agent's own comments to prevent self-response loops.
60
391
  # Cancels the task if no eligible comment remains.
61
- def self.refresh_deferred_context!(task)
392
+ #
393
+ # `absorbed_only` narrows the destination to the comments this turn is
394
+ # actually answering — its anchor plus what coalescing folded into it.
395
+ # A comment commits in its own transaction and its AiAgentJob creates the
396
+ # queued task afterwards, so a comment can be visible here with no task
397
+ # behind it yet: the topic lock orders task creation, not comment
398
+ # visibility. Moving the anchor onto one of those has this turn answer a
399
+ # comment it never absorbed, and when that job does run it parks a waiter
400
+ # against the now-claimed task — the same comment answered twice.
401
+ #
402
+ # Only coalesce_at_start! passes it. Promotion's refresh has always taken
403
+ # the newest comment in the topic ("the latest state, not the enqueue-time
404
+ # snapshot" is the whole reason it exists) and that is a wider question
405
+ # than this call site.
406
+ def self.refresh_deferred_context!(task, absorbed_only: false)
62
407
  context = task.trigger_event_payload
63
408
  creative_id = context&.dig("creative", "id")
64
409
  return unless creative_id && context&.key?("topic")
65
410
 
411
+ # Keeping a review out of TaskCoalescer is only half the boundary: the
412
+ # refresh is the other door onto the anchor, and moving it forward turns
413
+ # the in-place revision into a plain reply just as surely. A review
414
+ # answers the comment it quotes or nothing at all, so leave it alone.
415
+ #
416
+ # "Not moved" is not "not checked", though. An ordinary anchor is
417
+ # revalidated by having to be re-selected through the eligibility scope
418
+ # below; a review that skips straight past it keeps whatever
419
+ # trigger_event_payload cached, and the anchor is the one part of the
420
+ # payload MessageBuilder renders without a filter. ReviewHandler#eligible?
421
+ # only asks whether the *quoted* comment is private, so a review request
422
+ # the user withdrew — or that was moved out of this turn's scope — while
423
+ # it waited would still be delivered, and still drive an in-place
424
+ # revision of the agent's comment.
425
+ if review_anchor?(context)
426
+ task.update!(status: "cancelled") unless anchor_still_in_turn?(context)
427
+ return
428
+ end
429
+
66
430
  topic_id = context.dig("topic", "id")
67
- scope = Comment.public_only.without_approval_action
68
- .where(creative_id: creative_id, topic_id: topic_id)
69
- .where.not(user_id: [ task.agent_id, nil ])
70
- .order(created_at: :desc)
431
+ previous_anchor_id = context.dig("comment", "id")
432
+ delivered_ids = delivered_comment_ids(task, context)
433
+
434
+ # ...and a review is no better as a *destination* than as an anchor. The
435
+ # coalescer leaves a queued review alone, so an ordinary waiter promoted
436
+ # first would re-anchor onto it and run ReviewHandler over a comment it
437
+ # was never asked to revise — then the real review task repeats the
438
+ # revision later. Skip reviews and take the newest ordinary comment,
439
+ # which in the worst case is the waiter's own anchor (a no-op refresh)
440
+ # rather than nothing, so this cannot strand a live waiter.
441
+ scope = DeferredTriggerScope.for(task, context)
442
+
443
+ # Narrowing the destination closes the door this call site opens, but the
444
+ # promotion door still moves the anchor onto the newest comment in the
445
+ # topic — deliberately, since that is the reason the refresh exists. So
446
+ # ask the question from the delivery side as well: a comment already
447
+ # handed to this agent is not handed to it again, whichever door moved
448
+ # the anchor onto it. Asking here rather than at dispatch is what keeps
449
+ # it loss-free — by promotion time the covering turn's status says
450
+ # whether it actually delivered anything.
451
+ scope = scope.where.not(id: delivered_ids) if delivered_ids.any?
452
+
453
+ # When the filter above removed the waiter's *own* anchor, falling back
454
+ # to an older comment is not a rescue.
455
+ #
456
+ # The refresh's worst case used to be a no-op: the waiter's own anchor
457
+ # was always among the candidates, so "the newest eligible comment" was
458
+ # never older than the comment the waiter was dispatched for. Delivery
459
+ # filtering can now take that anchor away, and what is next is older —
460
+ # a comment this waiter was not dispatched for, that predates the one it
461
+ # was, and that the covering turn had in view when it answered the
462
+ # anchor. Answering it is a second reply to settled conversation.
463
+ #
464
+ # Scoped to exactly that cause rather than a blanket "never move
465
+ # backwards": an anchor that has left the turn for some other reason
466
+ # (moved to another creative, made private) is a different question with
467
+ # its own answer, and widening the floor to cover it would change what
468
+ # promotion does to waiters this feature never touched.
469
+ #
470
+ # With nothing at or above the anchor left, `latest_comment` is nil and
471
+ # the branch below cancels the waiter — which is the right outcome:
472
+ # everything it could have said has been said.
473
+ if previous_anchor_id && delivered_ids.include?(previous_anchor_id.to_i)
474
+ scope = scope.where("id >= ?", previous_anchor_id)
475
+ end
476
+
477
+ if absorbed_only
478
+ candidate_ids = (Array(context[TaskCoalescer::PAYLOAD_KEY]) + [ previous_anchor_id ])
479
+ .compact.map(&:to_i).uniq
480
+ return if candidate_ids.empty?
481
+
482
+ scope = scope.where(id: candidate_ids)
483
+ end
484
+
71
485
  latest_comment = scope.first
72
486
 
73
487
  unless latest_comment
@@ -75,16 +489,182 @@ module Collavre
75
489
  return
76
490
  end
77
491
 
78
- context["comment"] = {
79
- "id" => latest_comment.id,
80
- "content" => latest_comment.content,
81
- "user_id" => latest_comment.user_id
82
- }
83
- context["chat"] = { "content" => latest_comment.content }
492
+ # Moving the anchor forward must not drop what the task was originally
493
+ # going to answer. A session-backed agent only receives the :trigger
494
+ # message (SessionContextResolver#incremental_payload), so a silently
495
+ # replaced anchor is a comment the agent never sees at all.
496
+ #
497
+ # reanchor_payload also records the move when it is one, which is what
498
+ # delivered_comment_ids reads below: a comment this turn was handed
499
+ # without having been created for it.
500
+ context = DeferredTriggerScope.reanchor_payload(task, context, latest_comment)
501
+ # absorb_into_payload also drops the new anchor from the merged list, so
502
+ # a comment promoted from "merged" back to "trigger" is not sent twice.
503
+ context = TaskCoalescer.absorb_into_payload(context, [ previous_anchor_id ].compact)
504
+ # The merged list is rendered into the trigger exactly like the anchor, so
505
+ # keeping the anchor off a delivered comment is only half of it: the
506
+ # comment the refresh just displaced can itself be one an earlier turn
507
+ # already answered.
508
+ if delivered_ids.any?
509
+ context[TaskCoalescer::PAYLOAD_KEY] = Array(context[TaskCoalescer::PAYLOAD_KEY]) - delivered_ids
510
+ end
84
511
  task.update!(trigger_event_payload: context)
85
512
  end
86
513
  private_class_method :refresh_deferred_context!
87
514
 
515
+ # Statuses that mean this agent was actually handed the task's trigger.
516
+ #
517
+ # `queued` and `pending` are excluded because nothing has been delivered
518
+ # yet — those are the un-started rows TaskCoalescer folds, and treating a
519
+ # merely claimed task as an answer is what would make this lossy.
520
+ # `failed`, `cancelled` and `escalated` are excluded deliberately: a turn
521
+ # that died may never have delivered anything, so the comments it held
522
+ # stay answerable. That direction trades a possible duplicate after a
523
+ # failure for never silencing a comment no task is left to answer.
524
+ #
525
+ # The in-flight three would be unsound here if they were reachable: a
526
+ # `running` turn's record says it assembled, not that it handed anything
527
+ # over, and this reader *cancels a waiter* — the one removal this feature
528
+ # makes that writes nothing down, so a later failure has no row to undo it
529
+ # with. They are inert instead, and TopicSlot is why: an agent that
530
+ # occupies a slot in this scope may not be promoted into a second turn, so
531
+ # at the moment the refresh runs this agent has no running, delegated,
532
+ # pending or pending_approval task here — and `others` is scoped to this
533
+ # agent, so a concurrent turn (necessarily another agent's, for the same
534
+ # reason) is not read at all. The only covering turn this ever decides
535
+ # against is one that has already ended, which is what makes the decision
536
+ # sound. DeliveredHistoryDispatchTest holds both halves; relax TopicSlot's
537
+ # per-agent exclusion and they fail rather than this quietly losing a
538
+ # comment.
539
+ DELIVERED_STATUSES = %w[running delegated pending_approval done].freeze
540
+
541
+ # Comment ids this agent has already been given in this topic by a turn that
542
+ # was not created to answer them. MessageBuilder renders merged blocks into
543
+ # the trigger exactly like the anchor, so both count as delivery.
544
+ #
545
+ # The distinction matters more than the delivery: the ordinary history of a
546
+ # topic is one comment answered by its own turn, and *that* must silence
547
+ # nothing, or every waiter standing behind an answered comment would be
548
+ # cancelled. Two shapes say "this turn was not created for that comment",
549
+ # and neither needs anything new recorded:
550
+ #
551
+ # - it sits in the turn's merged list — coalescing put it there, and the
552
+ # task it belonged to was superseded, so this turn is its only delivery;
553
+ # - it is the anchor, and the payload records that the anchor was moved
554
+ # onto it — the refresh acquired it, which is the window this closes.
555
+ #
556
+ # An anchor a task was created for is that task's original trigger and is
557
+ # deliberately not counted. Which of the two it is comes from the payload
558
+ # (TaskCoalescer::ACQUIRED_ANCHOR_KEY, written by whichever door moved it)
559
+ # rather than from comparing the comment's clock with the task's.
560
+ #
561
+ # Scoped by topic and creative so a delivery is only ever matched against
562
+ # comments in the same conversation. Same trigger_event_name for the same
563
+ # reason TaskCoalescer folds only within one — a different event over the
564
+ # same comment is a different question.
565
+ def self.delivered_comment_ids(task, context)
566
+ others = Task.where(
567
+ agent_id: task.agent_id,
568
+ topic_id: context.dig("topic", "id"),
569
+ creative_id: context.dig("creative", "id"),
570
+ trigger_event_name: task.trigger_event_name,
571
+ status: DELIVERED_STATUSES
572
+ ).where.not(id: task.id).select(:id, :trigger_event_payload).to_a
573
+
574
+ # A resumed turn can carry both records legitimately: an earlier
575
+ # attempt handed off, then a later attempt failed before handoff. The
576
+ # failure invalidates inferred turn-wide delivery, but not the comment
577
+ # ids the earlier successful attempt recorded explicitly.
578
+ handed_off = others.flat_map { |other|
579
+ DeliveryRecord.handed_off_ids_in(other.trigger_event_payload)
580
+ }
581
+ delivered_turns = others.reject { |other|
582
+ # `done` is in DELIVERED_STATUSES because a finished turn delivered
583
+ # what it read — except when the request never reached the provider,
584
+ # which AiClient#chat swallows and AiAgentJob still finishes as
585
+ # `done`. Such a turn silences nothing, for the same reason `failed`
586
+ # is left out of the list; it just cannot be recognised by status.
587
+ DeliveryRecord.handoff_failed?(other.trigger_event_payload)
588
+ }
589
+
590
+ acquired_anchors = delivered_turns.filter_map { |other| acquired_anchor_id_in(other) }
591
+ merged = delivered_turns.flat_map { |other|
592
+ Array(other.trigger_event_payload&.dig(TaskCoalescer::PAYLOAD_KEY)).compact.map(&:to_i)
593
+ }
594
+ # The third shape, and the one the dispatch doors cannot catch: a
595
+ # non-session turn re-reads the topic when it assembles its payload, so
596
+ # a comment that landed after that turn was dispatched can be swept into
597
+ # its history window without ever being merged or re-anchored. A waiter
598
+ # parked before the covering turn assembled is only discoverable here.
599
+ swallowed = delivered_turns.flat_map { |other|
600
+ DeliveryRecord.ids_in(other.trigger_event_payload)
601
+ }
602
+
603
+ (acquired_anchors + merged + swallowed + handed_off).uniq
604
+ end
605
+ private_class_method :delivered_comment_ids
606
+
607
+ # The anchor of `other`, but only when `other` acquired it rather than
608
+ # being created for it. The recorded id is compared against the anchor as
609
+ # it stands now: a payload moved on again since carries the older id in
610
+ # its merged list, which is counted separately.
611
+ def self.acquired_anchor_id_in(other)
612
+ payload = other.trigger_event_payload
613
+ acquired = payload&.dig(TaskCoalescer::ACQUIRED_ANCHOR_KEY)
614
+ anchor = payload&.dig("comment", "id")
615
+ return nil if acquired.nil? || anchor.nil?
616
+
617
+ anchor.to_i if acquired.to_i == anchor.to_i
618
+ end
619
+ private_class_method :acquired_anchor_id_in
620
+
621
+ # May this turn still deliver the comment it is anchored on?
622
+ #
623
+ # Asked through MergedTriggerComments.in_turn — the same predicate the
624
+ # renderer uses for the merged list and Matcher.permits_assignment? for the
625
+ # mention — so an anchor cannot be delivered under rules the rest of the
626
+ # payload is filtered by: public_only, no approval surface, and still in
627
+ # this turn's creative/topic.
628
+ def self.anchor_still_in_turn?(context)
629
+ anchor_id = context.dig("comment", "id")
630
+ return false if anchor_id.blank?
631
+
632
+ AiAgent::MergedTriggerComments.in_turn([ anchor_id ], context).exists?
633
+ end
634
+ private_class_method :anchor_still_in_turn?
635
+
636
+ def self.review_anchor?(context)
637
+ anchor_id = context.dig("comment", "id")
638
+ anchor_id.present? && Comment.review_message_ids([ anchor_id ]).any?
639
+ end
640
+ private_class_method :review_anchor?
641
+
642
+ # Cancel a promoted waiter whose agent the topic's primary-agent assignment
643
+ # now excludes. A queued task keeps the agent chosen when it was dispatched,
644
+ # so a pin created or moved while it waited would otherwise be bypassed by
645
+ # the very work it is supposed to silence.
646
+ #
647
+ # Runs AFTER refresh_deferred_context! so the check judges what the agent is
648
+ # about to answer, not the stale snapshot: if the latest comment @mentions
649
+ # this agent, the mention outranks the assignment and the task survives.
650
+ # That refresh replaces the whole "chat" hash, dropping the resolved
651
+ # mentioned_user, so rebuild the context to recompute it from the content.
652
+ def self.revalidate_assignment!(task)
653
+ context = task.trigger_event_payload
654
+ return unless context&.key?("topic")
655
+
656
+ agent = task.agent
657
+ return unless agent
658
+ return if Matcher.permits_assignment?(context, agent)
659
+
660
+ Rails.logger.info(
661
+ "[AgentOrchestrator] Cancelling queued task #{task.id}: topic #{task.topic_id} " \
662
+ "is now assigned to another agent (agent=#{agent.id})"
663
+ )
664
+ task.update!(status: "cancelled")
665
+ end
666
+ private_class_method :revalidate_assignment!
667
+
88
668
  def initialize(event_name:, context:)
89
669
  @event_name = event_name
90
670
  # Build context ONCE here - no more duplicate builds
@@ -92,75 +672,107 @@ module Collavre
92
672
  @context["event_name"] = event_name
93
673
  end
94
674
 
95
- def dispatch
96
- # Step 1: Find qualified agents (Matcher)
97
- candidates = matcher.match
98
- return [] if candidates.empty?
675
+ def select
676
+ prepare_selection.commit!.agents
677
+ end
678
+
679
+ def prepare_selection(**options) = Selection.new(@context, policy_resolver: policy_resolver, **options).call
99
680
 
100
- # Step 2: Select responders (Arbiter) - with policy-based floor control
101
- selected = arbiter.select(candidates)
681
+ def dispatch(selected_agents: nil, selection: nil, context_for: nil, scheduling_hooks: nil)
682
+ selected = selected_agents || (selection ||= prepare_selection).agents
102
683
  return [] if selected.empty?
103
684
 
685
+ selection&.commit!
686
+
104
687
  # Step 3: Schedule execution (Scheduler) - Phase 3
105
688
  # For now, immediate execution
106
- decisions = scheduler.schedule(selected)
689
+ decisions = scheduler.schedule(selected, scheduling_hooks: scheduling_hooks)
690
+ scheduling_hooks&.scheduled(decisions.filter_map { |decision| decision[:agent] unless decision[:timing] == :rejected })
107
691
 
108
692
  # Step 4: Enqueue jobs
109
- enqueue_jobs(decisions)
693
+ enqueue_jobs(decisions, context_for: context_for)
110
694
  end
111
695
 
112
696
  private
113
697
 
114
- def policy_resolver
115
- @policy_resolver ||= PolicyResolver.new(@context)
116
- end
698
+ def policy_resolver = @policy_resolver ||= PolicyResolver.new(@context)
117
699
 
118
- def matcher
119
- @matcher ||= Matcher.new(@context)
120
- end
700
+ def scheduler = @scheduler ||= Scheduler.new(@context, policy_resolver: policy_resolver)
121
701
 
122
- def arbiter
123
- @arbiter ||= Arbiter.new(@context, policy_resolver: policy_resolver)
124
- end
125
-
126
- def scheduler
127
- @scheduler ||= Scheduler.new(@context, policy_resolver: policy_resolver)
128
- end
129
-
130
- def enqueue_jobs(decisions)
702
+ def enqueue_jobs(decisions, context_for:)
131
703
  decisions.filter_map do |decision|
132
- agent = decision[:agent]
704
+ context = context_for_agent(agent = decision[:agent], context_for)
133
705
  log_decision(decision)
134
706
 
135
- # Guard: skip if agent already has a running task for this comment
136
- comment_id = @context.dig("comment", "id")
707
+ # A rejected decision is not a dispatch, so neither guard below
708
+ # applies to it. Both ask "is this dispatch redundant?", which is only
709
+ # a question about work that was going to run: the scheduler has
710
+ # already refused this one, over an exhausted quota or a loop breaker
711
+ # that fired. Recording a drop against it would make
712
+ # DeliveryRecord.restore! owe a turn for work nothing scheduled, and
713
+ # the restore enqueues AiAgentJob directly — past the very check that
714
+ # did the refusing, so the quota is exceeded or the broken loop
715
+ # restarted. Reporting the agent would be the same mistake in the
716
+ # other direction: nothing is answering, and an empty result is how a
717
+ # caller learns that.
718
+ next nil if decision[:timing] == :rejected
719
+
720
+ # Guard: skip if agent already has a running task for this comment.
721
+ # Handled, not unscheduled — see the drop guard below for why the two
722
+ # are different answers and what reads them apart.
723
+ comment_id = context.dig("comment", "id")
137
724
  if comment_id && Task.duplicate_running_for_comment?(agent.id, comment_id)
138
725
  Rails.logger.warn(
139
726
  "[AgentOrchestrator] Skipping enqueue: agent #{agent.id} already has a running task " \
140
727
  "for comment #{comment_id}"
141
728
  )
142
- next
729
+ next agent
730
+ end
731
+
732
+ # Guard: an in-flight turn has already been given this comment. It
733
+ # reached the agent inside that turn's chat history, so a turn of its
734
+ # own would answer something the agent has read — and parking it as a
735
+ # waiter costs a "⏳" notice and a promotion round-trip for a reply
736
+ # nobody is waiting on. Drop it instead of queueing it.
737
+ #
738
+ # Nothing is recorded for a session-backed agent (it is sent only its
739
+ # :trigger), so nothing is dropped for one either — those bursts still
740
+ # go through TaskCoalescer, which merges rather than discards.
741
+ #
742
+ # Dropped only if the covering turn will take responsibility for it:
743
+ # claim_drop! re-reads that turn's status under a lock and refuses if
744
+ # it has already ended, because a turn that has ended has already run
745
+ # its restore and would leave this comment with nobody to answer it.
746
+ #
747
+ # The agent is still returned. What this method reports is who will
748
+ # answer, not how many turns it started — a :deferred decision
749
+ # returns its agent although all it created was a queued row — and a
750
+ # drop says this agent is answering that comment inside a turn
751
+ # already running. Reporting nothing is how a caller learns *no agent
752
+ # was scheduled*: DropTriggerJob#dispatch_trigger raises
753
+ # DispatchFailedError on an empty result and retries a trigger that
754
+ # was covered, three times, and calls the job failed at the end of it.
755
+ covering = DeliveryRecord.covering_task(agent, comment_id, context, @event_name)
756
+ if covering && DeliveryRecord.claim_drop!(covering, comment_id)
757
+ Rails.logger.info(
758
+ "[AgentOrchestrator] Dropping dispatch: comment #{comment_id} was already " \
759
+ "delivered to agent #{agent.id} by in-flight task #{covering.id}"
760
+ )
761
+ next agent
143
762
  end
144
763
 
145
764
  case decision[:timing]
146
765
  when :immediate
147
- AiAgentJob.perform_later(agent.id, @event_name, @context)
766
+ AiAgentJob.perform_later(agent.id, @event_name, context)
148
767
  agent
149
768
  when :deferred
150
- Task.create!(
151
- name: "Response to #{@event_name}",
152
- status: "queued",
153
- trigger_event_name: @event_name,
154
- trigger_event_payload: @context,
155
- agent: agent,
156
- topic_id: @context.dig("topic", "id"),
157
- creative_id: @context.dig("creative", "id")
158
- )
159
- post_waiting_notice(agent, decision)
769
+ next unless (waiter = park_waiter(agent, context))
770
+
771
+ post_waiting_notice(agent, decision, waiter: waiter)
160
772
  agent
161
773
  when :delayed
162
774
  AiAgentJob.set(wait: decision[:delay]).perform_later(
163
- agent.id, @event_name, @context
775
+ agent.id, @event_name, context
164
776
  )
165
777
  post_waiting_notice(agent, decision)
166
778
  agent
@@ -170,6 +782,59 @@ module Collavre
170
782
  end
171
783
  end
172
784
 
785
+ def context_for_agent(agent, context_for)
786
+ override = context_for&.call(agent)
787
+ override.present? ? @context.deep_merge(override.deep_stringify_keys) : @context
788
+ end
789
+
790
+ # Park this dispatch as a queued waiter and fold the earlier ones into it.
791
+ #
792
+ # A burst of comments queues one waiter each, and every one of them would
793
+ # answer the same latest comment on promotion. Fold them — merged, not
794
+ # dropped, so a session-backed agent still receives their content.
795
+ #
796
+ # Creating the row and folding its predecessors is ONE step, under the
797
+ # lock AiAgentJob#admit_or_defer! already takes for the other enqueue
798
+ # door. TaskCoalescer supersedes by `id < keep.id`, which reads as
799
+ # "everything already parked" only while id order and commit order agree.
800
+ # An unserialized insert lets them diverge: the higher-id waiter can
801
+ # commit and fold first, seeing nothing, and the lower-id one then folds
802
+ # nothing either because the survivor is newer than its guard allows.
803
+ # Both are still folded at promotion (scope: :all) and again at execution
804
+ # (coalesce_at_start!), so this is not a duplicate turn — but two doors
805
+ # onto one queue should not disagree about when a burst becomes one
806
+ # waiter, and only the serialized one holds without those later passes.
807
+ def park_waiter(agent, context)
808
+ topic_id = context.dig("topic", "id")
809
+ creative_id = context.dig("creative", "id")
810
+ coalescing = policy_resolver.coalesce_pending_tasks_for?(agent)
811
+ waiter = nil
812
+
813
+ Task.transaction do
814
+ next unless TopicSlot.matches_context?(TopicSlot.lock!(topic_id, creative_id), topic_id, creative_id)
815
+
816
+ waiter = Task.create!(
817
+ name: "Response to #{@event_name}",
818
+ status: "queued",
819
+ trigger_event_name: @event_name,
820
+ trigger_event_payload: context,
821
+ agent: agent,
822
+ topic_id: topic_id,
823
+ creative_id: creative_id,
824
+ # Which notice speaks for this waiter, written with the row rather
825
+ # than inferred from the notices afterwards. post_waiting_notice
826
+ # decides it from the same policy answer, but posts in a later
827
+ # transaction: between the two, this waiter is queued with no notice
828
+ # naming it, and a shared notice deleted in that window would read it
829
+ # as one of its own and cancel work that was only ever deferred.
830
+ waiting_notice_scope: coalescing ? Comment::WAITING_NOTICE_TOPIC : Comment::WAITING_NOTICE_TASK
831
+ )
832
+ TaskCoalescer.coalesce!(waiter) if coalescing
833
+ end
834
+
835
+ waiter
836
+ end
837
+
173
838
  def log_decision(decision)
174
839
  agent = decision[:agent]
175
840
  topic_id = @context.dig("topic", "id") || "main"
@@ -181,7 +846,7 @@ module Collavre
181
846
  )
182
847
  end
183
848
 
184
- def post_waiting_notice(agent, decision)
849
+ def post_waiting_notice(agent, decision, waiter: nil)
185
850
  creative_id = @context.dig("creative", "id")
186
851
  topic_id = @context.dig("topic", "id")
187
852
  return unless creative_id
@@ -190,16 +855,60 @@ module Collavre
190
855
  return unless creative
191
856
 
192
857
  reason_text = waiting_reason_text(decision[:reason] || :unknown, topic_id, creative_id)
858
+ deferred = decision[:timing] == :deferred
859
+
860
+ # Coalescing collapses a burst of deferrals into one waiter, so a notice
861
+ # per deferral would leave N-1 dead ends pointing at the same blocker.
862
+ # Keep exactly one topic-concurrency notice per creative/topic — and take
863
+ # the check and the insert under one lock, since a burst is precisely
864
+ # when an unserialized check reads stale.
865
+ #
866
+ # Taken from the waiter, which recorded it under the admission lock when
867
+ # park_waiter created it. One answer for the fold, the notice and the
868
+ # shared notice's stop button, rather than the same question asked at
869
+ # three moments a policy change can fall between. :delayed has no waiter.
870
+ shared = deferred && (waiter ? waiter.waiting_notice_scope == Comment::WAITING_NOTICE_TOPIC
871
+ : policy_resolver.coalesce_pending_tasks_for?(agent))
872
+ if shared
873
+ self.class.with_deduped_topic_notice(creative_id, topic_id) do
874
+ create_waiting_notice(creative, topic_id, reason_text, deferred: deferred,
875
+ shared: true, waiter: waiter)
876
+ end
877
+ elsif deferred
878
+ # park_waiter commits the row and this posts its notice afterwards, so
879
+ # the same window with_live_waiter closes on the late-admission door is
880
+ # open here too — see that method.
881
+ self.class.with_live_waiter(waiter, creative_id, topic_id) do
882
+ create_waiting_notice(creative, topic_id, reason_text, deferred: deferred,
883
+ shared: false, waiter: waiter)
884
+ end
885
+ else
886
+ # :delayed. The dispatch is still going to run, so there is no waiter
887
+ # for this notice to speak for and nothing for the guard to ask about.
888
+ create_waiting_notice(creative, topic_id, reason_text, deferred: deferred,
889
+ shared: false, waiter: waiter)
890
+ end
891
+ end
193
892
 
194
- creative.comments.create!(
893
+ def create_waiting_notice(creative, topic_id, reason_text, deferred:, shared:, waiter:)
894
+ creative.comments.build(
195
895
  content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
196
896
  topic_id: topic_id,
197
897
  private: false,
198
898
  skip_default_user: true,
199
899
  # Only :deferred queues a topic waiter; mark it so its stop button can
200
900
  # target the blocker. :delayed (busy / rate_limited) notices stay false.
201
- topic_concurrency_defer: decision[:timing] == :deferred
202
- )
901
+ topic_concurrency_defer: deferred,
902
+ # …and only a :deferred notice speaks for a waiter at all, so only that
903
+ # one records which. A :delayed notice never reaches
904
+ # Comment#cancel_queued_tasks_for_waiting_notice.
905
+ waiting_notice_scope: deferred ? (shared ? Comment::WAITING_NOTICE_TOPIC : Comment::WAITING_NOTICE_TASK) : nil,
906
+ waiting_notice_task_id: (waiter&.id unless shared)
907
+ ).tap(&:save!)
908
+ rescue ActiveRecord::RecordNotSaved => error
909
+ # A delayed job is already queued when this notice is posted. Topic
910
+ # relocation may invalidate only the stale notice in that gap.
911
+ raise unless error.record.errors.include?(:topic)
203
912
  end
204
913
 
205
914
  # Human-readable reason for the "⏳" waiting notice. For topic-concurrency
@@ -207,21 +916,7 @@ module Collavre
207
916
  # a waiting user can see *who* is blocking them (and reach that task's stop
208
917
  # button) rather than an anonymous "another task is running" dead end.
209
918
  def waiting_reason_text(reason_key, topic_id, creative_id)
210
- if reason_key == :topic_concurrency && topic_id
211
- names = Task.running_for_topic(topic_id, creative_id)
212
- .includes(:agent).filter_map { |t| t.agent&.name }.uniq
213
- if names.any?
214
- return I18n.t(
215
- "collavre.orchestration.waiting_reasons.topic_concurrency_with_agent",
216
- agent: names.join(", ")
217
- )
218
- end
219
- end
220
-
221
- I18n.t(
222
- "collavre.orchestration.waiting_reasons.#{reason_key}",
223
- default: reason_key.to_s.humanize
224
- )
919
+ self.class.waiting_reason_text(reason_key, topic_id, creative_id)
225
920
  end
226
921
  end
227
922
  end