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
@@ -38,12 +38,12 @@ module Collavre
38
38
  end
39
39
 
40
40
  # Main entry point - checks all loop conditions
41
- def check
41
+ def check(prospective_interaction: nil)
42
42
  return Result.new(should_break: false, reason: nil, details: {}) unless enabled?
43
43
 
44
44
  # Check each condition
45
45
  checks = [
46
- check_ping_pong,
46
+ check_ping_pong(prospective_interaction),
47
47
  check_creative_retry,
48
48
  check_task_timeout,
49
49
  check_token_spike
@@ -110,13 +110,14 @@ module Collavre
110
110
  end
111
111
 
112
112
  # Detect ping-pong between agents
113
- def check_ping_pong
113
+ def check_ping_pong(prospective_interaction)
114
114
  creative_id = @context.dig("creative", "id")
115
115
  return safe_result unless creative_id
116
116
 
117
117
  # Check the ping-pong tracking key for this creative
118
118
  key = "#{CACHE_PREFIX}:ping_pong_history:#{creative_id}"
119
- interactions = Rails.cache.read(key) || []
119
+ interactions = Array(Rails.cache.read(key)).dup
120
+ interactions << prospective_interaction_entry(prospective_interaction) if prospective_interaction
120
121
 
121
122
  # Count exchanges for each agent pair
122
123
  pair_exchanges = count_pair_exchanges(interactions)
@@ -139,6 +140,11 @@ module Collavre
139
140
  safe_result
140
141
  end
141
142
 
143
+ def prospective_interaction_entry(interaction)
144
+ edge = interaction.with_indifferent_access
145
+ { at: Time.current.to_i, from: edge[:from_agent_id], to: edge[:to_agent_id] }
146
+ end
147
+
142
148
  # Detect too many tasks on same topic (per-topic, not per-creative)
143
149
  def check_creative_retry
144
150
  creative_id = @context.dig("creative", "id")
@@ -8,13 +8,53 @@ module Collavre
8
8
  # 1. Mention-based: If a user is @mentioned, route exclusively to that user
9
9
  # - If mentioned user is AI agent → route to that agent only
10
10
  # - If mentioned user is human → no AI agents respond
11
- # 2. Expression-based: Evaluate each agent's routing_expression (Liquid)
11
+ # 2. Primary-agent assignment: If the topic has a primary_agent, that agent is
12
+ # the topic's sole ambient responder (see #match_by_primary_agent)
13
+ # 3. Expression-based: Evaluate each agent's routing_expression (Liquid)
12
14
  #
13
15
  # Permission checks:
14
16
  # - All agents need feedback permission on the creative to respond
15
17
  # - searchable only affects discoverability, not response permission
16
18
  #
17
19
  class Matcher
20
+ # The one entry point for "may this agent answer, given the topic's
21
+ # primary-agent assignment?", taking a raw trigger payload.
22
+ #
23
+ # Three paths re-ask this question about a waiting task — promotion
24
+ # (AgentOrchestrator.revalidate_assignment!), the resumed AiAgentJob, and
25
+ # approval resumption through it — and each one cancels the task on a
26
+ # "no". They must ask the same question, because a direct @mention
27
+ # outranks the assignment and coalescing moves that mention out of the
28
+ # anchor and into "merged_comment_ids": judging the anchor alone cancels a
29
+ # task that WAS explicitly addressed, and discards every comment it had
30
+ # absorbed along with it. The mention still reaches the agent
31
+ # (MergedTriggerComments folds it into the trigger), so it still counts.
32
+ def self.permits_assignment?(context, agent)
33
+ return true if new(SystemEvents::ContextBuilder.new(context).build)
34
+ .assignment_permits?(agent)
35
+
36
+ merged_ids = Array(context[TaskCoalescer::PAYLOAD_KEY]).compact
37
+ return false if merged_ids.empty?
38
+
39
+ # Scoped to the turn, through the same predicate that decides what is
40
+ # delivered. A merged mention moved to another creative while the task
41
+ # waited is deliberately kept out of the trigger, so it cannot also be
42
+ # the reason the turn is permitted: that would let a non-primary agent
43
+ # answer an ambient anchor with no in-scope mention anywhere.
44
+ AiAgent::MergedTriggerComments.in_turn(merged_ids, context).pluck(:content).any? do |content|
45
+ probe = context.merge("chat" => { "content" => content })
46
+ new(SystemEvents::ContextBuilder.new(probe).build).assignment_permits?(agent)
47
+ end
48
+ end
49
+
50
+ # The permission every routing path in #match is gated on, asked on its
51
+ # own — for a caller that names its agent instead of matching one.
52
+ # DeliveryRecord.restore! is that caller: it puts back the dispatch this
53
+ # matcher once produced, and nothing else on its way re-asks this.
54
+ def self.permits_creative_access?(context, agent)
55
+ new(SystemEvents::ContextBuilder.new(context).build).has_creative_permission?(agent)
56
+ end
57
+
18
58
  def initialize(context)
19
59
  @context = context
20
60
  end
@@ -29,10 +69,60 @@ module Collavre
29
69
  mentioned_result = match_by_mention
30
70
  return mentioned_result unless mentioned_result.nil?
31
71
 
32
- # Priority 2: Liquid expression routing (fallback)
72
+ # Priority 2: Topic primary agent assignment (exclusive)
73
+ primary_result = match_by_primary_agent
74
+ return primary_result unless primary_result.nil?
75
+
76
+ # Priority 3: Liquid expression routing (fallback)
33
77
  match_by_expression
34
78
  end
35
79
 
80
+ # May this agent still take the floor in this topic, given the topic's
81
+ # CURRENT primary-agent assignment?
82
+ #
83
+ # #match runs when an event is dispatched, but the agent it selects can
84
+ # reach execution much later: a :deferred decision parks a queued Task
85
+ # until the topic's blocker finishes, and a :delayed one sleeps in the job
86
+ # queue. Neither path re-matches — AiAgentJob#perform runs the agent the
87
+ # decision named — so an assignment created or moved during that window
88
+ # would be bypassed by work scheduled moments before it, and a demoted
89
+ # agent would speak in a topic that is now exclusively someone else's.
90
+ #
91
+ # Mirrors #match's precedence rather than re-deriving it: review and
92
+ # mention are exclusive routings that outrank the assignment (see
93
+ # #match_by_primary_agent), so those dispatches stay valid. Expression
94
+ # routing does not, so it is revalidated. Only the assignment is rechecked
95
+ # here — re-running the whole match would also re-evaluate each
96
+ # routing_expression against the refreshed comment, silently dropping
97
+ # deferred work for reasons that have nothing to do with the assignment.
98
+ def assignment_permits?(agent)
99
+ primary_id = matched_topic&.primary_agent_id
100
+ return true if primary_id.nil? || primary_id == agent.id
101
+
102
+ # A review can only ever be applied in place by the author of the quoted
103
+ # comment; no assignment can transfer it, so the alternative to running
104
+ # it is not "the primary answers instead" but a dead Review button.
105
+ return true if matched_comment&.review_message? &&
106
+ matched_comment.quoted_comment&.user_id == agent.id
107
+
108
+ mentioned_id = @context.dig("chat", "mentioned_user", "id") ||
109
+ @context.dig(:chat, :mentioned_user, :id)
110
+ mentioned_id.present? && mentioned_id.to_i == agent.id
111
+ end
112
+
113
+ # Public because #match is not the only door onto a dispatch: a restore
114
+ # names the agent the match once produced, and this is the gate every
115
+ # branch of #match shares. One predicate, so the two doors cannot come to
116
+ # disagree about what "may answer this creative" means.
117
+ def has_creative_permission?(agent)
118
+ # All agents need feedback permission on the creative to respond
119
+ # searchable only affects discoverability, not response permission
120
+ creative = matched_creative
121
+ return false unless creative
122
+
123
+ creative.has_permission?(agent, :feedback)
124
+ end
125
+
36
126
  private
37
127
 
38
128
  # Returns [author] for a routable review message, [] for an unroutable one,
@@ -92,26 +182,60 @@ module Collavre
92
182
  [ mentioned_user ]
93
183
  end
94
184
 
185
+ # Returns [primary_agent] when the topic has one, nil when it does not.
186
+ #
187
+ # A topic's primary agent is an exclusive assignment: it is the only agent
188
+ # that speaks on ambient events in that topic. This deliberately overrides
189
+ # each agent's own routing_expression in BOTH directions:
190
+ #
191
+ # - The primary speaks even with no routing_expression (or one that
192
+ # evaluates false), so pinning an agent is enough to talk to it.
193
+ # - Every other agent stays silent even when its expression matches, so a
194
+ # project-wide roster of agents does not all pile into one task.
195
+ #
196
+ # Other agents are not muted, only demoted to explicit invitation: an
197
+ # @mention routes to them via #match_by_mention, which runs first.
198
+ #
199
+ # Returning [] (rather than nil) when the primary is ineligible is
200
+ # intentional — falling through to expression routing would let exactly
201
+ # the agents this assignment excludes take the floor instead.
202
+ def match_by_primary_agent
203
+ agent = matched_topic&.primary_agent
204
+ return nil unless agent
205
+
206
+ return [] unless agent.ai_user?
207
+ return [] unless has_creative_permission?(agent)
208
+ return [] unless eligible_in_inbox?(agent)
209
+
210
+ [ agent ]
211
+ end
212
+
95
213
  def match_by_expression
96
- # Find all AI agents with routing expressions
97
- # Order by id for consistent ordering (important for round_robin strategy)
98
- agents = User.where.not(llm_vendor: nil).where.not(routing_expression: [ nil, "" ]).order(:id)
214
+ # A Claude Channel agent is ambiently routable only while its cable
215
+ # subscription has a live presence row. Its routing_expression remains
216
+ # available for an explicitly configured Liquid rule, but is never used
217
+ # as a session-liveness flag.
218
+ live_claude_agent_ids = AgentSubscription.live.pluck(:agent_id)
219
+ expression_agents = User.where.not(llm_vendor: nil)
220
+ .where.not(routing_expression: [ nil, "" ])
221
+ agents = expression_agents.or(User.where(id: live_claude_agent_ids)).order(:id)
99
222
 
100
223
  agents.select do |agent|
101
224
  next false unless has_creative_permission?(agent)
102
225
  next false unless eligible_in_inbox?(agent)
226
+ next false if agent.claude_channel_agent? && !live_claude_agent_ids.include?(agent.id)
103
227
 
104
- evaluate_routing_expression(agent)
228
+ agent.routing_expression.blank? || evaluate_routing_expression(agent)
105
229
  end
106
230
  end
107
231
 
108
232
  # A Claude Channel session agent holds inbox-wide :feedback +
109
- # routing_expression="true", so within the user's Inbox it would otherwise
233
+ # a live presence row, so within the user's Inbox it would otherwise
110
234
  # match EVERY topic. Confine it to its own registered session topic (the
111
235
  # topic it is primary_agent on, carrying a session_id) so ordinary inbox
112
236
  # topics — Main, Content, user threads — stay identical to a normal topic
113
237
  # and are never absorbed by a live session. Only the inbox is affected: on
114
- # work/project creatives the agent still matches via routing_expression.
238
+ # work/project creatives the agent still matches while it is live.
115
239
  def eligible_in_inbox?(agent)
116
240
  return true unless matched_creative&.inbox?
117
241
  return true unless agent.claude_channel_agent?
@@ -139,15 +263,6 @@ module Collavre
139
263
  false
140
264
  end
141
265
 
142
- def has_creative_permission?(agent)
143
- # All agents need feedback permission on the creative to respond
144
- # searchable only affects discoverability, not response permission
145
- creative = matched_creative
146
- return false unless creative
147
-
148
- creative.has_permission?(agent, :feedback)
149
- end
150
-
151
266
  def matched_creative
152
267
  return @matched_creative if defined?(@matched_creative)
153
268
 
@@ -28,6 +28,13 @@ module Collavre
28
28
  "rate_limit_per_minute" => 20,
29
29
  "backoff_strategy" => "exponential",
30
30
  "topic_max_concurrent_jobs" => 1,
31
+ # Fold un-started tasks for the same agent/topic/creative into one so a
32
+ # burst of comments produces one answer instead of one per comment.
33
+ "coalesce_pending_tasks" => true,
34
+ # Drop a dispatch whose comment an in-flight turn has already been
35
+ # given, instead of parking it as a waiter (see
36
+ # Orchestration::DeliveryRecord).
37
+ "drop_delivered_dispatches" => true,
31
38
  # Loop breaker settings
32
39
  "loop_breaker_enabled" => true,
33
40
  "ping_pong_threshold" => 5, # Max back-and-forth between same agents
@@ -38,9 +45,8 @@ module Collavre
38
45
  "token_spike_window_minutes" => 10
39
46
  },
