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
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# What a turn actually handed its agent, written down at the moment it was
|
|
6
|
+
# handed over.
|
|
7
|
+
#
|
|
8
|
+
# A burst of external events (a PR comment sync, a webhook batch) creates
|
|
9
|
+
# several comments in one topic within milliseconds.
|
|
10
|
+
# Orchestration::TaskCoalescer folds the *un-started* tasks of that burst
|
|
11
|
+
# together, but it cannot help the comment that arrives while a turn is
|
|
12
|
+
# already running: that dispatch parks a waiter, and the agent speaks a
|
|
13
|
+
# second time when the waiter is promoted.
|
|
14
|
+
#
|
|
15
|
+
# For a non-session agent the second turn is usually redundant.
|
|
16
|
+
# AiAgent::MessageBuilder#append_chat_history reads the topic's comments at
|
|
17
|
+
# *execution* time, so a comment committed before the payload was assembled
|
|
18
|
+
# is already inside the running turn's context — the agent has read it.
|
|
19
|
+
#
|
|
20
|
+
# For a session-backed agent it is not redundant at all:
|
|
21
|
+
# AiAgent::SessionContextResolver#incremental_payload keeps only the
|
|
22
|
+
# :trigger message, so the running turn never carried that text. Dropping
|
|
23
|
+
# the dispatch there would be message loss rather than de-duplication, and
|
|
24
|
+
# coalescing (merge into "merged_comment_ids") stays the right answer.
|
|
25
|
+
#
|
|
26
|
+
# So "the agent is busy" is the wrong predicate and "the agent has already
|
|
27
|
+
# been given that comment" is the right one — and the only place that can
|
|
28
|
+
# answer it is the resolved payload itself. This module records the answer
|
|
29
|
+
# off that payload and is the single door every reader goes through.
|
|
30
|
+
module DeliveryRecord
|
|
31
|
+
# Comment ids a turn delivered as chat history although it was not created
|
|
32
|
+
# for them. Sits beside TaskCoalescer::PAYLOAD_KEY and
|
|
33
|
+
# TaskCoalescer::ACQUIRED_ANCHOR_KEY, which record the other two ways a
|
|
34
|
+
# turn ends up carrying a comment it was not dispatched for.
|
|
35
|
+
KEY = "history_delivered_comment_ids"
|
|
36
|
+
|
|
37
|
+
# Comment ids whose dispatch this turn actually refused.
|
|
38
|
+
#
|
|
39
|
+
# A strict subset of KEY, and the distinction matters because the two are
|
|
40
|
+
# read for opposite purposes. KEY answers "has the agent been given this
|
|
41
|
+
# comment?", and chat history is the whole topic — Matcher's exclusive
|
|
42
|
+
# routings do not filter it, so every agent in a topic reads every public
|
|
43
|
+
# comment in it, including ones addressed to somebody else. That is the
|
|
44
|
+
# right predicate for the drop, which is only ever asked downstream of
|
|
45
|
+
# Matcher about a dispatch that already exists.
|
|
46
|
+
#
|
|
47
|
+
# It is the wrong predicate for the restore, whose subject is dispatches
|
|
48
|
+
# and which creates one per id it decides on. Reading KEY there turns a
|
|
49
|
+
# comment routed past this agent into a turn for it, past Matcher
|
|
50
|
+
# entirely; and it reads a dispatch still sitting in the job queue — an
|
|
51
|
+
# :immediate decision's Task row is created only when its job runs — as a
|
|
52
|
+
# discarded one, putting a second turn on a comment that already has one.
|
|
53
|
+
# Neither is visible in the absence of a Task row, so the drop is written
|
|
54
|
+
# down instead of reconstructed.
|
|
55
|
+
DROPPED_KEY = "dropped_dispatch_comment_ids"
|
|
56
|
+
|
|
57
|
+
# This turn asked the provider for an answer and the request never got
|
|
58
|
+
# there.
|
|
59
|
+
#
|
|
60
|
+
# Every other reader here decides off the turn's *status*, which works
|
|
61
|
+
# while the endings line up with what was delivered. This one does not:
|
|
62
|
+
# AiClient#chat catches the provider error, streams "⚠️ AI Error" into
|
|
63
|
+
# the reply and returns nil, and AiAgentJob marks an ordinary task `done`
|
|
64
|
+
# on that — the single status every reader counts as delivery. So a turn
|
|
65
|
+
# can end in the delivered ending having handed over nothing at all, and
|
|
66
|
+
# the comments it discarded were read by nobody.
|
|
67
|
+
#
|
|
68
|
+
# Recorded rather than inferred, because nothing else in the row says it:
|
|
69
|
+
# `done` with an error in the reply text looks exactly like `done`. The
|
|
70
|
+
# only place that knows is the service holding the client that failed.
|
|
71
|
+
HANDOFF_FAILED_KEY = "handoff_failed"
|
|
72
|
+
|
|
73
|
+
# This turn did hand its payload to the provider — the same question,
|
|
74
|
+
# asked positively, for the endings that never reach the line above.
|
|
75
|
+
#
|
|
76
|
+
# HANDOFF_FAILED_KEY is written after #chat returns, so a turn that leaves
|
|
77
|
+
# by exception never records anything: a user pressing Stop mid-answer
|
|
78
|
+
# ends `cancelled`, which every reader counts as undelivered outright,
|
|
79
|
+
# although the provider has the payload by then and AgentLifecycleManager
|
|
80
|
+
# keeps the partial reply. The comments that turn swallowed were read
|
|
81
|
+
# along with it, so restoring them starts fresh work the moment the user
|
|
82
|
+
# asked for none, and answers them twice.
|
|
83
|
+
#
|
|
84
|
+
# Positive rather than a second failure flag, because the absence of the
|
|
85
|
+
# record has to keep meaning "restore": most cancellations and every hard
|
|
86
|
+
# crash write nothing at all, and losing a comment costs more than a
|
|
87
|
+
# redundant turn. Only a turn that got far enough to say so is exempt.
|
|
88
|
+
HANDED_OFF_KEY = "handed_off"
|
|
89
|
+
|
|
90
|
+
# The comment ids an attempt that handed off actually carried.
|
|
91
|
+
#
|
|
92
|
+
# HANDED_OFF_KEY answers for the *turn*, and that is enough while a turn
|
|
93
|
+
# is one attempt. It is not: a tool approval pauses the turn and
|
|
94
|
+
# Comments::ActionExecutor resumes the same Task row, so AiAgentService
|
|
95
|
+
# runs over it again with a fresh client. The first pass reached its tools,
|
|
96
|
+
# which means the provider had that payload; if the resumed request then
|
|
97
|
+
# never lands, the row carries both records, Task#ended_undelivered? reads
|
|
98
|
+
# the failure first, and the restore puts back every dispatch ever dropped
|
|
99
|
+
# against the turn — including the ones the first pass handed over.
|
|
100
|
+
#
|
|
101
|
+
# So the exemption is recorded per comment, which is the subject the
|
|
102
|
+
# restore actually has. KEY at the moment of the handoff is exactly what
|
|
103
|
+
# went over: record! writes it before the request, and a drop taken later
|
|
104
|
+
# against an id already in KEY was in that payload too. Accumulates across
|
|
105
|
+
# attempts for the same reason KEY does — what one pass delivered it still
|
|
106
|
+
# delivered — and a comment only the failed pass read is not in it, so it
|
|
107
|
+
# is still owed back.
|
|
108
|
+
HANDED_OFF_IDS_KEY = "handed_off_comment_ids"
|
|
109
|
+
|
|
110
|
+
# Comments this turn's restore has already put back on their way.
|
|
111
|
+
#
|
|
112
|
+
# AiAgentJob creates the Task row when it *runs*, so between a restored
|
|
113
|
+
# enqueue and its execution the comment has no row and reads as orphaned —
|
|
114
|
+
# a worker outage or a backlog longer than the sweep interval therefore
|
|
115
|
+
# had every pass enqueue another copy, and the copies that lose the
|
|
116
|
+
# admission race become independent waiters that answer the comment again.
|
|
117
|
+
# Eventual Task creation cannot be the idempotency marker for work that
|
|
118
|
+
# has not started.
|
|
119
|
+
#
|
|
120
|
+
# Written before the enqueue and given back if the enqueue raises, so the
|
|
121
|
+
# claim never outlives the dispatch it stands for: a comment claimed by an
|
|
122
|
+
# enqueue that never happened is the previous finding pointing the other
|
|
123
|
+
# way.
|
|
124
|
+
RESTORED_KEY = "restored_dispatch_comment_ids"
|
|
125
|
+
|
|
126
|
+
# StuckDetector failed this `running` row from outside its worker while
|
|
127
|
+
# the worker may still be inside the provider call.
|
|
128
|
+
#
|
|
129
|
+
# The failed status alone cannot say whether the payload reached the
|
|
130
|
+
# provider: AiAgentService writes that evidence only when the call returns
|
|
131
|
+
# or raises. The status callback therefore defers while this marker is
|
|
132
|
+
# present. The live worker clears it in AiAgentJob's ensure; if the worker
|
|
133
|
+
# died outright, restore_missed! waits out the provider timeout and asks
|
|
134
|
+
# again.
|
|
135
|
+
WORKER_SETTLING_KEY = "delivery_worker_settling"
|
|
136
|
+
|
|
137
|
+
# Everything a turn writes onto its own payload while it runs, as opposed
|
|
138
|
+
# to what it was dispatched with.
|
|
139
|
+
#
|
|
140
|
+
# One list rather than a strip enumerated at the point of use: a restored
|
|
141
|
+
# dispatch is the one that was discarded, so it carries what a dispatch
|
|
142
|
+
# carries and nothing the turn wrote onto itself afterwards. Enumerating
|
|
143
|
+
# that at the call site is how HANDOFF_FAILED_KEY came to be left in — it
|
|
144
|
+
# was added here long after restored_context had named the other four —
|
|
145
|
+
# and the drift test on this constant is what will catch the sixth.
|
|
146
|
+
TURN_SCOPED_KEYS = [
|
|
147
|
+
KEY, DROPPED_KEY, HANDOFF_FAILED_KEY, HANDED_OFF_KEY, HANDED_OFF_IDS_KEY,
|
|
148
|
+
RESTORED_KEY, WORKER_SETTLING_KEY, TaskCoalescer::PAYLOAD_KEY,
|
|
149
|
+
TaskCoalescer::ACQUIRED_ANCHOR_KEY
|
|
150
|
+
].freeze
|
|
151
|
+
|
|
152
|
+
# Statuses in which a turn is still the thing that will answer.
|
|
153
|
+
#
|
|
154
|
+
# Narrower than AgentOrchestrator::DELIVERED_STATUSES, which also counts
|
|
155
|
+
# `done`, and deliberately so: the two readers ask different questions.
|
|
156
|
+
# The promotion door asks "may this *parked* waiter still speak?", and a
|
|
157
|
+
# waiter that may not simply loses its turn — it had no independent claim
|
|
158
|
+
# on the slot. This module's other reader is the dispatch door, where the
|
|
159
|
+
# alternative to running is discarding the dispatch outright. A turn that
|
|
160
|
+
# has already finished is no longer answering anything, so a comment
|
|
161
|
+
# arriving after it gets its own turn rather than being silently dropped.
|
|
162
|
+
#
|
|
163
|
+
# `queued`/`pending` are excluded at both: nothing has been delivered yet.
|
|
164
|
+
IN_FLIGHT_STATUSES = %w[running delegated pending_approval].freeze
|
|
165
|
+
|
|
166
|
+
# Terminal statuses in which the turn delivered nothing after all.
|
|
167
|
+
#
|
|
168
|
+
# The complement of AgentOrchestrator::DELIVERED_STATUSES over the
|
|
169
|
+
# terminal set, and that is the whole point: promotion says a dead turn
|
|
170
|
+
# silences nothing by leaving these statuses out of its list, and a parked
|
|
171
|
+
# waiter survives on that — it is still a row, and the refresh re-reads
|
|
172
|
+
# the covering turn's status before deciding anything.
|
|
173
|
+
#
|
|
174
|
+
# A *dropped* dispatch has no row to re-read anything. So the drop owes
|
|
175
|
+
# the same answer through the only means it has left: putting the
|
|
176
|
+
# discarded dispatch back. Without it the two doors disagree about what a
|
|
177
|
+
# failure means, and the disagreement costs a comment nobody answers.
|
|
178
|
+
#
|
|
179
|
+
# DeliveryRecordTest asserts this is exactly the complement; a status
|
|
180
|
+
# added to one list and not the other is how they drift apart.
|
|
181
|
+
UNDELIVERED_TERMINAL_STATUSES = %w[failed cancelled escalated].freeze
|
|
182
|
+
|
|
183
|
+
# Statuses a row can be in and still owe a restore — the undelivered
|
|
184
|
+
# endings, plus the one that reads as delivered until the payload is
|
|
185
|
+
# consulted. Derived rather than listed so the sweep cannot come to scan
|
|
186
|
+
# for less than Task#ended_undelivered? decides on.
|
|
187
|
+
RESTORABLE_STATUSES = (UNDELIVERED_TERMINAL_STATUSES + %w[done]).freeze
|
|
188
|
+
|
|
189
|
+
# How far back restore_missed! looks.
|
|
190
|
+
#
|
|
191
|
+
# Comfortably more than its schedule, so a restore the callback lost gets
|
|
192
|
+
# several attempts, and finite because it is a backstop: a comment whose
|
|
193
|
+
# turn ended an hour ago has been overtaken by whatever the topic did
|
|
194
|
+
# since, and re-dispatching it then would be the more surprising answer.
|
|
195
|
+
RESTORE_SWEEP_WINDOW = 1.hour
|
|
196
|
+
|
|
197
|
+
# Slack added to the provider timeout when waiting for a worker that
|
|
198
|
+
# another process ended to settle — see .worker_settle_grace.
|
|
199
|
+
WORKER_SETTLE_SLACK = 5.minutes
|
|
200
|
+
|
|
201
|
+
# Record what `resolved` carries as chat history.
|
|
202
|
+
#
|
|
203
|
+
# Measured off the *resolved* payload — the messages the adapter is handed
|
|
204
|
+
# — rather than off MessageBuilder's output, because the session filter
|
|
205
|
+
# sits between the two and is exactly the distinction that decides whether
|
|
206
|
+
# a comment was delivered at all. A future filter added there is then
|
|
207
|
+
# accounted for without a second place having to learn about it.
|
|
208
|
+
#
|
|
209
|
+
# Restricted to ids above the turn's anchor. Chat history normally holds
|
|
210
|
+
# the topic's backlog, which this turn did not swallow and must not
|
|
211
|
+
# silence; a history comment *newer* than the anchor can only have landed
|
|
212
|
+
# after this turn was dispatched, which is the burst case and the only one
|
|
213
|
+
# this record is for. Ids rather than created_at: a burst is written by
|
|
214
|
+
# several processes and the clock is whichever one wrote the row (the same
|
|
215
|
+
# reason AiAgent::MergedTriggerComments orders by id).
|
|
216
|
+
#
|
|
217
|
+
# Accumulates. A resumed turn (pending_approval, or a retry) assembles a
|
|
218
|
+
# second time, and what the first pass delivered it still delivered.
|
|
219
|
+
#
|
|
220
|
+
# Written onto the row's payload under the same lock as claim_drop!, not
|
|
221
|
+
# onto the caller's. A resumed turn's task object is loaded before it
|
|
222
|
+
# assembles, and a dispatch refused in the meantime writes DROPPED_KEY
|
|
223
|
+
# into the same JSON column from another process; a merge onto the stale
|
|
224
|
+
# copy would erase the drop, and a drop no restore can find is the
|
|
225
|
+
# message lost. Both writers of this column now read it under the lock.
|
|
226
|
+
def self.record!(task, resolved)
|
|
227
|
+
return if task.nil?
|
|
228
|
+
|
|
229
|
+
Task.transaction do
|
|
230
|
+
row = Task.lock.find_by(id: task.id)
|
|
231
|
+
payload = row&.trigger_event_payload
|
|
232
|
+
next unless payload.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
anchor_id = payload.dig("comment", "id").to_i
|
|
235
|
+
next if anchor_id.zero?
|
|
236
|
+
|
|
237
|
+
swallowed = history_comment_ids(resolved).select { |id| id > anchor_id }
|
|
238
|
+
merged = (ids_in(payload) + swallowed).uniq.sort
|
|
239
|
+
next if merged == ids_in(payload)
|
|
240
|
+
|
|
241
|
+
row.update!(trigger_event_payload: payload.merge(KEY => merged))
|
|
242
|
+
task.reload unless task.equal?(row)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def self.ids_in(payload)
|
|
247
|
+
return [] unless payload.is_a?(Hash)
|
|
248
|
+
|
|
249
|
+
Array(payload[KEY]).compact.map(&:to_i)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Write down that this turn's request never reached the provider.
|
|
253
|
+
#
|
|
254
|
+
# Under the same row lock as record! and claim_drop!, for the same
|
|
255
|
+
# reason: a dispatch refused in another process writes DROPPED_KEY into
|
|
256
|
+
# this JSON column while the turn runs, and a merge onto the caller's
|
|
257
|
+
# copy would erase it.
|
|
258
|
+
def self.mark_handoff_failed!(task)
|
|
259
|
+
return if task.nil?
|
|
260
|
+
|
|
261
|
+
Task.transaction do
|
|
262
|
+
row = Task.lock.find_by(id: task.id)
|
|
263
|
+
payload = row&.trigger_event_payload
|
|
264
|
+
next unless payload.is_a?(Hash)
|
|
265
|
+
next if handoff_failed?(payload)
|
|
266
|
+
|
|
267
|
+
row.update!(trigger_event_payload: payload.merge(HANDOFF_FAILED_KEY => true))
|
|
268
|
+
task.reload unless task.equal?(row)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def self.handoff_failed?(payload)
|
|
273
|
+
return false unless payload.is_a?(Hash)
|
|
274
|
+
|
|
275
|
+
payload[HANDOFF_FAILED_KEY] == true
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Write down that this turn's payload did reach the provider.
|
|
279
|
+
#
|
|
280
|
+
# Under the same row lock and for the same reason as its two siblings: a
|
|
281
|
+
# dispatch refused in another process writes DROPPED_KEY into this column
|
|
282
|
+
# while the turn runs, and this is written as the turn is being torn down
|
|
283
|
+
# by the very cancellation that dispatch is racing.
|
|
284
|
+
def self.mark_handed_off!(task)
|
|
285
|
+
return if task.nil?
|
|
286
|
+
|
|
287
|
+
Task.transaction do
|
|
288
|
+
row = Task.lock.find_by(id: task.id)
|
|
289
|
+
payload = row&.trigger_event_payload
|
|
290
|
+
next unless payload.is_a?(Hash)
|
|
291
|
+
|
|
292
|
+
# Not `next if handed_off?`: a resumed attempt records the handoff a
|
|
293
|
+
# second time, and what it carried this time is the point of asking.
|
|
294
|
+
recorded = handed_off_ids_in(payload)
|
|
295
|
+
covered = (recorded + ids_in(payload)).uniq.sort
|
|
296
|
+
next if handed_off?(payload) && covered == recorded
|
|
297
|
+
|
|
298
|
+
row.update!(trigger_event_payload: payload.merge(
|
|
299
|
+
HANDED_OFF_KEY => true, HANDED_OFF_IDS_KEY => covered
|
|
300
|
+
))
|
|
301
|
+
task.reload unless task.equal?(row)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def self.handed_off?(payload)
|
|
306
|
+
return false unless payload.is_a?(Hash)
|
|
307
|
+
|
|
308
|
+
payload[HANDED_OFF_KEY] == true
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def self.handed_off_ids_in(payload)
|
|
312
|
+
return [] unless payload.is_a?(Hash)
|
|
313
|
+
|
|
314
|
+
Array(payload[HANDED_OFF_IDS_KEY]).compact.map(&:to_i)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Fail a running row from outside its worker without letting the status
|
|
318
|
+
# callback decide from evidence the worker has not written yet.
|
|
319
|
+
#
|
|
320
|
+
# Status and marker are one locked update. A marker written before a
|
|
321
|
+
# separate status update could be observed by the sweep while the row was
|
|
322
|
+
# still running; one written afterwards is too late for the callback.
|
|
323
|
+
def self.fail_while_worker_settles!(task)
|
|
324
|
+
return false if task.nil?
|
|
325
|
+
|
|
326
|
+
failed = false
|
|
327
|
+
Task.transaction do
|
|
328
|
+
row = Task.lock.find_by(id: task.id)
|
|
329
|
+
next unless row&.status == "running"
|
|
330
|
+
|
|
331
|
+
payload = row.trigger_event_payload
|
|
332
|
+
payload = {} unless payload.is_a?(Hash)
|
|
333
|
+
row.update!(
|
|
334
|
+
status: "failed",
|
|
335
|
+
trigger_event_payload: payload.merge(WORKER_SETTLING_KEY => true)
|
|
336
|
+
)
|
|
337
|
+
failed = true
|
|
338
|
+
end
|
|
339
|
+
task.reload if failed
|
|
340
|
+
failed
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def self.worker_settling?(payload) = payload.is_a?(Hash) && payload[WORKER_SETTLING_KEY] == true
|
|
344
|
+
|
|
345
|
+
# The original worker has left the provider call, so the handoff records
|
|
346
|
+
# now carry the best answer this attempt can give. Clearing under the row
|
|
347
|
+
# lock preserves any drop written by another process in the meantime.
|
|
348
|
+
def self.settle_worker!(task)
|
|
349
|
+
return if task.nil?
|
|
350
|
+
|
|
351
|
+
Task.transaction do
|
|
352
|
+
row = Task.lock.find_by(id: task.id)
|
|
353
|
+
payload = row&.trigger_event_payload
|
|
354
|
+
next unless worker_settling?(payload)
|
|
355
|
+
|
|
356
|
+
row.update!(trigger_event_payload: payload.except(WORKER_SETTLING_KEY))
|
|
357
|
+
end
|
|
358
|
+
task.reload
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def self.restored_ids_in(payload) = payload.is_a?(Hash) ? Array(payload[RESTORED_KEY]).compact.map(&:to_i) : []
|
|
362
|
+
|
|
363
|
+
# Take responsibility for putting one dispatch back, or refuse it because
|
|
364
|
+
# somebody already has.
|
|
365
|
+
#
|
|
366
|
+
# The same shape as claim_drop!, and for the same reason: the callback and
|
|
367
|
+
# the sweep can be looking at this row at once, and the thing they would
|
|
368
|
+
# both act on leaves no trace until a worker picks it up.
|
|
369
|
+
#
|
|
370
|
+
# @return [Boolean] whether the caller may enqueue the dispatch
|
|
371
|
+
def self.claim_restore!(task, comment_id)
|
|
372
|
+
id = comment_id.to_i
|
|
373
|
+
return false if task.nil? || id.zero?
|
|
374
|
+
|
|
375
|
+
claimed = false
|
|
376
|
+
Task.transaction do
|
|
377
|
+
row = Task.lock.find_by(id: task.id)
|
|
378
|
+
payload = row&.trigger_event_payload
|
|
379
|
+
next unless payload.is_a?(Hash)
|
|
380
|
+
next if restored_ids_in(payload).include?(id)
|
|
381
|
+
|
|
382
|
+
restored = (restored_ids_in(payload) + [ id ]).uniq.sort
|
|
383
|
+
row.update!(trigger_event_payload: payload.merge(RESTORED_KEY => restored))
|
|
384
|
+
claimed = true
|
|
385
|
+
end
|
|
386
|
+
claimed
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Give the claim back for a dispatch that was never enqueued after all.
|
|
390
|
+
def self.release_restore_claim!(task, comment_id)
|
|
391
|
+
id = comment_id.to_i
|
|
392
|
+
return if task.nil? || id.zero?
|
|
393
|
+
|
|
394
|
+
Task.transaction do
|
|
395
|
+
row = Task.lock.find_by(id: task.id)
|
|
396
|
+
payload = row&.trigger_event_payload
|
|
397
|
+
next unless payload.is_a?(Hash)
|
|
398
|
+
|
|
399
|
+
restored = restored_ids_in(payload) - [ id ]
|
|
400
|
+
row.update!(trigger_event_payload: payload.merge(RESTORED_KEY => restored))
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def self.dropped_ids_in(payload) = payload.is_a?(Hash) ? Array(payload[DROPPED_KEY]).compact.map(&:to_i) : []
|
|
405
|
+
|
|
406
|
+
def self.pending_restoration?(task)
|
|
407
|
+
payload = task&.trigger_event_payload
|
|
408
|
+
return false unless task&.ended_undelivered? && task.agent
|
|
409
|
+
|
|
410
|
+
pending = dropped_ids_in(payload) - handed_off_ids_in(payload) - claimed_comment_ids(task)
|
|
411
|
+
restorable_comments(task, pending).exists?
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Take responsibility for a dispatch about to be discarded, or refuse it.
|
|
415
|
+
#
|
|
416
|
+
# The drop and its record are one act, and this is where they are made
|
|
417
|
+
# one. `covering_task` answers off the caller's snapshot; between that
|
|
418
|
+
# answer and this write the turn can end, run its restore, and leave —
|
|
419
|
+
# and a record written after that restore is a comment nobody ever comes
|
|
420
|
+
# back for. So the status is re-read from the locked row, and a caller
|
|
421
|
+
# told `false` dispatches normally instead of dropping. Refusing a sound
|
|
422
|
+
# drop costs a redundant turn; taking an unsound one costs the message.
|
|
423
|
+
#
|
|
424
|
+
# @return [Boolean] whether the caller may discard the dispatch
|
|
425
|
+
def self.claim_drop!(covering, comment_id)
|
|
426
|
+
id = comment_id.to_i
|
|
427
|
+
return false if covering.nil? || id.zero?
|
|
428
|
+
|
|
429
|
+
claimed = false
|
|
430
|
+
Task.transaction do
|
|
431
|
+
task = Task.lock.find_by(id: covering.id)
|
|
432
|
+
payload = task&.trigger_event_payload
|
|
433
|
+
next unless task&.status.to_s.in?(IN_FLIGHT_STATUSES) && payload.is_a?(Hash)
|
|
434
|
+
|
|
435
|
+
dropped = (dropped_ids_in(payload) + [ id ]).uniq.sort
|
|
436
|
+
task.update!(trigger_event_payload: payload.merge(DROPPED_KEY => dropped))
|
|
437
|
+
claimed = true
|
|
438
|
+
end
|
|
439
|
+
claimed
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# The in-flight turn that has already given `comment_id` to `agent`, or nil.
|
|
443
|
+
#
|
|
444
|
+
# Every gate that drops a dispatch goes through this one method: the
|
|
445
|
+
# orchestrator's enqueue door, AiAgentJob's late-admission door, and any
|
|
446
|
+
# later one. Two doors that ask this question separately are how they come
|
|
447
|
+
# to disagree about it.
|
|
448
|
+
#
|
|
449
|
+
# Scoped exactly like AgentOrchestrator.delivered_comment_ids — same agent,
|
|
450
|
+
# topic, creative and trigger event — so a dispatch in another
|
|
451
|
+
# creative, or a different event over the same comment, is a different
|
|
452
|
+
# question and not an answer to this one.
|
|
453
|
+
def self.covering_task(agent, comment_id, context, trigger_event_name)
|
|
454
|
+
return nil if agent.nil? || comment_id.blank?
|
|
455
|
+
return nil unless context.is_a?(Hash) && context.key?("topic")
|
|
456
|
+
return nil unless PolicyResolver.new(context).drop_delivered_dispatches_for?(agent)
|
|
457
|
+
# A review request is bound to the comment it quotes and can only run as
|
|
458
|
+
# its own turn: AiAgentService and ReviewHandler read review behaviour
|
|
459
|
+
# off the anchor, never off the history window. Having been read as
|
|
460
|
+
# context is not having been answered, so a review is never dropped —
|
|
461
|
+
# the same boundary TaskCoalescer and refresh_deferred_context! keep.
|
|
462
|
+
return nil if Comment.review_message_ids([ comment_id ]).any?
|
|
463
|
+
|
|
464
|
+
Task.where(
|
|
465
|
+
agent_id: agent.id,
|
|
466
|
+
topic_id: context.dig("topic", "id"),
|
|
467
|
+
creative_id: context.dig("creative", "id"),
|
|
468
|
+
trigger_event_name: trigger_event_name,
|
|
469
|
+
status: IN_FLIGHT_STATUSES
|
|
470
|
+
).select(:id, :trigger_event_payload).find do |task|
|
|
471
|
+
payload = task.trigger_event_payload
|
|
472
|
+
# A turn still `running` after its request failed is not going to
|
|
473
|
+
# answer anything, and dropping against it would record a drop after
|
|
474
|
+
# the restore that ends it has already looked.
|
|
475
|
+
next false if handoff_failed?(payload)
|
|
476
|
+
|
|
477
|
+
ids_in(payload).include?(comment_id.to_i)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Put back the dispatches this turn's record caused to be discarded, now
|
|
482
|
+
# that the turn has ended without delivering them.
|
|
483
|
+
#
|
|
484
|
+
# Driven off Task's status callback rather than from AiAgentJob's rescue:
|
|
485
|
+
# the job is not the only door onto a failed turn — StuckDetector fails a
|
|
486
|
+
# task that never reached a rescue at all — and a restore wired to one of
|
|
487
|
+
# them is a restore that does not happen on the other.
|
|
488
|
+
#
|
|
489
|
+
# Its subject is DROPPED_KEY — the dispatches this turn refused — and not
|
|
490
|
+
# KEY, which is merely everything it read. Only a refused dispatch is a
|
|
491
|
+
# thing this turn owes back; see DROPPED_KEY for what taking the wider
|
|
492
|
+
# set costs.
|
|
493
|
+
#
|
|
494
|
+
# Narrowed once more by what nothing else is on the hook for. A comment
|
|
495
|
+
# swept into this turn's history may still have a waiter of its own
|
|
496
|
+
# (parked before the turn assembled, so never droppable), and promotion
|
|
497
|
+
# decides that waiter's fate by re-reading this turn's status. Restoring
|
|
498
|
+
# it as well would put two turns on one comment. Belt to DROPPED_KEY's
|
|
499
|
+
# braces: the record already excludes what was never dropped, and this
|
|
500
|
+
# excludes what has since acquired a row of its own.
|
|
501
|
+
#
|
|
502
|
+
# And once more by HANDED_OFF_IDS_KEY, for the turn that ran more than
|
|
503
|
+
# once: a comment an earlier attempt handed to the provider is not owed
|
|
504
|
+
# back by a later attempt's failure.
|
|
505
|
+
#
|
|
506
|
+
# Each orphan is re-dispatched on its own, exactly as it would have been
|
|
507
|
+
# had it never been dropped, and the ordinary admission path folds them
|
|
508
|
+
# back together — rather than this method inventing a second merge rule
|
|
509
|
+
# beside TaskCoalescer's.
|
|
510
|
+
def self.restore!(task)
|
|
511
|
+
# Re-read the row rather than trust the caller's copy. The drop record
|
|
512
|
+
# is written by whichever dispatch was refused, in another process
|
|
513
|
+
# entirely, onto a row this turn's own object was loaded before — so
|
|
514
|
+
# the in-memory payload that reaches this callback is exactly the one
|
|
515
|
+
# that predates every drop it is being asked about.
|
|
516
|
+
payload = Task.find_by(id: task.id)&.trigger_event_payload
|
|
517
|
+
return unless payload.is_a?(Hash) && payload.key?("topic")
|
|
518
|
+
|
|
519
|
+
orphaned = dropped_ids_in(payload) - handed_off_ids_in(payload) -
|
|
520
|
+
claimed_comment_ids(task) - restored_ids_in(payload)
|
|
521
|
+
return if orphaned.empty?
|
|
522
|
+
|
|
523
|
+
agent = task.agent
|
|
524
|
+
return if agent.nil?
|
|
525
|
+
|
|
526
|
+
# Same eligibility the refresh applies when it moves an anchor: a
|
|
527
|
+
# comment that was deleted, made private, or turned into an approval
|
|
528
|
+
# action while the turn ran has nothing left to answer.
|
|
529
|
+
restorable_comments(task, orphaned).order(:id).each do |comment|
|
|
530
|
+
begin
|
|
531
|
+
enqueue_restored(task, agent, task.trigger_event_name, restored_context(payload, comment))
|
|
532
|
+
rescue StandardError => e
|
|
533
|
+
# Each orphan is a dispatch of its own; one that cannot be enqueued
|
|
534
|
+
# says nothing about the next. What it leaves behind is the absence
|
|
535
|
+
# of a row, which is exactly what restore_missed! reads.
|
|
536
|
+
Rails.logger.error(
|
|
537
|
+
"[DeliveryRecord] Failed to restore dispatch for comment #{comment.id} " \
|
|
538
|
+
"of task #{task.id}: #{e.class}: #{e.message}"
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Ask again for every turn whose restore may not have been made.
|
|
545
|
+
#
|
|
546
|
+
# restore! runs from an after_update_commit callback, which is to say
|
|
547
|
+
# after the covering turn's status is already committed: a transient
|
|
548
|
+
# failure there has nothing left to roll back and no later transition that
|
|
549
|
+
# would retry it, and the dropped dispatch is the one thing in this
|
|
550
|
+
# feature with no row of its own to fall back on.
|
|
551
|
+
#
|
|
552
|
+
# Nothing extra is persisted for it, because the orphan set already is:
|
|
553
|
+
# DROPPED_KEY is on the row and claimed_comment_ids subtracts whatever has
|
|
554
|
+
# since acquired a Task, so a comment stays orphaned exactly until its
|
|
555
|
+
# dispatch exists again and restore! is idempotent by construction. What
|
|
556
|
+
# was lost is only the asking.
|
|
557
|
+
#
|
|
558
|
+
# A dispatch the Scheduler refuses leaves no row either, so it is asked
|
|
559
|
+
# again on each pass through the window — which is the right answer for a
|
|
560
|
+
# quota that has since reset, and a bounded number of cheap refusals if it
|
|
561
|
+
# has not.
|
|
562
|
+
# How long after another process ends a worker this sweep waits before
|
|
563
|
+
# deciding it.
|
|
564
|
+
#
|
|
565
|
+
# Task#ended_while_worker_settles? explains why the status callback
|
|
566
|
+
# declines: Stop and StuckDetector both commit from another process while
|
|
567
|
+
# the worker can be inside AiClient#chat, and the worker's account of the
|
|
568
|
+
# handoff is written only on the way out. This door cannot tell "still
|
|
569
|
+
# settling" from "nobody is coming" either. Without a wait it answers in
|
|
570
|
+
# the callback's place and re-dispatches everything the turn swallowed.
|
|
571
|
+
#
|
|
572
|
+
# Derived from the configured provider timeout because that is what bounds
|
|
573
|
+
# the wait in the shape this is for: Stop pressed while the turn is still
|
|
574
|
+
# waiting for its first token, where AgentLifecycleManager#check_cancelled!
|
|
575
|
+
# cannot fire at all because it polls from inside the streaming block.
|
|
576
|
+
#
|
|
577
|
+
# It is not a bound on a conversation that keeps making requests without
|
|
578
|
+
# yielding content — a tool loop can outlast any grace, and for that one
|
|
579
|
+
# this sweep can still be early. What that costs is a duplicate reply;
|
|
580
|
+
# declining these endings outright would cost the comment when the worker
|
|
581
|
+
# died before it could clear the marker.
|
|
582
|
+
def self.worker_settle_grace
|
|
583
|
+
SystemSetting.llm_request_timeout_seconds.seconds + WORKER_SETTLE_SLACK
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def self.restore_missed!(window: RESTORE_SWEEP_WINDOW)
|
|
587
|
+
grace = worker_settle_grace
|
|
588
|
+
|
|
589
|
+
[
|
|
590
|
+
# The endings whose status is written after the worker is out of the
|
|
591
|
+
# provider call, so the row is as settled as it will ever be.
|
|
592
|
+
Task.where(status: RESTORABLE_STATUSES - %w[cancelled failed])
|
|
593
|
+
.where(updated_at: window.ago..),
|
|
594
|
+
# And the one that is not. The window moves with the wait rather than
|
|
595
|
+
# being spent by it: subtracting the grace from a fixed horizon would
|
|
596
|
+
# leave a cancellation less backstop than every other ending gets, and
|
|
597
|
+
# at the default timeout almost none.
|
|
598
|
+
Task.where(status: "cancelled")
|
|
599
|
+
.where(updated_at: (window + grace).ago..grace.ago)
|
|
600
|
+
].each do |scope|
|
|
601
|
+
scope.find_each { |task| restore_if_undelivered!(task) }
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# A normal failed ending is settled and keeps the ordinary window. A
|
|
605
|
+
# StuckDetector failure carries WORKER_SETTLING_KEY and gets the same
|
|
606
|
+
# grace as cancellation. Read the marker in Ruby so this stays portable
|
|
607
|
+
# across the JSON implementations used by SQLite tests and PostgreSQL.
|
|
608
|
+
Task.where(status: "failed")
|
|
609
|
+
.where(updated_at: (window + grace).ago..)
|
|
610
|
+
.find_each do |task|
|
|
611
|
+
settling = worker_settling?(task.trigger_event_payload)
|
|
612
|
+
next if settling && task.updated_at > grace.ago
|
|
613
|
+
next if !settling && task.updated_at < window.ago
|
|
614
|
+
|
|
615
|
+
restore_if_undelivered!(task)
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Ask the restore question of a turn that has settled.
|
|
620
|
+
#
|
|
621
|
+
# The one door for every caller that is not Task's status callback: this
|
|
622
|
+
# sweep, and AiAgentJob's cancellation teardown, which is where a turn
|
|
623
|
+
# stopped mid-answer settles — see Task#ended_while_worker_settles? for why
|
|
624
|
+
# the cancel itself cannot decide. Gated on the same predicate the callback
|
|
625
|
+
# is, because a second door deciding "terminal" where the callback decides
|
|
626
|
+
# Task#ended_undelivered? is how one of them comes to re-answer comments the
|
|
627
|
+
# turn had already answered.
|
|
628
|
+
#
|
|
629
|
+
# Best effort, like the callback's: the teardown runs inside the job's last
|
|
630
|
+
# rescue, where an escaping exception would turn a cancelled turn into a
|
|
631
|
+
# failed job, and the sweep must not lose the rest of its pass to one row.
|
|
632
|
+
# Nothing is lost by swallowing — the orphan set is on the row, so the next
|
|
633
|
+
# pass asks again.
|
|
634
|
+
def self.restore_if_undelivered!(task)
|
|
635
|
+
return if task.nil? || !task.ended_undelivered?
|
|
636
|
+
|
|
637
|
+
restore!(task)
|
|
638
|
+
rescue StandardError => e
|
|
639
|
+
Rails.logger.error(
|
|
640
|
+
"[DeliveryRecord] Restore failed for task #{task.id}: #{e.class}: #{e.message} — " \
|
|
641
|
+
"leaving it to the restore sweep"
|
|
642
|
+
)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# Put the dispatch back the way the orchestrator would have, by asking the
|
|
646
|
+
# same Scheduler again.
|
|
647
|
+
#
|
|
648
|
+
# The decision the drop discarded was a decision about *then*. A dispatch
|
|
649
|
+
# refused while the agent was at its concurrency limit or rate-limited
|
|
650
|
+
# carried a backoff with it, and enqueuing it now would run it past the
|
|
651
|
+
# check that set that delay — while the dying turn's own ResourceTracker
|
|
652
|
+
# slot is very often still held, since this fires from its status
|
|
653
|
+
# callback. Replaying the delay recorded at drop time would be no better:
|
|
654
|
+
# by now it describes pressure that may be long gone or much worse.
|
|
655
|
+
#
|
|
656
|
+
# Asked again, not merely honoured: a quota exhausted or a loop breaker
|
|
657
|
+
# tripped between the drop and the restore refuses this dispatch exactly
|
|
658
|
+
# as it would refuse a fresh one. AgentOrchestrator's own guard keeps
|
|
659
|
+
# rejected work out of the record; this is the other end of the same
|
|
660
|
+
# question, and only the scheduler can answer it as of now.
|
|
661
|
+
#
|
|
662
|
+
# Enqueued through AiAgentJob rather than AgentOrchestrator#enqueue_jobs
|
|
663
|
+
# even for a :deferred decision, because the job door re-asks
|
|
664
|
+
# Matcher#assignment_permits? against the re-anchored payload before it
|
|
665
|
+
# parks anything — a restored dispatch has moved, and park_waiter would
|
|
666
|
+
# create the row without that question being asked.
|
|
667
|
+
def self.enqueue_restored(task, agent, event_name, context)
|
|
668
|
+
# The one question no door on this path asks. Naming the agent skips
|
|
669
|
+
# Matcher#match, and every routing path in #match is gated on feedback
|
|
670
|
+
# permission for the creative; AiAgentJob re-asks the topic assignment
|
|
671
|
+
# and nothing else. A permission revoked between the drop and the
|
|
672
|
+
# restore would otherwise be seen by nobody, and this dispatch would
|
|
673
|
+
# answer a creative the agent may no longer read.
|
|
674
|
+
unless Matcher.permits_creative_access?(context, agent)
|
|
675
|
+
Rails.logger.info(
|
|
676
|
+
"[DeliveryRecord] Not restoring dispatch for comment #{context.dig("comment", "id")}: " \
|
|
677
|
+
"agent #{agent.id} no longer has feedback permission on creative " \
|
|
678
|
+
"#{context.dig("creative", "id")}"
|
|
679
|
+
)
|
|
680
|
+
return
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
decision = Scheduler.new(context).schedule([ agent ]).first
|
|
684
|
+
return if decision.nil?
|
|
685
|
+
|
|
686
|
+
comment_id = context.dig("comment", "id")
|
|
687
|
+
case decision[:timing]
|
|
688
|
+
when :rejected
|
|
689
|
+
Rails.logger.info(
|
|
690
|
+
"[DeliveryRecord] Not restoring dispatch for comment #{comment_id}: " \
|
|
691
|
+
"scheduler rejected agent #{agent.id} (#{decision[:reason]})"
|
|
692
|
+
)
|
|
693
|
+
when :delayed
|
|
694
|
+
enqueue_claimed(task, comment_id) do
|
|
695
|
+
AiAgentJob.set(wait: decision[:delay]).perform_later(agent.id, event_name, context)
|
|
696
|
+
end
|
|
697
|
+
else
|
|
698
|
+
enqueue_claimed(task, comment_id) { AiAgentJob.perform_later(agent.id, event_name, context) }
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
private_class_method :enqueue_restored
|
|
702
|
+
|
|
703
|
+
def self.restorable_comments(task, ids)
|
|
704
|
+
Comment.public_only.without_approval_action
|
|
705
|
+
.where(id: ids, topic_id: task.topic_id, creative_id: task.creative_id)
|
|
706
|
+
.where.not(user_id: [ task.agent_id, nil ])
|
|
707
|
+
end
|
|
708
|
+
private_class_method :restorable_comments
|
|
709
|
+
|
|
710
|
+
# Claim the comment, then enqueue — and only in that order. The job leaves
|
|
711
|
+
# no row until a worker runs it, so between the two the comment reads as
|
|
712
|
+
# orphaned to anyone else looking, and the sweep is looking every ten
|
|
713
|
+
# minutes. A claim released on failure is the whole difference between
|
|
714
|
+
# this and a dispatch nobody comes back for.
|
|
715
|
+
def self.enqueue_claimed(task, comment_id)
|
|
716
|
+
return unless claim_restore!(task, comment_id)
|
|
717
|
+
|
|
718
|
+
begin
|
|
719
|
+
yield
|
|
720
|
+
rescue StandardError
|
|
721
|
+
release_restore_claim!(task, comment_id)
|
|
722
|
+
raise
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
private_class_method :enqueue_claimed
|
|
726
|
+
|
|
727
|
+
# The dispatch that was discarded — not a descendant of the turn that
|
|
728
|
+
# discarded it. reanchor_payload is the single door for moving an anchor,
|
|
729
|
+
# and TURN_SCOPED_KEYS is stripped after it because every one of them
|
|
730
|
+
# describes the dead turn: its drop list would make a restored turn that
|
|
731
|
+
# fails again restore the same comments a second time, its history record
|
|
732
|
+
# would licence dropping them, its failed handoff would leave a turn that
|
|
733
|
+
# succeeded unable to cover anything it read, its merged list would
|
|
734
|
+
# re-send comments it was created for, and its acquired anchor would
|
|
735
|
+
# label this turn's own trigger as borrowed.
|
|
736
|
+
def self.restored_context(payload, comment)
|
|
737
|
+
TaskCoalescer.reanchor_payload(payload, comment).except(*TURN_SCOPED_KEYS)
|
|
738
|
+
end
|
|
739
|
+
private_class_method :restored_context
|
|
740
|
+
|
|
741
|
+
# Comment ids some other task in this turn's scope is already on the hook
|
|
742
|
+
# for, as its trigger or as a merged block. Any status counts: what is
|
|
743
|
+
# being asked is whether a dispatch exists at all, since the only thing
|
|
744
|
+
# this restore undoes is a dispatch that was never allowed to become one.
|
|
745
|
+
#
|
|
746
|
+
# A terminal sibling counts too, and deliberately. For a comment to be in
|
|
747
|
+
# DROPPED_KEY *and* have a row, the row came from a different dispatch of
|
|
748
|
+
# the same comment — a redelivered event, which `duplicate_running_for_
|
|
749
|
+
# comment?` lets past a merely queued waiter — so the discarded one is the
|
|
750
|
+
# duplicate and the row is the comment's own turn. Reading `cancelled` or
|
|
751
|
+
# `failed` there as "no longer coverage" would re-dispatch the duplicate
|
|
752
|
+
# of a turn the user stopped, or that reassignment, agent teardown or a
|
|
753
|
+
# workflow stop cancelled on purpose; restore! re-asks the comment's
|
|
754
|
+
# eligibility but has no way to re-ask those decisions. A turn that failed
|
|
755
|
+
# leaves a failed Task the product already shows and offers to retry,
|
|
756
|
+
# which is the ordinary ending for every turn — not this restore's
|
|
757
|
+
# subject, which is the dispatch that left nothing behind at all.
|
|
758
|
+
# DeliveredHistoryDispatchTest reproduces that state and holds both.
|
|
759
|
+
def self.claimed_comment_ids(task)
|
|
760
|
+
Task.where(
|
|
761
|
+
agent_id: task.agent_id, topic_id: task.topic_id,
|
|
762
|
+
creative_id: task.creative_id, trigger_event_name: task.trigger_event_name
|
|
763
|
+
).where.not(id: task.id).select(:id, :trigger_event_payload).flat_map { |other|
|
|
764
|
+
other_payload = other.trigger_event_payload
|
|
765
|
+
next [] unless other_payload.is_a?(Hash)
|
|
766
|
+
|
|
767
|
+
[ other_payload.dig("comment", "id") ] + Array(other_payload[TaskCoalescer::PAYLOAD_KEY])
|
|
768
|
+
}.compact.map(&:to_i)
|
|
769
|
+
end
|
|
770
|
+
private_class_method :claimed_comment_ids
|
|
771
|
+
|
|
772
|
+
# The comments the history window carried *whole*, read off the tag
|
|
773
|
+
# MessageBuilder#append_chat_history writes. A window entry can render a
|
|
774
|
+
# comment without delivering it — a blob or a linked creative's subtree
|
|
775
|
+
# rides only on the trigger path — and those entries go untagged, so this
|
|
776
|
+
# record never claims them. That judgement lives with the builder, which
|
|
777
|
+
# is the only place that knows what it rendered; see
|
|
778
|
+
# MessageBuilder#delivered_whole?.
|
|
779
|
+
def self.history_comment_ids(resolved)
|
|
780
|
+
messages = resolved.is_a?(Hash) ? Array(resolved[:messages] || resolved["messages"]) : Array(resolved)
|
|
781
|
+
messages.filter_map do |message|
|
|
782
|
+
next unless message.is_a?(Hash)
|
|
783
|
+
next unless (message[:kind] || message["kind"]).to_s == "chat_history"
|
|
784
|
+
|
|
785
|
+
id = message[:comment_id] || message["comment_id"]
|
|
786
|
+
id&.to_i
|
|
787
|
+
end.uniq
|
|
788
|
+
end
|
|
789
|
+
private_class_method :history_comment_ids
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|