collavre 0.22.0 → 0.24.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 +1 -0
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +42 -10
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +27 -0
- data/app/models/collavre/user.rb +90 -10
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -20,6 +20,12 @@ module Collavre
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
created = []
|
|
23
|
+
# Per-parent sequence counters. closure_tree orders siblings by the
|
|
24
|
+
# `sequence` column (default 0), but plain Creative.create never assigns
|
|
25
|
+
# it — so without this every imported sibling would share sequence 0 and
|
|
26
|
+
# fall back to a DB-defined tie-break (stable on SQLite, arbitrary on
|
|
27
|
+
# Postgres), corrupting document order. Assign contiguous values here.
|
|
28
|
+
seq = {}
|
|
23
29
|
root = parent
|
|
24
30
|
i = 0
|
|
25
31
|
if create_root
|
|
@@ -28,7 +34,7 @@ module Collavre
|
|
|
28
34
|
end
|
|
29
35
|
if i < lines.size && lines[i] !~ /^\s*#/ && lines[i] !~ /^\s*[-*+]/
|
|
30
36
|
page_title = lines[i].strip
|
|
31
|
-
root =
|
|
37
|
+
root = create_child(user: user, parent: parent, description: page_title, seq: seq)
|
|
32
38
|
created << root
|
|
33
39
|
i += 1
|
|
34
40
|
end
|
|
@@ -73,7 +79,7 @@ module Collavre
|
|
|
73
79
|
code_html = build_code_block_html(code_content, fence_info)
|
|
74
80
|
|
|
75
81
|
new_parent = stack.any? ? stack.last[1] : root
|
|
76
|
-
c =
|
|
82
|
+
c = create_child(user: user, parent: new_parent, description: code_html, seq: seq)
|
|
77
83
|
created << c
|
|
78
84
|
next
|
|
79
85
|
end
|
|
@@ -81,7 +87,7 @@ module Collavre
|
|
|
81
87
|
if (table_data = parse_markdown_table(lines, i))
|
|
82
88
|
table_html = build_table_html(table_data, image_refs)
|
|
83
89
|
new_parent = stack.any? ? stack.last[1] : root
|
|
84
|
-
c =
|
|
90
|
+
c = create_child(user: user, parent: new_parent, description: table_html, seq: seq)
|
|
85
91
|
created << c
|
|
86
92
|
i = table_data[:next_index]
|
|
87
93
|
elsif line =~ /^(#+)\s+(.*)$/
|
|
@@ -89,7 +95,7 @@ module Collavre
|
|
|
89
95
|
desc = ApplicationController.helpers.markdown_links_to_html($2.strip, image_refs)
|
|
90
96
|
stack.pop while stack.any? && stack.last[0] >= level
|
|
91
97
|
new_parent = stack.any? ? stack.last[1] : root
|
|
92
|
-
c =
|
|
98
|
+
c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
|
|
93
99
|
created << c
|
|
94
100
|
stack << [ level, c ]
|
|
95
101
|
i += 1
|
|
@@ -99,14 +105,14 @@ module Collavre
|
|
|
99
105
|
bullet_level = 10 + indent / 2
|
|
100
106
|
stack.pop while stack.any? && stack.last[0] >= bullet_level
|
|
101
107
|
new_parent = stack.any? ? stack.last[1] : root
|
|
102
|
-
c =
|
|
108
|
+
c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
|
|
103
109
|
created << c
|
|
104
110
|
stack << [ bullet_level, c ]
|
|
105
111
|
i += 1
|
|
106
112
|
elsif !line.strip.empty?
|
|
107
113
|
desc = ApplicationController.helpers.markdown_links_to_html(line.strip, image_refs)
|
|
108
114
|
new_parent = stack.any? ? stack.last[1] : root
|
|
109
|
-
c =
|
|
115
|
+
c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
|
|
110
116
|
created << c
|
|
111
117
|
i += 1
|
|
112
118
|
else
|
|
@@ -121,6 +127,24 @@ module Collavre
|
|
|
121
127
|
created
|
|
122
128
|
end
|
|
123
129
|
|
|
130
|
+
# Creates a child under `parent` with an explicit `sequence`, tracking the
|
|
131
|
+
# next value per parent in `seq`. The first time a parent is seen the
|
|
132
|
+
# counter is seeded past any pre-existing children so an import into an
|
|
133
|
+
# already-populated parent appends after them instead of colliding at 0.
|
|
134
|
+
# A nil parent means a root-level import (top-level page with no parent_id);
|
|
135
|
+
# closure_tree orders roots by `sequence` too, so seed from Creative.roots.
|
|
136
|
+
def self.create_child(user:, parent:, description:, seq:)
|
|
137
|
+
key = parent&.id
|
|
138
|
+
unless seq.key?(key)
|
|
139
|
+
siblings = parent ? parent.children : Creative.roots
|
|
140
|
+
max = siblings.maximum(:sequence)
|
|
141
|
+
seq[key] = max ? max + 1 : 0
|
|
142
|
+
end
|
|
143
|
+
creative = Creative.create(user: user, parent: parent, description: description, sequence: seq[key])
|
|
144
|
+
seq[key] += 1
|
|
145
|
+
creative
|
|
146
|
+
end
|
|
147
|
+
|
|
124
148
|
def self.build_code_block_html(code_content, language = nil)
|
|
125
149
|
escaped_content = ERB::Util.html_escape(code_content)
|
|
126
150
|
lang_class = language.present? ? " class=\"language-#{ERB::Util.html_escape(language)}\"" : ""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Mobile
|
|
5
|
+
# Turns an approval/permission comment or an agent reply into a SHORT,
|
|
6
|
+
# decision-oriented line of speech. Deterministic (no LLM) so the hot path
|
|
7
|
+
# stays fast: it extracts the tool name / human description from the
|
|
8
|
+
# permission action payload and renders a localized, spoken-friendly summary.
|
|
9
|
+
class EventSummarizer
|
|
10
|
+
def initialize(locale:)
|
|
11
|
+
@locale = (locale.presence || I18n.default_locale).to_s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def approval_summary(comment:, label:)
|
|
15
|
+
payload = parse_action(comment)
|
|
16
|
+
I18n.with_locale(@locale) do
|
|
17
|
+
detail =
|
|
18
|
+
payload&.dig("description").presence ||
|
|
19
|
+
payload&.dig("tool_name").presence ||
|
|
20
|
+
I18n.t("collavre.mobile.summary.a_tool")
|
|
21
|
+
I18n.t("collavre.mobile.summary.approval", label: label, detail: flatten(detail))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def flatten(text)
|
|
28
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_action(comment)
|
|
32
|
+
return nil unless comment.approval_action?
|
|
33
|
+
|
|
34
|
+
JSON.parse(comment.action)
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -126,24 +126,10 @@ module Collavre
|
|
|
126
126
|
"id" => @agent.id,
|
|
127
127
|
"name" => @agent.name,
|
|
128
128
|
"display_name" => @agent.display_name,
|
|
129
|
-
"type" =>
|
|
129
|
+
"type" => AgentTypeClassifier.classify(@agent)
|
|
130
130
|
}
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
def extract_agent_type
|
|
134
|
-
# Extract agent type from system_prompt or default
|
|
135
|
-
prompt = @agent.system_prompt.to_s.downcase
|
|
136
|
-
case prompt
|
|
137
|
-
when /developer|개발/ then "developer"
|
|
138
|
-
when /pm|project.?manager|프로젝트/ then "pm"
|
|
139
|
-
when /qa|test|quality|테스트|품질/ then "qa"
|
|
140
|
-
when /research|조사|연구/ then "researcher"
|
|
141
|
-
when /market|마케팅/ then "marketer"
|
|
142
|
-
when /plan|기획/ then "planner"
|
|
143
|
-
else "agent"
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
133
|
def build_collaborators
|
|
148
134
|
return [] unless @creative
|
|
149
135
|
|
|
@@ -45,7 +45,12 @@ module Collavre
|
|
|
45
45
|
|
|
46
46
|
Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil)
|
|
47
47
|
.where("content LIKE ?", "⏳%")
|
|
48
|
-
.
|
|
48
|
+
.find_each do |notice|
|
|
49
|
+
# System promotion, not user abandonment: do not let the destroy
|
|
50
|
+
# callback cancel other still-queued waiters in this topic.
|
|
51
|
+
notice.suppress_waiter_cancellation = true
|
|
52
|
+
notice.destroy
|
|
53
|
+
end
|
|
49
54
|
end
|
|
50
55
|
private_class_method :cleanup_waiting_notices!
|
|
51
56
|
|
|
@@ -59,7 +64,7 @@ module Collavre
|
|
|
59
64
|
return unless creative_id && context&.key?("topic")
|
|
60
65
|
|
|
61
66
|
topic_id = context.dig("topic", "id")
|
|
62
|
-
scope = Comment.public_only
|
|
67
|
+
scope = Comment.public_only.without_approval_action
|
|
63
68
|
.where(creative_id: creative_id, topic_id: topic_id)
|
|
64
69
|
.where.not(user_id: [ task.agent_id, nil ])
|
|
65
70
|
.order(created_at: :desc)
|
|
@@ -184,17 +189,38 @@ module Collavre
|
|
|
184
189
|
creative = Creative.find_by(id: creative_id)
|
|
185
190
|
return unless creative
|
|
186
191
|
|
|
187
|
-
|
|
188
|
-
reason_text = I18n.t(
|
|
189
|
-
"collavre.orchestration.waiting_reasons.#{reason_key}",
|
|
190
|
-
default: reason_key.to_s.humanize
|
|
191
|
-
)
|
|
192
|
+
reason_text = waiting_reason_text(decision[:reason] || :unknown, topic_id, creative_id)
|
|
192
193
|
|
|
193
194
|
creative.comments.create!(
|
|
194
195
|
content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
|
|
195
196
|
topic_id: topic_id,
|
|
196
197
|
private: false,
|
|
197
|
-
skip_default_user: true
|
|
198
|
+
skip_default_user: true,
|
|
199
|
+
# Only :deferred queues a topic waiter; mark it so its stop button can
|
|
200
|
+
# target the blocker. :delayed (busy / rate_limited) notices stay false.
|
|
201
|
+
topic_concurrency_defer: decision[:timing] == :deferred
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Human-readable reason for the "⏳" waiting notice. For topic-concurrency
|
|
206
|
+
# deferrals, name the agent(s) actually holding the topic's running slot so
|
|
207
|
+
# a waiting user can see *who* is blocking them (and reach that task's stop
|
|
208
|
+
# button) rather than an anonymous "another task is running" dead end.
|
|
209
|
+
def waiting_reason_text(reason_key, topic_id, creative_id)
|
|
210
|
+
if reason_key == :topic_concurrency && topic_id
|
|
211
|
+
names = Task.running_for_topic(topic_id, creative_id)
|
|
212
|
+
.includes(:agent).filter_map { |t| t.agent&.name }.uniq
|
|
213
|
+
if names.any?
|
|
214
|
+
return I18n.t(
|
|
215
|
+
"collavre.orchestration.waiting_reasons.topic_concurrency_with_agent",
|
|
216
|
+
agent: names.join(", ")
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
I18n.t(
|
|
222
|
+
"collavre.orchestration.waiting_reasons.#{reason_key}",
|
|
223
|
+
default: reason_key.to_s.humanize
|
|
198
224
|
)
|
|
199
225
|
end
|
|
200
226
|
end
|
|
@@ -25,6 +25,13 @@ module Collavre
|
|
|
25
25
|
def select(candidates)
|
|
26
26
|
return [] if candidates.empty?
|
|
27
27
|
|
|
28
|
+
# Review feedback is forced routing: the Matcher already restricts candidates
|
|
29
|
+
# to the quoted comment's author, the sole agent ReviewHandler will accept.
|
|
30
|
+
# Floor-control arbitration (bid scoring, round_robin) among a forced single
|
|
31
|
+
# recipient is meaningless, and a low bid score must not drop it (e.g. bid
|
|
32
|
+
# strategy with bid_fallback_enabled: false) or the Review button no-ops.
|
|
33
|
+
return candidates if review_message?
|
|
34
|
+
|
|
28
35
|
strategy = @policy_resolver.arbitration_strategy
|
|
29
36
|
selected = apply_strategy(strategy, candidates)
|
|
30
37
|
|
|
@@ -37,6 +44,15 @@ module Collavre
|
|
|
37
44
|
|
|
38
45
|
private
|
|
39
46
|
|
|
47
|
+
# True when the triggering comment is review feedback (mirrors Matcher's
|
|
48
|
+
# review-author routing), so arbitration can be bypassed for forced routing.
|
|
49
|
+
def review_message?
|
|
50
|
+
comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
|
|
51
|
+
return false unless comment_id
|
|
52
|
+
|
|
53
|
+
Comment.find_by(id: comment_id)&.review_message? || false
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
def apply_strategy(strategy, candidates)
|
|
41
57
|
case strategy
|
|
42
58
|
when "all"
|
|
@@ -21,6 +21,10 @@ module Collavre
|
|
|
21
21
|
|
|
22
22
|
# Returns Array of User (AI agents) that are qualified to respond
|
|
23
23
|
def match
|
|
24
|
+
# Priority 0: Review routing (exclusive)
|
|
25
|
+
review_result = match_by_review_author
|
|
26
|
+
return review_result unless review_result.nil?
|
|
27
|
+
|
|
24
28
|
# Priority 1: Mention-based routing (exclusive)
|
|
25
29
|
mentioned_result = match_by_mention
|
|
26
30
|
return mentioned_result unless mentioned_result.nil?
|
|
@@ -31,6 +35,39 @@ module Collavre
|
|
|
31
35
|
|
|
32
36
|
private
|
|
33
37
|
|
|
38
|
+
# Returns [author] for a routable review message, [] for an unroutable one,
|
|
39
|
+
# nil when this is not a review message.
|
|
40
|
+
#
|
|
41
|
+
# A review message can ONLY be handled by the author of the quoted comment:
|
|
42
|
+
# ReviewHandler#eligible? requires quoted_comment.user_id == agent.id, so any
|
|
43
|
+
# other agent would post a stray reply instead of the in-place review update.
|
|
44
|
+
# Route exclusively to that author — independent of mention or
|
|
45
|
+
# routing_expression — so the Review button is reliable even when the author
|
|
46
|
+
# has none (e.g. /compress summaries authored by a primary agent resolved via
|
|
47
|
+
# primary_agent_id). Without this the button renders but the feedback no-ops.
|
|
48
|
+
#
|
|
49
|
+
# Once this IS a review message, the only safe outcomes are exclusive-route or
|
|
50
|
+
# block ([]) — never nil. ResponseFinalizer keys on review_message? regardless
|
|
51
|
+
# of which agent the matcher picks, so a fall-through to mention/expression
|
|
52
|
+
# routing would schedule an agent that ReviewHandler#handle then bails on,
|
|
53
|
+
# producing the very stray reply this routing exists to prevent.
|
|
54
|
+
def match_by_review_author
|
|
55
|
+
return nil unless matched_comment&.review_message?
|
|
56
|
+
|
|
57
|
+
author = matched_comment.quoted_comment&.user
|
|
58
|
+
return [] unless author&.ai_user?
|
|
59
|
+
|
|
60
|
+
# Mirror ReviewHandler#eligible?: if the handler would reject this quote
|
|
61
|
+
# (private, or from another topic/creative), no agent can handle the review.
|
|
62
|
+
# Block rather than fall through, so it can't become a stray normal reply.
|
|
63
|
+
return [] unless Collavre::AiAgent::ReviewHandler.eligible?(matched_comment, author)
|
|
64
|
+
|
|
65
|
+
return [] unless has_creative_permission?(author)
|
|
66
|
+
return [] unless eligible_in_inbox?(author)
|
|
67
|
+
|
|
68
|
+
[ author ]
|
|
69
|
+
end
|
|
70
|
+
|
|
34
71
|
# Returns Array of agents if mention found, nil if no mention
|
|
35
72
|
# When mention IS found, this is exclusive routing
|
|
36
73
|
def match_by_mention
|
|
@@ -47,6 +84,11 @@ module Collavre
|
|
|
47
84
|
# Permission check for mentioned AI agent
|
|
48
85
|
return [] unless has_creative_permission?(mentioned_user)
|
|
49
86
|
|
|
87
|
+
# Inbox confinement applies to mentions too: a live Claude Channel
|
|
88
|
+
# session agent must not be pulled into an ordinary inbox topic, even by
|
|
89
|
+
# an explicit @mention (see #eligible_in_inbox?).
|
|
90
|
+
return [] unless eligible_in_inbox?(mentioned_user)
|
|
91
|
+
|
|
50
92
|
[ mentioned_user ]
|
|
51
93
|
end
|
|
52
94
|
|
|
@@ -57,11 +99,26 @@ module Collavre
|
|
|
57
99
|
|
|
58
100
|
agents.select do |agent|
|
|
59
101
|
next false unless has_creative_permission?(agent)
|
|
102
|
+
next false unless eligible_in_inbox?(agent)
|
|
60
103
|
|
|
61
104
|
evaluate_routing_expression(agent)
|
|
62
105
|
end
|
|
63
106
|
end
|
|
64
107
|
|
|
108
|
+
# A Claude Channel session agent holds inbox-wide :feedback +
|
|
109
|
+
# routing_expression="true", so within the user's Inbox it would otherwise
|
|
110
|
+
# match EVERY topic. Confine it to its own registered session topic (the
|
|
111
|
+
# topic it is primary_agent on, carrying a session_id) so ordinary inbox
|
|
112
|
+
# topics — Main, Content, user threads — stay identical to a normal topic
|
|
113
|
+
# and are never absorbed by a live session. Only the inbox is affected: on
|
|
114
|
+
# work/project creatives the agent still matches via routing_expression.
|
|
115
|
+
def eligible_in_inbox?(agent)
|
|
116
|
+
return true unless matched_creative&.inbox?
|
|
117
|
+
return true unless agent.claude_channel_agent?
|
|
118
|
+
|
|
119
|
+
matched_topic&.session_id.present? && matched_topic.primary_agent_id == agent.id
|
|
120
|
+
end
|
|
121
|
+
|
|
65
122
|
def evaluate_routing_expression(agent)
|
|
66
123
|
expression = agent.routing_expression.strip
|
|
67
124
|
|
|
@@ -85,14 +142,32 @@ module Collavre
|
|
|
85
142
|
def has_creative_permission?(agent)
|
|
86
143
|
# All agents need feedback permission on the creative to respond
|
|
87
144
|
# searchable only affects discoverability, not response permission
|
|
88
|
-
|
|
89
|
-
return false unless creative_id
|
|
90
|
-
|
|
91
|
-
creative = Creative.find_by(id: creative_id)
|
|
145
|
+
creative = matched_creative
|
|
92
146
|
return false unless creative
|
|
93
147
|
|
|
94
148
|
creative.has_permission?(agent, :feedback)
|
|
95
149
|
end
|
|
150
|
+
|
|
151
|
+
def matched_creative
|
|
152
|
+
return @matched_creative if defined?(@matched_creative)
|
|
153
|
+
|
|
154
|
+
creative_id = @context.dig("creative", "id") || @context.dig(:creative, :id)
|
|
155
|
+
@matched_creative = creative_id && Creative.find_by(id: creative_id)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def matched_topic
|
|
159
|
+
return @matched_topic if defined?(@matched_topic)
|
|
160
|
+
|
|
161
|
+
topic_id = @context.dig("topic", "id") || @context.dig(:topic, :id)
|
|
162
|
+
@matched_topic = topic_id && Topic.find_by(id: topic_id)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def matched_comment
|
|
166
|
+
return @matched_comment if defined?(@matched_comment)
|
|
167
|
+
|
|
168
|
+
comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
|
|
169
|
+
@matched_comment = comment_id && Comment.find_by(id: comment_id)
|
|
170
|
+
end
|
|
96
171
|
end
|
|
97
172
|
end
|
|
98
173
|
end
|
|
@@ -39,9 +39,10 @@ module Collavre
|
|
|
39
39
|
},
|
|
40
40
|
"stuck_detection" => {
|
|
41
41
|
"enabled" => false,
|
|
42
|
-
"task_stuck_threshold_minutes" => 30,
|
|
43
|
-
"creative_stall_threshold_minutes" => 120,
|
|
44
|
-
"
|
|
42
|
+
"task_stuck_threshold_minutes" => 30, # Task running for > N minutes
|
|
43
|
+
"creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
|
|
44
|
+
"queued_orphan_threshold_minutes" => 5, # Queued waiter with no live blocker for > N minutes
|
|
45
|
+
"create_system_comment" => true # Create system comment on escalation
|
|
45
46
|
},
|
|
46
47
|
"collaboration" => {
|
|
47
48
|
"a2a_focus_instruction" => nil, # nil = locale default
|
|
@@ -32,6 +32,7 @@ module Collavre
|
|
|
32
32
|
|
|
33
33
|
stuck_items = []
|
|
34
34
|
stuck_items.concat(detect_stuck_tasks(config))
|
|
35
|
+
stuck_items.concat(detect_orphaned_queued_tasks(config))
|
|
35
36
|
stuck_items.concat(detect_stalled_creatives(config))
|
|
36
37
|
|
|
37
38
|
auto_recover_stuck_tasks(stuck_items)
|
|
@@ -47,54 +48,124 @@ module Collavre
|
|
|
47
48
|
|
|
48
49
|
stuck_items = []
|
|
49
50
|
stuck_items.concat(detect_stuck_tasks(config))
|
|
51
|
+
stuck_items.concat(detect_orphaned_queued_tasks(config))
|
|
50
52
|
stuck_items.concat(detect_stalled_creatives(config))
|
|
51
53
|
stuck_items
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
private
|
|
55
57
|
|
|
56
|
-
# Auto-recover stuck
|
|
58
|
+
# Auto-recover stuck items: fail-and-drain for live tasks, self-heal for
|
|
59
|
+
# orphaned queued waiters.
|
|
57
60
|
def auto_recover_stuck_tasks(stuck_items)
|
|
58
61
|
stuck_items.each do |stuck_item|
|
|
59
|
-
|
|
62
|
+
case stuck_item.type
|
|
63
|
+
when :task then recover_stuck_task(stuck_item)
|
|
64
|
+
when :queued_orphan then recover_orphaned_queued_task(stuck_item)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
# Self-heal an orphaned queued waiter: its blocker is gone but it was never
|
|
70
|
+
# drained (missed dequeue / enqueue-vs-terminate TOCTOU race / lost
|
|
71
|
+
# cross-process broadcast). Re-check liveness atomically, then drain.
|
|
72
|
+
def recover_orphaned_queued_task(stuck_item)
|
|
73
|
+
task = stuck_item.item.reload
|
|
74
|
+
return unless task.status == "queued"
|
|
75
|
+
|
|
76
|
+
# If the topic is back at capacity since detection, the normal terminal
|
|
77
|
+
# callback will drain the queue when a slot frees — leave it alone. This
|
|
78
|
+
# check also bounds promotions across one detection cycle: dequeue moves
|
|
79
|
+
# a waiter queued -> pending synchronously, which occupying_topic_slot
|
|
80
|
+
# counts, so consecutive orphans each fill exactly one free slot until the
|
|
81
|
+
# topic is full. With topic_max_concurrent_jobs > 1 and several free slots
|
|
82
|
+
# all are filled this cycle; a per-topic dedupe would instead leave every
|
|
83
|
+
# waiter past the first orphaned until the next run.
|
|
84
|
+
return if topic_at_capacity?(task)
|
|
85
|
+
|
|
86
|
+
Rails.logger.info(
|
|
87
|
+
"[StuckDetector] Self-healing orphaned queued task #{task.id} " \
|
|
88
|
+
"(topic=#{task.topic_id}, creative=#{task.creative_id}): no live blocker, draining queue"
|
|
89
|
+
)
|
|
90
|
+
AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Rails.logger.error("[StuckDetector] Self-heal failed for queued task #{stuck_item.item.id}: #{e.message}")
|
|
93
|
+
end
|
|
63
94
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
# Whether a topic holds no free concurrency slot for a queued waiter.
|
|
96
|
+
# Compares occupied slots against topic_max (the scheduler's admission rule)
|
|
97
|
+
# rather than treating any single live blocker as full capacity — otherwise,
|
|
98
|
+
# with topic_max_concurrent_jobs > 1, a missed dequeue would leave the waiter
|
|
99
|
+
# suppressed until the *last* blocker terminates instead of the moment a slot
|
|
100
|
+
# frees up. Occupancy counts pending as well as running/delegated: a waiter
|
|
101
|
+
# that a prior dequeue already claimed sits in "pending" until its AiAgentJob
|
|
102
|
+
# starts, and the detector fires precisely on the backed-up condition where
|
|
103
|
+
# that window is wide — counting only running/delegated would see a free slot
|
|
104
|
+
# and promote a second waiter into a slot that is already claimed (double
|
|
105
|
+
# dequeue). This is intentionally stricter than the scheduler's reactive
|
|
106
|
+
# check; being stricter can only suppress recovery, never over-admit. When no
|
|
107
|
+
# topic limit is configured the scheduler never defers, so fall back to the
|
|
108
|
+
# conservative "any occupied slot" check.
|
|
109
|
+
def topic_at_capacity?(task)
|
|
110
|
+
topic_max = scheduling_resolver_for(task).topic_max_concurrent_jobs
|
|
111
|
+
occupied_count = Task.occupying_topic_slot(task.topic_id, task.creative_id).count
|
|
112
|
+
return occupied_count.positive? unless topic_max
|
|
113
|
+
|
|
114
|
+
occupied_count >= topic_max
|
|
115
|
+
end
|
|
69
116
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
117
|
+
# Resolve scheduling policy against the queued task's own topic/creative
|
|
118
|
+
# context — the same context the scheduler used to admit it. The detector's
|
|
119
|
+
# default resolver is built with an empty context (it only needs the global
|
|
120
|
+
# stuck_detection policy), so reading topic_max_concurrent_jobs off it would
|
|
121
|
+
# see only the global default and ignore any topic-/creative-scoped override.
|
|
122
|
+
# That mismatch would violate a topic's serialization when its scoped limit
|
|
123
|
+
# is below the global value, or wrongly suppress recovery when it is above.
|
|
124
|
+
def scheduling_resolver_for(task)
|
|
125
|
+
context = {}
|
|
126
|
+
context["creative"] = { "id" => task.creative_id } if task.creative_id
|
|
127
|
+
context["topic"] = { "id" => task.topic_id } if task.topic_id
|
|
128
|
+
PolicyResolver.new(context)
|
|
129
|
+
end
|
|
75
130
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
rescue StandardError => e
|
|
87
|
-
Rails.logger.error(
|
|
88
|
-
"[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
|
|
89
|
-
)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
131
|
+
# Fail a stuck running/delegated task and drain the topic queue.
|
|
132
|
+
def recover_stuck_task(stuck_item)
|
|
133
|
+
task = stuck_item.item
|
|
134
|
+
return unless %w[running delegated].include?(task.status)
|
|
135
|
+
|
|
136
|
+
task.update!(status: "failed")
|
|
137
|
+
Rails.logger.info(
|
|
138
|
+
"[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
|
|
139
|
+
"marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
|
|
140
|
+
)
|
|
92
141
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
# Release resources held by the stuck task
|
|
143
|
+
if task.agent
|
|
144
|
+
tracker = ResourceTracker.for(task.agent)
|
|
145
|
+
tracker.release!(task.id)
|
|
97
146
|
end
|
|
147
|
+
|
|
148
|
+
# If this was a workflow subtask, fail the parent so the workflow
|
|
149
|
+
# advances instead of staying running with pending_creative_ids
|
|
150
|
+
# pointing at a child that's been failed underneath it.
|
|
151
|
+
if task.parent_task_id.present?
|
|
152
|
+
begin
|
|
153
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
|
|
154
|
+
task,
|
|
155
|
+
error_message: "Auto-recovered: stuck for " \
|
|
156
|
+
"#{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
|
|
157
|
+
)
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
Rails.logger.error(
|
|
160
|
+
"[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Drain the queue for the topic so waiting tasks can execute
|
|
166
|
+
AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{stuck_item.item.id}: #{e.message}")
|
|
98
169
|
end
|
|
99
170
|
|
|
100
171
|
def stuck_detection_config
|
|
@@ -129,6 +200,38 @@ module Collavre
|
|
|
129
200
|
end
|
|
130
201
|
end
|
|
131
202
|
|
|
203
|
+
# Detect orphaned queued waiters: tasks left in "queued" for a topic that
|
|
204
|
+
# holds no occupied slot (no running/delegated blocker and no pending claim).
|
|
205
|
+
# A queued task's only path to
|
|
206
|
+
# execution is dequeue_next_for_topic, which fires when the blocker reaches
|
|
207
|
+
# a terminal status. If that single hand-off is missed — an
|
|
208
|
+
# enqueue-vs-terminate TOCTOU race, or a lost cross-process broadcast — the
|
|
209
|
+
# blocker is already gone and nothing will ever wake the waiter: it shows
|
|
210
|
+
# "⏳" waiting notice forever. These are invisible to detect_stuck_tasks, which only
|
|
211
|
+
# scans running/delegated. There is no stop button to press here because the
|
|
212
|
+
# blocker no longer exists; the fix is to drain the queue (see
|
|
213
|
+
# recover_orphaned_queued_task), not to escalate.
|
|
214
|
+
def detect_orphaned_queued_tasks(config)
|
|
215
|
+
threshold_minutes = config["queued_orphan_threshold_minutes"] || 5
|
|
216
|
+
threshold_time = threshold_minutes.minutes.ago
|
|
217
|
+
|
|
218
|
+
Task.where(status: "queued")
|
|
219
|
+
.where("updated_at < ?", threshold_time)
|
|
220
|
+
.filter_map do |task|
|
|
221
|
+
# Only orphaned if the topic has a free slot. A waiter is legitimately
|
|
222
|
+
# queued while the topic is at capacity.
|
|
223
|
+
next if topic_at_capacity?(task)
|
|
224
|
+
|
|
225
|
+
StuckItem.new(
|
|
226
|
+
type: :queued_orphan,
|
|
227
|
+
item: task,
|
|
228
|
+
reason: :orphaned_waiter,
|
|
229
|
+
stuck_since: task.updated_at,
|
|
230
|
+
escalation_targets: []
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
132
235
|
# Detect creatives that have stalled (no activity for extended period)
|
|
133
236
|
def detect_stalled_creatives(config)
|
|
134
237
|
threshold_minutes = config["creative_stall_threshold_minutes"] || 120
|
|
@@ -216,6 +319,10 @@ module Collavre
|
|
|
216
319
|
escalated_count = 0
|
|
217
320
|
|
|
218
321
|
stuck_items.each do |stuck_item|
|
|
322
|
+
# Orphaned queued waiters are silently self-healed (queue drained),
|
|
323
|
+
# not escalated to admins — there is no human action to take.
|
|
324
|
+
next if stuck_item.type == :queued_orphan
|
|
325
|
+
|
|
219
326
|
escalated = escalate_item(stuck_item, config)
|
|
220
327
|
if escalated
|
|
221
328
|
mark_escalated(stuck_item)
|
|
@@ -34,23 +34,10 @@ module Collavre
|
|
|
34
34
|
"name" => user.name,
|
|
35
35
|
"display_name" => user.respond_to?(:display_name) ? user.display_name : user.name,
|
|
36
36
|
"is_ai" => user.ai_user?,
|
|
37
|
-
"type" => user.ai_user? ?
|
|
37
|
+
"type" => user.ai_user? ? AgentTypeClassifier.classify(user) : "human"
|
|
38
38
|
}
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def extract_agent_type(user)
|
|
42
|
-
prompt = user.system_prompt.to_s.downcase
|
|
43
|
-
case prompt
|
|
44
|
-
when /developer|개발/ then "developer"
|
|
45
|
-
when /pm|project.?manager|프로젝트/ then "pm"
|
|
46
|
-
when /qa|test|quality|테스트|품질/ then "qa"
|
|
47
|
-
when /research|조사|연구/ then "researcher"
|
|
48
|
-
when /market|마케팅/ then "marketer"
|
|
49
|
-
when /plan|기획/ then "planner"
|
|
50
|
-
else "agent"
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
41
|
def mentioned_user(chat_context)
|
|
55
42
|
content = chat_context["content"]
|
|
56
43
|
return nil unless content
|
|
@@ -18,9 +18,10 @@ module Collavre
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
tool_param :operations, description: "Array of operation objects. Each object must have an 'action' key.\n\n" \
|
|
21
|
-
"For 'create': { action: 'create', parent_id: <int>, description: <string>, progress: <float>, after_id: <int>, before_id: <int> }\n" \
|
|
22
|
-
"For 'update': { action: 'update', id: <int>, description: <string>, progress: 1.0, parent_id: <int> } — progress only accepts 1.0 (complete) and only on leaf Creatives\n" \
|
|
21
|
+
"For 'create': { action: 'create', parent_id: <int>, description: <markdown string>, progress: <float>, after_id: <int>, before_id: <int> }\n" \
|
|
22
|
+
"For 'update': { action: 'update', id: <int>, description: <markdown string>, progress: 1.0, parent_id: <int> } — progress only accepts 1.0 (complete) and only on leaf Creatives\n" \
|
|
23
23
|
"For 'delete': { action: 'delete', id: <int> }\n\n" \
|
|
24
|
+
"The 'description' field is written as Markdown (GitHub-Flavored).\n" \
|
|
24
25
|
"Fields other than 'action' and 'id'/'parent_id' are optional.", required: true
|
|
25
26
|
|
|
26
27
|
class BatchRollbackError < StandardError
|