40
47
  "stuck_detection" => {
41
- "enabled" => false,
48
+ "enabled" => true,
42
49
  "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
43
- "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
44
50
  "queued_orphan_threshold_minutes" => 5, # Queued waiter with no live blocker for > N minutes
45
51
  "create_system_comment" => true # Create system comment on escalation
46
52
  },
@@ -87,6 +93,43 @@ module Collavre
87
93
  scheduling_config["topic_max_concurrent_jobs"]
88
94
  end
89
95
 
96
+ # Whether un-started tasks for the same agent/topic/creative are folded
97
+ # into a single one (see Orchestration::TaskCoalescer).
98
+ #
99
+ # Resolved per agent, because that is the granularity of the thing being
100
+ # switched: coalescing folds *same-agent* siblings, and a User-scoped
101
+ # scheduling policy is how an administrator turns it off for one agent
102
+ # without touching the others in the topic. #scheduling_config excludes
103
+ # agent policies by construction, so asking it here silently ignored that
104
+ # opt-out on every enqueue, promotion, start and notice path.
105
+ #
106
+ # There is deliberately no agent-less variant to fall back to: a second
107
+ # door onto this question is how the two answers drift, and every caller
108
+ # has the agent to hand. `nil` is accepted only for a dispatch with no
109
+ # agent resolved yet, where the global answer is the only one there is.
110
+ def coalesce_pending_tasks_for?(agent)
111
+ config = agent ? scheduling_config_for(agent) : scheduling_config
112
+ config["coalesce_pending_tasks"] != false
113
+ end
114
+
115
+ # Whether a dispatch is dropped outright when an in-flight turn has
116
+ # already delivered its comment (see Orchestration::DeliveryRecord).
117
+ #
118
+ # Resolved per agent for the same reason coalescing is: delivery is a
119
+ # same-agent question, and a User-scoped scheduling policy is how an
120
+ # administrator turns this off for one agent without touching the others
121
+ # in the topic.
122
+ #
123
+ # Only the *drop* is switched. The record itself keeps being written
124
+ # whatever this says — it is evidence of what an agent was sent, and the
125
+ # promotion door reads it to keep a parked waiter from replaying a
126
+ # delivered comment. Gating the write here would make turning the switch
127
+ # off mid-burst leave that door reading a record with a hole in it.
128
+ def drop_delivered_dispatches_for?(agent)
129
+ config = agent ? scheduling_config_for(agent) : scheduling_config
130
+ config["drop_delivered_dispatches"] != false
131
+ end
132
+
90
133
  # Convenience methods
91
134
  def arbitration_strategy
92
135
  return "primary_first" if topic_primary_agent_id.present?
@@ -102,6 +145,19 @@ module Collavre
102
145
  topic_primary_agent_id || arbitration_config["primary_agent_id"]
103
146
  end
104
147
 
148
+ # Read primary_agent_id directly from the topics table.
149
+ #
150
+ # Public because the two sources of a primary agent carry different
151
+ # strength: a topic-column assignment is exclusive, while a policy-level
152
+ # primary_agent_id is only a preference. The Arbiter needs to tell them
153
+ # apart even though #primary_agent_id collapses both.
154
+ def topic_primary_agent_id
155
+ topic_id = @context.dig("topic", "id")
156
+ return nil unless topic_id
157
+
158
+ Topic.where(id: topic_id).pick(:primary_agent_id)
159
+ end
160
+
105
161
  # Bid strategy specific
106
162
  def confidence_threshold
107
163
  arbitration_config["confidence_threshold"]
@@ -163,14 +219,6 @@ module Collavre
163
219
 
164
220
  config
165
221
  end
166
-
167
- # Read primary_agent_id directly from the topics table
168
- def topic_primary_agent_id
169
- topic_id = @context.dig("topic", "id")
170
- return nil unless topic_id
171
-
172
- Topic.where(id: topic_id).pick(:primary_agent_id)
173
- end
174
222
  end
