collavre 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb2d48043704a517d8970498aa1057e5eaa29133304fe4fb82ebe37ac96b8458
4
- data.tar.gz: d517609fd42be9255120f2faafe4acaf0fa36a2797c96021c5502acb6206af66
3
+ metadata.gz: 6e8aa662b4715665ebaa090bc8975037ca96c4059d53acfa379ccc88217487f8
4
+ data.tar.gz: 2d61c18b32e5228bbe84cd9cf8fb73898eedab099ce11959badf95194fb0b431
5
5
  SHA512:
6
- metadata.gz: ca97eb7ec6d858f67b1d3da6f7db767d15a7ba16a14b3dcb883c051a8bef55b2d679441ff8eb32cd9455528ef977f74430e535553cf9511d74024a23d7c8d0e7
7
- data.tar.gz: a5cccbe54450cc225d25935ab85720302f61354785dfac02c1170933369c470767786a7c2fc683a93f907ffdc6ce5a34cb2abd1fec8b77aa6fbce9408c68873d
6
+ metadata.gz: 3b0baedb45c15a38689e9db2d68bec4fb1ec8cecc7fedfc4a213236bba81e5c65af9eaf54db14b9916811060216856aefaa327ecaa650a8a1ae7ed6a212b74bf
7
+ data.tar.gz: c667c0457625a3c8d0bd7fcf0dd55f9388e6f01ddf23bc3c6f3741bef5abadf3be10031bec72df99851d4aea24815fbb8d7f9caa00d94378f02c0f3790da0cf9
@@ -17,7 +17,7 @@ module Collavre
17
17
  validate_policies!(parsed)
18
18
  apply_policies!(parsed)
19
19
 
20
- redirect_to main_app.admin_orchestration_path, notice: t("admin.orchestration.updated")
20
+ redirect_to admin_orchestration_path, notice: t("admin.orchestration.updated")
21
21
  rescue Psych::SyntaxError => e
22
22
  flash.now[:alert] = t("admin.orchestration.yaml_syntax_error", message: e.message)
23
23
  @policies_yaml = yaml_content
@@ -179,7 +179,7 @@ module Collavre
179
179
  chat: {
180
180
  content: @comment.content
181
181
  }
182
- }) unless @comment.private?
182
+ }) unless @comment.private? || response.present?
183
183
  @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
184
184
  render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }, status: :created
185
185
  else
@@ -491,6 +491,12 @@ module Collavre
491
491
  aliases: [ "/cal" ],
492
492
  description: I18n.t("collavre.comments.command_menu.calendar_description"),
493
493
  args: I18n.t("collavre.comments.command_menu.calendar_args")
494
+ },
495
+ {
496
+ name: "topic",
497
+ label: "/topic",
498
+ description: I18n.t("collavre.comments.command_menu.topic_description"),
499
+ args: I18n.t("collavre.comments.command_menu.topic_args")
494
500
  }
495
501
  ] + mcp_command_items
496
502
  end
@@ -75,10 +75,24 @@ module Collavre
75
75
  :ok
76
76
  end
77
77
 
78
+ def mentioned_users
79
+ return Collavre.user_class.none unless user
80
+ emails = mentioned_emails - [ user.email.downcase ]
81
+ names = mentioned_names - [ user.name.downcase ]
82
+
83
+ origin = creative.effective_origin
84
+ mentionable_users = Collavre.user_class.mentionable_for(origin)
85
+
86
+ scope = Collavre.user_class.none
87
+ scope = scope.or(mentionable_users.where(email: emails)) if emails.any?
88
+ scope = scope.or(mentionable_users.where("LOWER(name) IN (?)", names)) if names.any?
89
+ scope
90
+ end
91
+
78
92
  private
79
93
 
80
94
  def cancel_pending_tasks
81
- Task.where(status: %w[pending running]).each do |task|
95
+ Task.where(status: %w[pending running queued]).each do |task|
82
96
  if task.trigger_event_payload&.dig("comment", "id") == id
83
97
  task.update!(status: "cancelled")
84
98
  end
@@ -126,20 +140,6 @@ module Collavre
126
140
  .uniq
127
141
  end
128
142
 
129
- def mentioned_users
130
- return Collavre.user_class.none unless user
131
- emails = mentioned_emails - [ user.email.downcase ]
132
- names = mentioned_names - [ user.name.downcase ]
133
-
134
- origin = creative.effective_origin
135
- mentionable_users = Collavre.user_class.mentionable_for(origin)
136
-
137
- scope = Collavre.user_class.none
138
- scope = scope.or(mentionable_users.where(email: emails)) if emails.any?
139
- scope = scope.or(mentionable_users.where("LOWER(name) IN (?)", names)) if names.any?
140
- scope
141
- end
142
-
143
143
  def broadcast_create
