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