175
223
  end
176
224
  end
@@ -31,10 +31,10 @@ module Collavre
31
31
  # @param agents [Array<User>] Agents selected by Arbiter
32
32
  # @return [Array<Hash>] Scheduling decisions
33
33
  # Each decision: { agent:, timing:, delay: (optional), reason: (optional) }
34
- def schedule(agents)
34
+ def schedule(agents, scheduling_hooks: nil)
35
35
  topic_immediate_count = 0
36
36
  agents.map do |agent|
37
- decision = evaluate(agent, topic_immediate_count: topic_immediate_count)
37
+ decision = evaluate(agent, topic_immediate_count: topic_immediate_count, scheduling_hooks: scheduling_hooks)
38
38
  topic_immediate_count += 1 if decision[:timing] == :immediate
39
39
  decision
40
40
  end
@@ -42,13 +42,13 @@ module Collavre
42
42
 
43
43
  private
44
44
 
45
- def evaluate(agent, topic_immediate_count: 0)
45
+ def evaluate(agent, topic_immediate_count: 0, scheduling_hooks: nil)
46
46
  tracker = ResourceTracker.for(agent)
47
47
  config = @policy_resolver.scheduling_config_for(agent)
48
48
 
49
49
  # Check 0: Loop breaker
50
50
  if @policy_resolver.loop_breaker_enabled?