144
144
  return if private?
145
145
  broadcast_append_later_to([ creative, :comments ], target: "comments-list", partial: "collavre/comments/comment")
@@ -34,19 +34,12 @@ module Collavre
34
34
  content = comment.content.to_s.strip
35
35
 
36
36
  # Extract topic name in quotes
37
- name_match = content.match(/[""]([^""]+)[""]|"([^"]+)"/)
37
+ name_match = content.match(/[\u201c\u201d""]([^"\u201c\u201d""]+)[\u201c\u201d""]|"([^"]+)"/)
38
38
  topic_name = name_match ? (name_match[1] || name_match[2]) : nil
39
39
 
40
40
  return if topic_name.blank?
41
41
 
42
- # Extract @mentions for primary agent
43
- agent_match = content.match(/@(\w+)/)
44
- agent_name = agent_match ? agent_match[1] : nil
45
-
46
- {
47
- name: topic_name,
48
- agent_name: agent_name
49
- }
42
+ { name: topic_name }
50
43
  end
51
44
  end
52
45
 
@@ -54,45 +47,47 @@ module Collavre
54
47
  data = parsed_args
55
48
  return I18n.t("collavre.comments.topic_command.missing_name") if data.blank?
56
49
 
57
- # Create new topic
58
- topic = Topic.create!(
59
- creative: creative,
60
- user: user,
61
- name: data[:name]
62
- )
50
+ # Find primary agent from @mentions using the same parsing as chat
51
+ primary_agent = comment.mentioned_users.find(&:ai_user?)
63
52
 
64
- # Set primary agent if specified
65
- primary_agent = find_agent(data[:agent_name]) if data[:agent_name].present?
53
+ # Find existing topic or create new one
54
+ existing_topic = Topic.find_by(creative: creative, name: data[:name])
66
55
 
67
- if primary_agent
68
- set_primary_agent(topic, primary_agent)
69
- I18n.t("collavre.comments.topic_command.created_with_agent",
70
- name: topic.name,
71
- agent: primary_agent.name)
56
+ if existing_topic
57
+ if primary_agent
58
+ set_primary_agent(existing_topic, primary_agent)
59
+ I18n.t("collavre.comments.topic_command.updated_agent",
60
+ name: existing_topic.name,
61
+ agent: primary_agent.name)
62
+ else
63
+ I18n.t("collavre.comments.topic_command.already_exists",
64
+ name: existing_topic.name)
65
+ end
72
66
  else
73
- if data[:agent_name].present?
74
- I18n.t("collavre.comments.topic_command.created_agent_not_found",
67
+ topic = Topic.create!(
68
+ creative: creative,
69
+ user: user,
70
+ name: data[:name]
71
+ )
72
+
73
+ if primary_agent
74
+ set_primary_agent(topic, primary_agent)
75
+ I18n.t("collavre.comments.topic_command.created_with_agent",
75
76
  name: topic.name,
76
- agent_name: data[:agent_name])
77
+ agent: primary_agent.name)
77
78
  else
78
79
  I18n.t("collavre.comments.topic_command.created", name: topic.name)
79
80
  end
80
81
  end
81
82
  end
82
83
 
83
- def find_agent(name)
84
- # Find AI agent by name (case-insensitive)
85
- user_class = Collavre.configuration.user_class_name.constantize
86
- user_class.where.not(llm_vendor: [ nil, "" ])
87
- .where("LOWER(name) = ?", name.downcase)
88
- .first
89
- end
90
-
91
84
  def set_primary_agent(topic, agent)
92
- OrchestratorPolicy.create!(
85
+ policy = OrchestratorPolicy.find_or_initialize_by(
93
86
  policy_type: "arbitration",
94
87
  scope_type: "Topic",
95
- scope_id: topic.id,
88
+ scope_id: topic.id
89
+ )
90
+ policy.update!(
96
91
  config: {
97
92
  "strategy" => "primary_first",
98
93
  "primary_agent_id" => agent.id
@@ -24,23 +24,37 @@ module Collavre
24
24
  if updated > 0
25
25
  task.reload
26
26
  refresh_deferred_context!(task)
27
- AiAgentJob.perform_later(task)
27
+
28
+ if task.status == "cancelled"
29
+ # refresh_deferred_context! cancelled this task (no eligible comment),
30
+ # try the next queued task for this topic.
31
+ dequeue_next_for_topic(topic_id)
32
+ else
33
+ AiAgentJob.perform_later(task)
34
+ end
28
35
  end
29
36
  end
30
37
 
31
38
  # Refresh trigger_event_payload so the deferred agent sees the latest
32
39
  # conversation state instead of the stale snapshot from enqueue time.
40
+ # Skips AI agent's own comments to prevent self-response loops.
41
+ # Cancels the task if no eligible comment remains.
33
42
  def self.refresh_deferred_context!(task)
34
43
  context = task.trigger_event_payload
35
44
  creative_id = context&.dig("creative", "id")
36
45
  return unless creative_id && context&.key?("topic")
37
46
 
38
47
  topic_id = context.dig("topic", "id")
39
- latest_comment = Comment
48
+ scope = Comment
40
49
  .where(creative_id: creative_id, topic_id: topic_id, private: false)
50
+ .where.not(user_id: task.agent_id)
41
51
  .order(created_at: :desc)
42
- .first
43
- return unless latest_comment
52
+ latest_comment = scope.first
53
+
54
+ unless latest_comment
55
+ task.update!(status: "cancelled")
56
+ return
57
+ end
44
58
 
45
59
  context["comment"] = {
46
60
  "id" => latest_comment.id,
@@ -43,7 +43,7 @@ module Collavre
43
43
  "token_spike_window_minutes" => 10
44
44
  },
45
45
  "stuck_detection" => {
46
- "enabled" => false,
46
+ "enabled" => true,
47
47
  "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
48
48
  "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
49
49
  "create_system_comment" => true # Create system comment on escalation
@@ -24,7 +24,7 @@ module Collavre
24
24
  @policy_resolver = policy_resolver || PolicyResolver.new({})
25
25
  end
26
26
 
27
- # Run detection and escalation
27
+ # Run detection, auto-recovery, and escalation
28
28
  # Returns Result with stuck items and escalation count
29
29
  def detect_and_escalate
30
30
  config = stuck_detection_config
@@ -34,6 +34,7 @@ module Collavre
34
34
  stuck_items.concat(detect_stuck_tasks(config))
35
35
  stuck_items.concat(detect_stalled_creatives(config))
36
36
 
37
+ auto_recover_stuck_tasks(stuck_items)
37
38
  escalated_count = escalate_stuck_items(stuck_items, config)
38
39
 
39
40
  Result.new(stuck_items: stuck_items, escalated_count: escalated_count)
@@ -52,6 +53,33 @@ module Collavre
52
53
 
53
54
  private
54
55
 
56
+ # Auto-recover stuck tasks by marking them as failed and draining the queue.
57
+ def auto_recover_stuck_tasks(stuck_items)
58
+ stuck_items.each do |stuck_item|
59
+ next unless stuck_item.type == :task
60
+
61
+ task = stuck_item.item
62
+ next unless task.status == "running"
63
+
64
+ task.update!(status: "failed")
65
+ Rails.logger.info(
66
+ "[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
67
+ "marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
68
+ )
69
+
70
+ # Release resources held by the stuck task
71
+ if task.agent
72
+ tracker = ResourceTracker.for(task.agent)
73
+ tracker.release!(task.id)
74
+ end
75
+
76
+ # Drain the queue for the topic so waiting tasks can execute
77
+ AgentOrchestrator.dequeue_next_for_topic(task.topic_id)
78
+ rescue StandardError => e
79
+ Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{task.id}: #{e.message}")
80
+ end
81
+ end
82
+
55
83
  def stuck_detection_config
56
84
  @policy_resolver.resolve("stuck_detection")
57
85
  end
@@ -52,16 +52,11 @@ module Collavre
52
52
  end
53
53
 
54
54
  def mentioned_user(chat_context)
55
- # This mimics the chat.mentioned_user function requested
56
- # It assumes chat_context has 'content' or similar, or we might need to look up the comment
57
- # For now, let's assume the context passed in already has the necessary info or we extract it.
58
- # If the event is comment_created, the payload usually has the comment content.
59
-
60
55
  content = chat_context["content"]
61
56
  return nil unless content
62
57
 
63
- # Simple regex to find the first mention
64
- match = content.match(/\A@([^:]+?):\s*/) || content.match(/\A@(\S+)\s+/)
58
+ # Canonical mention format: @name: (with colon)
59
+ match = content.match(/\A@([^:]+?):\s*/)
65
60
  return nil unless match
66
61
 
67
62
  name = match[1].strip
@@ -1,5 +1,5 @@
1
1
  <div class="tab-list">
2
2
  <%= link_to t('admin.tabs.system'), main_app.admin_path, class: "tab-button #{'active' if controller_name == 'settings'}" %>
3
3
  <%= link_to t('admin.tabs.users'), collavre.users_path, class: "tab-button #{'active' if controller_name == 'users'}" %>
4
- <%= link_to t('admin.tabs.orchestration'), main_app.admin_orchestration_path, class: "tab-button #{'active' if controller_name == 'orchestration'}" %>
4
+ <%= link_to t('admin.tabs.orchestration'), collavre.admin_orchestration_path, class: "tab-button #{'active' if controller_name == 'orchestration'}" %>
5
5
  </div>
@@ -14,7 +14,7 @@
14
14
  </p>
15
15
  </div>
16
16
 
17
- <%= form_with url: main_app.admin_orchestration_path, method: :patch, local: true, html: { class: 'profile-form' } do |f| %>
17
+ <%= form_with url: admin_orchestration_path, method: :patch, local: true, html: { class: 'profile-form' } do |f| %>
18
18
  <div>
19
19
  <label for="policies_yaml" style="font-weight: 600; display: block; margin-bottom: 0.5em;">
20
20
  <%= t('admin.orchestration.policies_yaml') %>
@@ -30,7 +30,7 @@ en:
30
30
  reviewer_header: "### Reviewers (request review)"
31
31
  reference_header: "### References (information only)"
32
32
  rules_header: "## Collaboration Rules"
33
- mention_rule: "- Call other agents: @name request"
33
+ mention_rule: "- Call other agents: @name: request"
34
34
  confidence_rule: "- Re-evaluate before responding if uncertain"
35
35
  escalation_rule: "- Ask escalation targets for help when stuck"
36
36
  review_rule: "- Request review from reviewers when code review is needed"
@@ -30,7 +30,7 @@ ko:
30
30
  reviewer_header: "### 리뷰어 (검토 요청)"
31
31
  reference_header: "### 참조 (정보 요청만)"
32
32
  rules_header: "## 협업 규칙"
33
- mention_rule: "- 다른 Agent 호출: @이름 요청내용"
33
+ mention_rule: "- 다른 Agent 호출: @이름: 요청내용"
34
34
  confidence_rule: "- 확신이 낮으면 재검토 후 발화"
35
35
  escalation_rule: "- 막히면 에스컬레이션 대상에게 도움 요청"
36
36
  review_rule: "- 코드 리뷰가 필요하면 리뷰어에게 요청"
@@ -91,7 +91,8 @@ en:
91
91
  missing_name: 'Please specify a topic name in quotes: /topic "topic name"'
92
92
  created: 'Topic "%{name}" created.'
93
93
  created_with_agent: 'Topic "%{name}" created with @%{agent} as primary agent.'
94
- created_agent_not_found: 'Topic "%{name}" created. Note: Agent @%{agent_name} not found.'
94
+ updated_agent: 'Topic "%{name}" primary agent updated to @%{agent}.'
95
+ already_exists: 'Topic "%{name}" already exists.'
95
96
  read_by: Read by %{name}
96
97
  activity_logs_summary: Activity Logs
97
98
  calendar_events:
@@ -88,7 +88,8 @@ ko:
88
88
  missing_name: '토픽 이름을 따옴표로 지정하세요: /topic "토픽 이름"'
89
89
  created: '토픽 "%{name}"이(가) 생성되었습니다.'
90
90
  created_with_agent: '토픽 "%{name}"이(가) 생성되었습니다. @%{agent}이(가) Primary Agent로 설정되었습니다.'
91
- created_agent_not_found: '토픽 "%{name}"이(가) 생성되었습니다. 참고: @%{agent_name} 에이전트를 찾을 수 없습니다.'
91
+ updated_agent: '토픽 "%{name}" Primary Agent가 @%{agent}(으)로 변경되었습니다.'
92
+ already_exists: '토픽 "%{name}"이(가) 이미 존재합니다.'
92
93
  read_by: "%{name} 님이 읽음"
93
94
  activity_logs_summary: 활동 기록
94
95
  calendar_events:
data/config/routes.rb CHANGED
@@ -93,4 +93,9 @@ Collavre::Engine.routes.draw do
93
93
 
94
94
  post "/creative_expanded_states/toggle", to: "creative_expanded_states#toggle"
95
95
  post "/comment_read_pointers/update", to: "comment_read_pointers#update"
96
+
97
+ # Admin orchestration
98
+ scope "/admin", as: :admin do
99
+ resource :orchestration, only: [ :show, :update ], controller: "admin/orchestration"
100
+ end
96
101
  end
@@ -1,3 +1,3 @@
1
1
  module Collavre
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre