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
@@ -1,51 +1,91 @@
1
1
  module Collavre
2
2
  class AiAgentJob < ApplicationJob
3
- queue_as :default
3
+ queue_as :ai_agents
4
4
 
5
5
  # Allow resuming a task that was pending approval
6
6
  def perform(agent_id_or_task, event_name = nil, context = nil)
7
7
  if agent_id_or_task.is_a?(Task)
8
8
  # Resume existing task
9
9
  task = agent_id_or_task
10
- return if task.reload.status == "cancelled"
10
+ if task.reload.status == "cancelled"
11
+ # A cancelled task can still be holding the topic slot. Promotion moves
12
+ # a waiter queued -> pending and enqueues this job, and the whole gap
13
+ # from that claim until this line is one where deleting the comment it
14
+ # answers cancels it (Comment#cancel_pending_tasks covers `pending`).
15
+ # Returning bare hands the slot to nobody, so the waiters behind it sit
16
+ # queued until orphan recovery. Same reason the fold's own cancelled
17
+ # branch below drains — this is the earlier door onto it.
18
+ if task.trigger_event_payload&.key?("topic")
19
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
20
+ end
21
+ return
22
+ end
11
23
 
12
24
  agent = task.agent
13
25
 
14
26
  # Guard: same offline-session check as the agent_id branch below.
15
27
  # Queued Claude Channel tasks resumed via Orchestration::AgentOrchestrator
16
28
  # .dequeue_next_for_topic enter this branch as AiAgentJob.perform_later(task).
17
- # If AgentChannel#unsubscribed cleared routing_expression while the
29
+ # If AgentChannel#unsubscribed removed the last presence row while the
18
30
  # task was queued (WS drop without DELETE /agent/:id, e.g. SIGKILL or
19
31
  # network blip past the reconnect grace), and another completion later
20
32
  # drains the queue, this task would otherwise be promoted to running →
21
33
  # delegated and broadcast to a clientless agent:user:<id> stream —
22
34
  # held until stuck recovery.
23
- if agent.claude_channel_agent? && agent.routing_expression.blank?
35
+ if agent.claude_channel_agent? && !agent.claude_channel_online?
24
36
  Rails.logger.info(
25
37
  "[AiAgentJob] Skipping resumed Claude Channel task #{task.id}: " \
26
- "session offline (routing_expression blank)"
38
+ "session offline (no live presence)"
27
39
  )
28
- # Workflow subtasks created by WorkflowExecutor carry parent_task_id and
29
- # no topic. If we only cancel the child and return, the parent workflow
30
- # stays "running" with its current/pending creative state forever — no
31
- # rescue path runs because we never raise. Mirror the StandardError
32
- # rescue below: fail the child and notify the parent so the workflow
33
- # transitions to "failed" with a failure_reason.
34
- if task.parent_task_id.present?
35
- task.update!(status: "failed")
36
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
37
- task,
38
- error_message: "Claude Channel session offline before dispatch"
39
- )
40
- else
41
- task.update!(status: "cancelled")
42
- end
40
+ task.update!(status: "cancelled")
43
41
  if task.trigger_event_payload&.key?("topic")
44
42
  Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
45
43
  end
46
44
  return
47
45
  end
48
46
 
47
+ # A claimed task holds the topic slot as `pending` from promotion until
48
+ # this line, and a comment arriving in that window parks a waiter that
49
+ # enqueue-time coalescing cannot fold into a non-`queued` sibling. Both
50
+ # turns are still un-started here, so this is the last place the fold
51
+ # can happen — and it must run before the assignment guard below, which
52
+ # reads the payload this may re-anchor.
53
+ Orchestration::AgentOrchestrator.coalesce_at_start!(task)
54
+ if task.reload.status == "cancelled"
55
+ # The fold's re-anchor found no comment left to answer. A `pending`
56
+ # task holds no ResourceTracker reservation yet (reserve! is below),
57
+ # but it does hold the topic slot — drain or the queue stalls.
58
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
59
+ return
60
+ end
61
+
62
+ # Guard: an exclusive primary-agent assignment can also land after a task
63
+ # has already been cleared for execution, so checking at enqueue time is
64
+ # not sufficient. AgentOrchestrator.dequeue_next_for_topic revalidates and
65
+ # then enqueues asynchronously — a pin created in that window is missed —
66
+ # and approval resumption (Comments::ActionExecutor) re-enqueues a
67
+ # pending_approval task with no check at all, after a pause that can last
68
+ # as long as the human takes to approve. Re-check at the moment of
69
+ # execution so a demoted agent cannot answer in a topic that is now
70
+ # exclusively someone else's.
71
+ resumed_context = task.trigger_event_payload
72
+ if resumed_context&.key?("topic") &&
73
+ !Orchestration::Matcher.permits_assignment?(resumed_context, agent)
74
+ Rails.logger.info(
75
+ "[AiAgentJob] Cancelling resumed task #{task.id}: topic #{task.topic_id} " \
76
+ "is now assigned to another agent (agent=#{agent.id})"
77
+ )
78
+ task.update!(status: "cancelled")
79
+ # A pending_approval task kept its slot across the pause (the
80
+ # ApprovalPendingError rescue sets should_release = false), and this
81
+ # early return skips the ensure block that would give it back, so
82
+ # release explicitly. release! is idempotent, so a queued task that
83
+ # never reserved one is unaffected.
84
+ Orchestration::ResourceTracker.for(agent).release!(task.id)
85
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
86
+ return
87
+ end
88
+
49
89
  task.update!(status: "running")
50
90
  else
51
91
  # Create new task
@@ -53,17 +93,33 @@ module Collavre
53
93
 
54
94
  # Guard: skip if the Claude Channel session has unregistered (or its WS
55
95
  # dropped) during the window between Scheduler enqueue and this job
56
- # firing. AgentsController#destroy / AgentChannel#unsubscribed clear
57
- # routing_expression on the per-session ai_user, so a blank value here
58
- # means there is no live MCP client to receive the dispatch. Without
96
+ # firing. AgentsController#destroy / AgentChannel#unsubscribed remove
97
+ # the last presence row, so no live presence here means there is no MCP
98
+ # client to receive the dispatch. Without
59
99
  # this guard, a :delayed (busy / rate-limited) enqueue from
60
100
  # Scheduler#evaluate would materialize a fresh Task, flip it to
61
101
  # "delegated", and broadcast to a clientless agent:user:<id> stream
62
102
  # — holding the topic/agent slot until stuck recovery.
63
- if agent.claude_channel_agent? && agent.routing_expression.blank?
103
+ if agent.claude_channel_agent? && !agent.claude_channel_online?
64
104
  Rails.logger.info(
65
105
  "[AiAgentJob] Skipping Claude Channel job for agent #{agent.id}: " \
66
- "session offline (routing_expression blank, event=#{event_name})"
106
+ "session offline (no live presence, event=#{event_name})"
107
+ )
108
+ return
109
+ end
110
+
111
+ # Guard: an exclusive primary-agent assignment created (or moved) after
112
+ # this dispatch was scheduled must still bind it. A :delayed decision
113
+ # (agent busy / rate limited) sits in the job queue with its agent already
114
+ # chosen and is never re-matched, so without this a demoted agent speaks
115
+ # in a topic that now belongs to someone else. Unlike a queued waiter
116
+ # there is no Task yet to cancel, so cancelling on assignment change
117
+ # cannot cover this path — the check has to happen here.
118
+ if context && !Orchestration::Matcher.new(context).assignment_permits?(agent)
119
+ Rails.logger.info(
120
+ "[AiAgentJob] Skipping job for agent #{agent.id}: topic " \
121
+ "#{context.dig('topic', 'id')} is now assigned to another agent " \
122
+ "(event=#{event_name})"
67
123
  )
68
124
  return
69
125
  end
@@ -78,25 +134,31 @@ module Collavre
78
134
  return
79
135
  end
80
136
 
81
- task = Task.create!(
82
- name: "Response to #{event_name}",
83
- status: "running",
84
- trigger_event_name: event_name,
85
- trigger_event_payload: context,
86
- agent: agent,
87
- topic_id: context&.dig("topic", "id"),
88
- creative_id: context&.dig("creative", "id")
89
- )
90
-
91
- # Record task for loop breaker tracking (per-topic, skip user-initiated)
92
- creative_id = context&.dig("creative", "id")
93
- if creative_id
94
- from_ai = context&.dig("comment", "from_ai") == true
95
- topic_id = context&.dig("topic", "id")
96
- Orchestration::LoopBreaker.new(context).record_task(
97
- creative_id, agent.id, topic_id: topic_id, triggered_by_user: !from_ai
137
+ # Guard: the same question the orchestrator asks before enqueueing, asked
138
+ # again where the answer is authoritative. This job can sit in the queue
139
+ # while the covering turn assembles its payload, so a dispatch that was
140
+ # not droppable at enqueue time can be droppable by the time it runs —
141
+ # and the enqueue door alone would leave that window open.
142
+ #
143
+ # As at the enqueue door, the drop only happens if the covering turn
144
+ # records it — otherwise this dispatch has nobody to restore it.
145
+ covering = Orchestration::DeliveryRecord.covering_task(agent, comment_id, context, event_name)
146
+ if covering && Orchestration::DeliveryRecord.claim_drop!(covering, comment_id)
147
+ Rails.logger.info(
148
+ "[AiAgentJob] Skipping dispatch: comment #{comment_id} was already delivered to " \
149
+ "agent #{agent.id} by in-flight task #{covering.id} (event=#{event_name})"
98
150
  )
151
+ return
99
152
  end
153
+
154
+ # Guard: the Scheduler's topic-concurrency check counts Task rows, but
155
+ # the row for an :immediate decision is only created *here*. A burst of
156
+ # comments dispatched before the first job runs therefore all see an
157
+ # empty topic and are all judged :immediate — several turns run at once
158
+ # in a topic limited to one. Re-check at the moment the row is created,
159
+ # where the answer is authoritative, and defer into the queue instead.
160
+ task = admit_or_defer!(agent, event_name, context)
161
+ return if task.nil?
100
162
  end
101
163
 
102
164
  # Reserve resources before starting work
@@ -138,60 +200,188 @@ module Collavre
138
200
  task.reload
139
201
  end
140
202
 
141
- response_content = AiAgentService.new(task).call
203
+ AiAgentService.new(task).call
142
204
 
143
205
  # Claude Channel agents delegate via MCP; no immediate response expected
144
206
  if is_claude_channel_agent
145
207
  # Hold agent capacity until reply / cancel / stuck-recovery releases it.
146
208
  should_release = false
147
- # Workflow subtasks with empty responses should retry, then fail
148
- elsif task.parent_task_id.present? && response_content.blank?
149
- max_retries = 2
150
- current_retry = task.retry_count || 0
151
-
152
- if current_retry < max_retries
153
- task.update!(retry_count: current_retry + 1, status: "pending")
154
- Rails.logger.warn(
155
- "[AiAgentJob] Workflow subtask #{task.id} returned empty response, " \
156
- "retrying (#{current_retry + 1}/#{max_retries})"
157
- )
158
- AiAgentJob.set(wait: 5.seconds).perform_later(task)
159
- else
160
- task.update!(status: "failed")
161
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
162
- task, error_message: "Agent returned empty response after #{max_retries} retries"
163
- )
164
- end
165
209
  else
166
- task.update!(status: "done")
167
- # Advance workflow (release happens in ensure block)
168
- if task.parent_task_id.present?
169
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
170
- end
210
+ transition_running_task!(task, status: "done")
171
211
  end
172
212
  rescue ApprovalPendingError
173
213
  # Task status already set to pending_approval by AiAgentService
174
214
  # Don't release resources yet - task will resume
175
215
  should_release = false
176
216
  Rails.logger.info("AiAgentJob paused for task #{task.id}: awaiting tool approval")
217
+ rescue TurnDeadlineError
218
+ # AgentLifecycleManager already wrote the terminal status inside this
219
+ # call stack. Restore/settle of dropped dispatches needs nothing extra:
220
+ # fail_while_worker_settles! marked the row, and the ensure below
221
+ # settles it.
222
+ Rails.logger.info("AiAgentJob turn deadline exceeded for task #{task.id}")
177
223
  rescue CancelledError
178
224
  # Task status already set to "cancelled" by Comment callback
179
225
  Rails.logger.info("AiAgentJob cancelled for task #{task.id}: trigger message deleted")
226
+ # Where a turn stopped mid-answer settles. The cancel was committed in
227
+ # another process while this worker was inside the provider call, so
228
+ # Task's status callback declined to decide
229
+ # (Task#ended_while_worker_settles?) and this is the first moment either
230
+ # answer exists: AiAgentService's ensure has recorded whether the
231
+ # payload got there on the way up to here.
232
+ # Reloaded because the status was written to the row by somebody else.
233
+ Orchestration::DeliveryRecord.restore_if_undelivered!(task.reload)
180
234
  rescue StandardError => e
181
235
  task.update!(status: "failed")
182
- # Fail workflow if this is a sub-task
183
- if task.parent_task_id.present?
184
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(task, error_message: e.message)
185
- end
186
236
  Rails.logger.error("AiAgentJob failed for task #{task.id}: #{e.message}")
187
237
  raise e
188
238
  ensure
189
239
  # Guarantee resource release for all paths except pending_approval
190
240
  tracker.release!(resource_id, tokens_used: 0) if should_release && tracker && resource_id
191
- if task&.trigger_event_payload&.key?("topic") && %w[done failed cancelled escalated].include?(task.reload.status)
192
- Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
241
+ settled_task = task&.reload
242
+ if settled_task &&
243
+ Orchestration::DeliveryRecord.worker_settling?(settled_task.trigger_event_payload)
244
+ # StuckDetector failed this row while this worker was still in the
245
+ # provider call. The worker is out now, so remove the deferral and ask
246
+ # the restore question from the handoff evidence AiAgentService wrote.
247
+ Orchestration::DeliveryRecord.settle_worker!(settled_task)
248
+ Orchestration::DeliveryRecord.restore_if_undelivered!(settled_task.reload)
249
+ end
250
+ if settled_task&.trigger_event_payload&.key?("topic") &&
251
+ %w[done failed cancelled escalated].include?(settled_task.status)
252
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(settled_task.topic_id, settled_task.creative_id)
193
253
  end
194
254
  end
195
255
  end
256
+
257
+ private
258
+
259
+ # A terminal status can be written by Stop/StuckDetector after the service's
260
+ # last lifecycle checkpoint but before this job records its outcome. Lock
261
+ # and re-check the row so normal completion/retry cannot overwrite that
262
+ # external winner.
263
+ def transition_running_task!(task, **attributes)
264
+ task.with_lock do
265
+ raise CancelledError unless task.status == "running"
266
+
267
+ task.update!(attributes)
268
+ end
269
+ end
270
+
271
+ # Create this dispatch's Task row, either admitted (`running`) or parked as
272
+ # a `queued` waiter when the topic's concurrency slot is already taken.
273
+ #
274
+ # Returns the admitted task, or nil when the dispatch was deferred.
275
+ def admit_or_defer!(agent, event_name, context)
276
+ attrs = {
277
+ name: "Response to #{event_name}",
278
+ trigger_event_name: event_name,
279
+ trigger_event_payload: context,
280
+ agent: agent,
281
+ topic_id: context&.dig("topic", "id"),
282
+ creative_id: context&.dig("creative", "id")
283
+ }
284
+ unless topic_admission_scoped?(context)
285
+ return Task.create!(attrs.merge(status: "running")).tap { record_loop_breaker_turn(agent, context) }
286
+ end
287
+
288
+ task, admitted, current_context = nil, false, false
289
+ # A queued row here is a waiter, and what speaks for it is settled now, with
290
+ # the row — not later, from whichever notices happen to be visible. Its
291
+ # notice is posted in a separate transaction below, so between the two this
292
+ # waiter is queued with nothing naming it, and a shared notice deleted in
293
+ # that window would read an opted-out waiter as one of its own and cancel a
294
+ # turn that was only deferred.
295
+ # Counting occupants and claiming the slot must be ONE step. Two workers
296
+ # starting within the same millisecond — the exact burst this job defends
297
+ # against — otherwise both read an occupancy below the limit before either
298
+ # inserts its `running` row, and the re-check reproduces the very TOCTOU
299
+ # race it exists to close. Serialize admission on the topic row: every
300
+ # admission for a topic takes the same lock, so the loser reads the
301
+ # winner's committed row.
302
+ Task.transaction do
303
+ next unless Orchestration::TopicSlot.lock_matches_context?(attrs[:topic_id], attrs[:creative_id])
304
+
305
+ current_context = true
306
+ admitted = Orchestration::TopicSlot.available_for?(agent.id, attrs[:topic_id], attrs[:creative_id], context)
307
+ task = Task.create!(attrs.merge(
308
+ status: admitted ? "running" : "queued",
309
+ # Left nil on an admitted row: it is not waiting, so no notice speaks
310
+ # for it and there is nothing for a stop control to represent.
311
+ waiting_notice_scope: admitted ? nil : waiter_notice_scope(agent, context)
312
+ ))
313
+ end
314
+ return unless current_context
315
+
316
+ park_deferred_waiter(task, agent, event_name, context, attrs[:topic_id], attrs[:creative_id]) unless admitted
317
+ # Count where the row is created, before an admitted turn starts and after
318
+ # a deferred turn is parked. A stale context creates no row and no count.
319
+ record_loop_breaker_turn(agent, context)
320
+ task if admitted
321
+ end
322
+
323
+ # Record a created turn for the loop breaker's creative-retry count.
324
+ # User-initiated messages are not loop material — a person typing twice is
325
+ # not a runaway — and LoopBreaker skips those itself.
326
+ def record_loop_breaker_turn(agent, context)
327
+ creative_id = context&.dig("creative", "id")
328
+ return unless creative_id
329
+
330
+ Orchestration::LoopBreaker.new(context).record_task(
331
+ creative_id, agent.id,
332
+ topic_id: context&.dig("topic", "id"),
333
+ triggered_by_user: context&.dig("comment", "from_ai") != true
334
+ )
335
+ end
336
+
337
+ # Which notice will speak for a waiter parked by this dispatch: the topic's
338
+ # shared one when this agent coalesces, its own per-deferral one when it has
339
+ # opted out. Resolved once, at the moment the waiter is created, so the fold,
340
+ # the notice and the shared notice's stop button all read one answer.
341
+ def waiter_notice_scope(agent, context)
342
+ if Orchestration::PolicyResolver.new(context).coalesce_pending_tasks_for?(agent)
343
+ Comment::WAITING_NOTICE_TOPIC
344
+ else
345
+ Comment::WAITING_NOTICE_TASK
346
+ end
347
+ end
348
+
349
+ # Is this dispatch subject to the topic concurrency limit at all?
350
+ # Topic-less dispatches are not.
351
+ def topic_admission_scoped?(context)
352
+ context.is_a?(Hash) && context.key?("topic")
353
+ end
354
+
355
+ # Finish parking a waiter created by #admit_or_defer!: fold it together with
356
+ # any waiters already parked for the same agent/topic/creative, and surface
357
+ # one "⏳" notice for the topic.
358
+ def park_deferred_waiter(waiter, agent, event_name, context, topic_id, creative_id)
359
+ # Read off the waiter rather than resolved again: folding and the notice are
360
+ # the same policy answer, and asking twice is how they come to disagree
361
+ # about a wait whose policy changed in between.
362
+ if waiter.waiting_notice_scope == Comment::WAITING_NOTICE_TOPIC
363
+ Orchestration::TaskCoalescer.coalesce!(waiter)
364
+ end
365
+ Orchestration::AgentOrchestrator.post_topic_concurrency_notice(
366
+ creative_id, topic_id, context, agent: agent, waiter: waiter
367
+ )
368
+
369
+ Rails.logger.info(
370
+ "[AiAgentJob] Deferred agent #{agent.id} into topic #{topic_id} queue as task " \
371
+ "#{waiter.id}: slot already occupied (event=#{event_name})"
372
+ )
373
+
374
+ # A holder may have finished between the admission check and this point,
375
+ # leaving a waiter with nobody left to drain it (its
376
+ # dequeue_next_for_topic already ran). Drain unconditionally: this
377
+ # deferral is not proof the topic is full — with topic_max > 1 an agent
378
+ # defers because *it* is busy while a slot sits free for someone else, and
379
+ # gating on "no occupants at all" would strand that waiter until the
380
+ # unrelated holder finishes. dequeue_next_for_topic re-checks capacity and
381
+ # eligibility under the admission lock, so an over-eager call is a no-op.
382
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(topic_id, creative_id)
383
+
384
+ nil
385
+ end
196
386
  end
197
387
  end
@@ -5,15 +5,15 @@ module Collavre
5
5
  # session whose WebSocket dropped without DELETE /api/v1/agent/:id.
6
6
  #
7
7
  # AgentChannel#unsubscribed makes the agent unroutable, but a task already
8
- # in "delegated" still holds its ResourceTracker slot and (for workflow
9
- # subtasks) blocks the parent workflow. The dispatch was broadcast to a
10
- # now-dead stream, so no client remains to call /reply — without this job
8
+ # in "delegated" still holds its ResourceTracker slot. The dispatch was
9
+ # broadcast to a now-dead stream, so no client remains to call /reply —
10
+ # without this job
11
11
  # the slot stays held until StuckDetectorJob times out (minutes to hours).
12
12
  #
13
13
  # The grace delay lets transient WS blips self-heal: if the client
14
14
  # reconnects before the job fires, AgentChannel#subscribe_to_agent_stream
15
- # restores routing_expression to "true" and writes a fresh subscription
16
- # token. The job's online-state recheck then no-ops.
15
+ # creates a fresh live presence row and writes a fresh subscription token.
16
+ # The job's online-state recheck then no-ops.
17
17
  class CancelOfflineDelegatedTasksJob < ApplicationJob
18
18
  queue_as :default
19
19
 
@@ -31,10 +31,6 @@ module Collavre
31
31
  # when a sibling remains; mirrors the destroy path's cancel_tasks_for_topic.
32
32
  return cancel_dropped_session_tasks(agent, session_id) if session_id.present?
33
33
 
34
- # Agent came back online during the grace window — the same MCP
35
- # session (or a new one) is subscribed and can answer /reply.
36
- return if agent.routing_expression.present?
37
-
38
34
  # A session (reconnect or a still-live sibling sharing this agent) holds
39
35
  # a LIVE presence row — the agent is online, its delegated work is still
40
36
  # owned. Presence is the authority now that one agent fans out to many
@@ -87,21 +83,11 @@ module Collavre
87
83
 
88
84
  tracker = Orchestration::ResourceTracker.for(agent)
89
85
  tasks.find_each do |task|
90
- was_running = task.status == "running"
91
- task.update!(status: "cancelled")
92
- tracker.release!(task.id) if was_running
86
+ previous_status = task.cancel_if_active!(statuses: %w[queued pending running])
87
+ next unless previous_status
93
88
 
94
- next if task.parent_task_id.blank?
95
-
96
- begin
97
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
98
- task, error_message: "Claude Channel session lost connection before dispatch"
99
- )
100
- rescue StandardError => e
101
- Rails.logger.error(
102
- "[CancelOfflineDelegatedTasksJob] fail_subtask! failed for queued task #{task.id}: #{e.message}"
103
- )
104
- end
89
+ was_running = previous_status == "running"
90
+ tracker.release!(task.id) if was_running
105
91
  end
106
92
  end
107
93
 
@@ -110,20 +96,9 @@ module Collavre
110
96
 
111
97
  tracker = Orchestration::ResourceTracker.for(agent)
112
98
  tasks.find_each do |task|
113
- task.update!(status: "cancelled")
114
- tracker.release!(task.id)
99
+ next unless task.cancel_if_active!(statuses: %w[delegated])
115
100
 
116
- if task.parent_task_id.present?
117
- begin
118
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
119
- task, error_message: "Claude Channel session lost connection before reply"
120
- )
121
- rescue StandardError => e
122
- Rails.logger.error(
123
- "[CancelOfflineDelegatedTasksJob] fail_subtask! failed for task #{task.id}: #{e.message}"
124
- )
125
- end
126
- end
101
+ tracker.release!(task.id)
127
102
 
128
103
  if task.topic_id.present?
129
104
  Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Recomputes and rebroadcasts the per-participant comment badges of a creative.
5
+ #
6
+ # The arithmetic is O(comment history) x O(participants): a total count, an
7
+ # unread count per distinct read watermark, and a private count per user. Run
8
+ # inline from Comment's after_commit it made writing a message cost more the
9
+ # longer the conversation was — and an inbox notification wrote a second
10
+ # comment into an inbox creative, paying the same price again over that
11
+ # inbox's own history.
12
+ #
13
+ # Badges are eventually-consistent UI: the recipient already got the comment
14
+ # itself over its own Turbo stream, so recounting a beat later is invisible.
15
+ class CommentBadgesBroadcastJob < ApplicationJob
16
+ queue_as :default
17
+ discard_on ActiveJob::DeserializationError
18
+
19
+ def perform(creative_id)
20
+ creative = Creative.find_by(id: creative_id)
21
+ # Deleting a creative destroys its comments, so the enqueued job can
22
+ # outlive its subject. Nothing to recount, and nobody to tell.
23
+ return unless creative
24
+
25
+ Comment.broadcast_badges(creative)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class CommentLinkPreviewJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
8
+ discard_on ActiveRecord::RecordNotFound
9
+
10
+ def perform(comment_id, expected_content, expected_revision)
11
+ comment = Comment.find(comment_id)
12
+ return unless comment.content == expected_content
13
+ return unless comment.notification_revision == expected_revision
14
+
15
+ formatted_content = CommentLinkFormatter.new(expected_content).format
16
+ return if formatted_content == expected_content
17
+
18
+ Comment.transaction do
19
+ comment = Comment.lock.find(comment_id)
20
+ return unless comment.content == expected_content
21
+ return unless comment.notification_revision == expected_revision
22
+
23
+ # This is a derived representation of the same logical comment revision.
24
+ # Save normally so every Comment after_update_commit integration observes
25
+ # the formatted content, while suppressing only notification revision
26
+ # advancement and another preview job.
27
+ comment.skip_link_preview = true
28
+ comment.skip_notification_revision = true
29
+ comment.update!(content: formatted_content)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class CommentNotificationJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
8
+ discard_on ActiveRecord::RecordNotFound
9
+
10
+ def perform(comment_id, kind, event)
11
+ Comment.find(comment_id).deliver_notifications(kind, event)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class CommentPushDeliverySweepJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform
8
+ CommentNotificationDelivery.ready_for_push.find_each do |delivery|
9
+ delivery.enqueue_push!
10
+ rescue StandardError => e
11
+ Rails.logger.error(
12
+ "[CommentPushDeliverySweepJob] Failed delivery #{delivery.id}: #{e.class}: #{e.message}"
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end