51
- loop_result = LoopBreaker.new(@context, policy_resolver: @policy_resolver).check
51
+ loop_result = loop_breaker_result(agent, scheduling_hooks)
52
52
  if loop_result.should_break?
53
53
  return loop_broken_decision(agent, loop_result)
54
54
  end
@@ -86,6 +86,12 @@ module Collavre
86
86
  immediate_decision(agent)
87
87
  end
88
88
 
89
+ def loop_breaker_result(agent, scheduling_hooks)
90
+ LoopBreaker.new(@context, policy_resolver: @policy_resolver).check(
91
+ prospective_interaction: scheduling_hooks&.interaction_for(agent)
92
+ )
93
+ end
94
+
89
95
  def immediate_decision(agent)
90
96
  {
91
97
  agent: agent,
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ SchedulingHooks = Data.define(:interaction_callback, :scheduled_callback) do
6
+ def interaction_for(agent) = interaction_callback&.call(agent)
7
+ def scheduled(agents) = scheduled_callback&.call(agents)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # Resolves the exact responders for an event without scheduling work.
6
+ class Selection
7
+ attr_reader :agents
8
+
9
+ def initialize(context, policy_resolver:, candidate_overrides: {})
10
+ @context = context
11
+ @policy_resolver = policy_resolver
12
+ @candidate_overrides = candidate_overrides
13
+ end
14
+
15
+ def call
16
+ candidates = candidates_for_contexts
17
+ @agents = [] if candidates.empty?
18
+ return self if candidates.empty?
19
+
20
+ @arbiter = Arbiter.new(@context, policy_resolver: @policy_resolver)
21
+ @agents = @arbiter.select(candidates, commit: false)
22
+ self
23
+ end
24
+
25
+ def commit!
26
+ @arbiter&.commit_selection!
27
+ self
28
+ end
29
+
30
+ private
31
+
32
+ def candidates_for_contexts
33
+ candidates = Matcher.new(@context).match
34
+ @candidate_overrides.each do |agent_id, override|
35
+ candidates = candidates.reject { |agent| agent.id == agent_id }
36
+ overridden = Matcher.new(@context.deep_merge(override.deep_stringify_keys)).match
37
+ candidates.concat(overridden.select { |agent| agent.id == agent_id })
38
+ end
39
+ candidates.uniq(&:id).sort_by(&:id)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,15 +2,14 @@
2
2
 
3
3
  module Collavre
4
4
  module Orchestration
5
- # StuckDetector detects stuck tasks and creatives, then auto-escalates to admins.
5
+ # StuckDetector detects stuck tasks, auto-recovers them, and escalates to admins.
6
6
  #
7
7
  # Detection conditions:
8
8
  # 1. Running tasks that haven't progressed for N minutes
9
9
  # 2. Tasks with too many retries (escalated but not handled)
10
- # 3. Creatives with no progress for extended periods
11
10
  #
12
11
  # Auto-escalation:
13
- # - Creates InboxItem for admin users
12
+ # - Creates inbox comments for admin users
14
13
  # - Optionally creates a system comment
15
14
  #
16
15
  class StuckDetector
@@ -33,7 +32,6 @@ module Collavre
33
32
  stuck_items = []
34
33
  stuck_items.concat(detect_stuck_tasks(config))
35
34
  stuck_items.concat(detect_orphaned_queued_tasks(config))
36
- stuck_items.concat(detect_stalled_creatives(config))
37
35
 
38
36
  auto_recover_stuck_tasks(stuck_items)
39
37
  escalated_count = escalate_stuck_items(stuck_items, config)
@@ -49,7 +47,6 @@ module Collavre
49
47
  stuck_items = []
50
48
  stuck_items.concat(detect_stuck_tasks(config))
51
49
  stuck_items.concat(detect_orphaned_queued_tasks(config))
52
- stuck_items.concat(detect_stalled_creatives(config))
53
50
  stuck_items
54
51
  end
55
52
 
@@ -133,7 +130,14 @@ module Collavre
133
130
  task = stuck_item.item
134
131
  return unless %w[running delegated].include?(task.status)
135
132
 
136
- task.update!(status: "failed")
133
+ recovered = if task.status == "running"
134
+ DeliveryRecord.fail_while_worker_settles!(task)
135
+ else
136
+ task.update!(status: "failed")
137
+ true
138
+ end
139
+ return unless recovered
140
+
137
141
  Rails.logger.info(
138
142
  "[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
139
143
  "marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
@@ -145,23 +149,6 @@ module Collavre
145
149
  tracker.release!(task.id)
146
150
  end
147
151
 
148
- # If this was a workflow subtask, fail the parent so the workflow
149
- # advances instead of staying running with pending_creative_ids
150
- # pointing at a child that's been failed underneath it.
151
- if task.parent_task_id.present?
152
- begin
153
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
154
- task,
155
- error_message: "Auto-recovered: stuck for " \
156
- "#{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
157
- )
158
- rescue StandardError => e
159
- Rails.logger.error(
160
- "[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
161
- )
162
- end
163
- end
164
-
165
152
  # Drain the queue for the topic so waiting tasks can execute
166
153
  AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
167
154
  rescue StandardError => e
@@ -184,7 +171,6 @@ module Collavre
184
171
  .where("updated_at < ?", threshold_time)
185
172
 
186
173
  stuck_tasks.filter_map do |task|
187
- # Skip if already escalated recently
188
174
  next if recently_escalated?(task)
189
175
 
190
176
  escalation_targets = find_escalation_targets(task)
@@ -232,45 +218,6 @@ module Collavre
232
218
  end
233
219
  end
234
220
 
235
- # Detect creatives that have stalled (no activity for extended period)
236
- def detect_stalled_creatives(config)
237
- threshold_minutes = config["creative_stall_threshold_minutes"] || 120
238
- threshold_time = threshold_minutes.minutes.ago
239
-
240
- # Find creatives with incomplete status but no recent activity
241
- # Only check creatives that have at least one AI agent with access
242
- stalled = []
243
-
244
- Creative.where("progress < 1.0")
245
- .where("updated_at < ?", threshold_time)
246
- .includes(creative_shares: :user)
247
- .find_each do |creative|
248
- # Skip if no AI agents have access
249
- ai_agents = creative.creative_shares
250
- .reject { |s| s.permission == "no_access" }
251
- .map(&:user)
252
- .select(&:ai_user?)
253
-
254
- next if ai_agents.empty?
255
-
256
- # Skip if already escalated recently
257
- next if recently_escalated_creative?(creative)
258
-
259
- escalation_targets = find_creative_escalation_targets(creative)
260
- next if escalation_targets.empty?
261
-
262
- stalled << StuckItem.new(
263
- type: :creative,
264
- item: creative,
265
- reason: :stalled,
266
- stuck_since: creative.updated_at,
267
- escalation_targets: escalation_targets
268
- )
269
- end
270
-
271
- stalled
272
- end
273
-
274
221
  def find_escalation_targets(task)
275
222
  # Find admin users for the task's creative
276
223
  creative_id = task.trigger_event_payload&.dig("creative", "id")
@@ -287,7 +234,7 @@ module Collavre
287
234
  ancestor_ids = [ creative.id ] + creative.ancestor_ids
288
235
  admin_users = CreativeShare.where(creative_id: ancestor_ids, permission: "admin")
289
236
  .includes(:user)
290
- .filter_map { |share| share.user unless share.user.ai_user? }
237
+ .filter_map { |share| share.user if share.user && !share.user.ai_user? }
291
238
 
292
239
  # Also include the creative owner
293
240
  admin_users << creative.user if creative.user && !creative.user.ai_user?
@@ -301,18 +248,9 @@ module Collavre
301
248
  Rails.cache.exist?(cache_key)
302
249
  end
303
250
 
304
- def recently_escalated_creative?(creative)
305
- cache_key = "stuck_detector:creative:#{creative.id}"
306
- Rails.cache.exist?(cache_key)
307
- end
308
-
309
251
  def mark_escalated(item)
310
- cache_key = case item.type
311
- when :task then "stuck_detector:task:#{item.item.id}"
312
- when :creative then "stuck_detector:creative:#{item.item.id}"
313
- end
314
252
  # Don't re-escalate for 1 hour
315
- Rails.cache.write(cache_key, true, expires_in: 1.hour)
253
+ Rails.cache.write("stuck_detector:task:#{item.item.id}", true, expires_in: 1.hour)
316
254
  end
317
255
 
318
256
  def escalate_stuck_items(stuck_items, config)
@@ -339,9 +277,7 @@ module Collavre
339
277
  end
340
278
 
341
279
  # Optionally create a system comment
342
- if config["create_system_comment"] && stuck_item.type == :task
343
- create_system_comment(stuck_item)
344
- end
280
+ create_system_comment(stuck_item) if config["create_system_comment"]
345
281
 
346
282
  true
347
283
  rescue StandardError => e
@@ -352,13 +288,8 @@ module Collavre
352
288
  def create_inbox_notification(owner, stuck_item)
353
289
  message_key, message_params = build_message(stuck_item)
354
290
 
355
- creative = case stuck_item.type
356
- when :task
357
- creative_id = stuck_item.item.trigger_event_payload&.dig("creative", "id")
358
- Creative.find_by(id: creative_id)
359
- when :creative
360
- stuck_item.item
361
- end
291
+ creative_id = stuck_item.item.trigger_event_payload&.dig("creative", "id")
292
+ creative = Creative.find_by(id: creative_id)
362
293
 
363
294
  inbox_creative = Creative.inbox_for(owner)
364
295
  system_topic = inbox_creative.system_topic(fallback_user: owner)
@@ -377,37 +308,19 @@ module Collavre
377
308
  end
378
309
 
379
310
  def build_message(stuck_item)
380
- case stuck_item.type
381
- when :task
382
- task = stuck_item.item
383
- minutes_stuck = ((Time.current - stuck_item.stuck_since) / 60).round
384
-
385
- [
386
- "collavre.stuck_detection.task_stuck",
387
- {
388
- task_name: task.name,
389
- agent_name: task.agent&.display_name || "Unknown",
390
- minutes: minutes_stuck
391
- }
392
- ]
393
- when :creative
394
- creative = stuck_item.item
395
- hours_stuck = ((Time.current - stuck_item.stuck_since) / 3600).round
396
-
397
- [
398
- "collavre.stuck_detection.creative_stalled",
399
- {
400
- creative_title: creative.creative_snippet,
401
- hours: hours_stuck,
402
- progress: ((creative.progress || 0) * 100).round
403
- }
404
- ]
405
- end
311
+ task = stuck_item.item
312
+ minutes_stuck = ((Time.current - stuck_item.stuck_since) / 60).round
313
+ [
314
+ "collavre.stuck_detection.task_stuck",
315
+ {
316
+ task_name: task.name,
317
+ agent_name: task.agent&.display_name || "Unknown",
318
+ minutes: minutes_stuck
319
+ }
320
+ ]
406
321
  end
407
322
 
408
323
  def create_system_comment(stuck_item)
409
- return unless stuck_item.type == :task
410
-
411
324
  task = stuck_item.item
412
325
  creative_id = task.trigger_event_payload&.dig("creative", "id")
413
326
  creative = Creative.find_by(id: creative_id)