collavre 0.2.5 → 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 +4 -4
- data/app/channels/collavre/comments_presence_channel.rb +6 -3
- data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
- data/app/controllers/collavre/comments_controller.rb +10 -1
- data/app/javascript/controllers/comments/presence_controller.js +5 -1
- data/app/jobs/collavre/ai_agent_job.rb +55 -4
- data/app/jobs/collavre/cron_action_job.rb +53 -0
- data/app/jobs/collavre/stuck_detector_job.rb +34 -0
- data/app/models/collavre/comment.rb +15 -15
- data/app/models/collavre/creative.rb +1 -1
- data/app/models/collavre/orchestrator_policy.rb +124 -0
- data/app/models/collavre/task.rb +3 -0
- data/app/models/collavre/user.rb +1 -1
- data/app/services/collavre/ai_agent_service.rb +32 -1
- data/app/services/collavre/ai_client.rb +4 -3
- data/app/services/collavre/comments/command_processor.rb +4 -1
- data/app/services/collavre/comments/topic_command.rb +101 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
- data/app/services/collavre/orchestration/arbiter.rb +257 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
- data/app/services/collavre/orchestration/matcher.rb +98 -0
- data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
- data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
- data/app/services/collavre/orchestration/scheduler.rb +145 -0
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
- data/app/services/collavre/system_events/context_builder.rb +34 -7
- data/app/services/collavre/system_events/dispatcher.rb +2 -7
- data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
- data/app/services/collavre/tools/cron_create_service.rb +73 -0
- data/app/services/collavre/tools/cron_list_service.rb +65 -0
- data/app/services/collavre/tools/cron_update_service.rb +82 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
- data/app/views/collavre/creatives/index.html.erb +1 -1
- data/config/locales/ai_agent.en.yml +21 -0
- data/config/locales/ai_agent.ko.yml +21 -0
- data/config/locales/comments.en.yml +8 -0
- data/config/locales/comments.ko.yml +8 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
- data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
- data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
- data/lib/collavre/version.rb +1 -1
- metadata +24 -13
- data/app/controllers/collavre/github_auth_controller.rb +0 -25
- data/app/models/collavre/github_account.rb +0 -10
- data/app/models/collavre/github_repository_link.rb +0 -19
- data/app/services/collavre/github/client.rb +0 -112
- data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
- data/app/services/collavre/github/pull_request_processor.rb +0 -181
- data/app/services/collavre/github/webhook_provisioner.rb +0 -130
- data/app/services/collavre/system_events/router.rb +0 -96
- data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
- data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
- data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e8aa662b4715665ebaa090bc8975037ca96c4059d53acfa379ccc88217487f8
|
|
4
|
+
data.tar.gz: 2d61c18b32e5228bbe84cd9cf8fb73898eedab099ce11959badf95194fb0b431
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b0baedb45c15a38689e9db2d68bec4fb1ec8cecc7fedfc4a213236bba81e5c65af9eaf54db14b9916811060216856aefaa327ecaa650a8a1ae7ed6a212b74bf
|
|
7
|
+
data.tar.gz: c667c0457625a3c8d0bd7fcf0dd55f9388e6f01ddf23bc3c6f3741bef5abadf3be10031bec72df99851d4aea24815fbb8d7f9caa00d94378f02c0f3790da0cf9
|
|
@@ -12,20 +12,23 @@ class CommentsPresenceChannel < ApplicationCable::Channel
|
|
|
12
12
|
status: "thinking",
|
|
13
13
|
agent_id: task.agent_id,
|
|
14
14
|
agent_name: task.agent.display_name,
|
|
15
|
-
task_id: task.id
|
|
15
|
+
task_id: task.id,
|
|
16
|
+
source_creative_id: task_creative_id
|
|
16
17
|
)
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
# Broadcast agent status (thinking/streaming/idle) to presence channel.
|
|
21
22
|
# This allows the frontend typing indicator to show AI agent activity.
|
|
22
|
-
|
|
23
|
+
# source_creative_id: the actual creative where agent is working (for filtering on frontend)
|
|
24
|
+
def self.broadcast_agent_status(creative_id, status:, agent_id:, agent_name:, task_id: nil, content: nil, source_creative_id: nil)
|
|
23
25
|
payload = {
|
|
24
26
|
agent_status: {
|
|
25
27
|
id: agent_id,
|
|
26
28
|
name: agent_name,
|
|
27
29
|
status: status,
|
|
28
|
-
task_id: task_id
|
|
30
|
+
task_id: task_id,
|
|
31
|
+
creative_id: source_creative_id || creative_id
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
payload[:agent_status][:content] = content if content.present?
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Admin
|
|
5
|
+
class OrchestrationController < ApplicationController
|
|
6
|
+
before_action :require_system_admin!
|
|
7
|
+
|
|
8
|
+
def show
|
|
9
|
+
@policies_yaml = policies_to_yaml
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update
|
|
13
|
+
yaml_content = params[:policies_yaml].to_s
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
parsed = YAML.safe_load(yaml_content, permitted_classes: [ Symbol ])
|
|
17
|
+
validate_policies!(parsed)
|
|
18
|
+
apply_policies!(parsed)
|
|
19
|
+
|
|
20
|
+
redirect_to admin_orchestration_path, notice: t("admin.orchestration.updated")
|
|
21
|
+
rescue Psych::SyntaxError => e
|
|
22
|
+
flash.now[:alert] = t("admin.orchestration.yaml_syntax_error", message: e.message)
|
|
23
|
+
@policies_yaml = yaml_content
|
|
24
|
+
render :show, status: :unprocessable_entity
|
|
25
|
+
rescue PolicyValidationError => e
|
|
26
|
+
flash.now[:alert] = e.message
|
|
27
|
+
@policies_yaml = yaml_content
|
|
28
|
+
render :show, status: :unprocessable_entity
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
class PolicyValidationError < StandardError; end
|
|
35
|
+
|
|
36
|
+
def policies_to_yaml
|
|
37
|
+
policies = OrchestratorPolicy.enabled.order(:policy_type, :scope_type, :priority)
|
|
38
|
+
|
|
39
|
+
# Group by type for readable YAML structure
|
|
40
|
+
structure = {
|
|
41
|
+
"arbitration" => { "global" => nil, "overrides" => [] },
|
|
42
|
+
"scheduling" => { "global" => nil, "overrides" => [] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
policies.each do |policy|
|
|
46
|
+
type = policy.policy_type
|
|
47
|
+
next unless structure.key?(type)
|
|
48
|
+
|
|
49
|
+
if policy.global?
|
|
50
|
+
structure[type]["global"] = policy.config
|
|
51
|
+
else
|
|
52
|
+
structure[type]["overrides"] << {
|
|
53
|
+
"scope_type" => policy.scope_type,
|
|
54
|
+
"scope_id" => policy.scope_id,
|
|
55
|
+
"config" => policy.config,
|
|
56
|
+
"priority" => policy.priority
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Remove empty sections
|
|
62
|
+
structure.each do |type, data|
|
|
63
|
+
data.delete("global") if data["global"].nil?
|
|
64
|
+
data.delete("overrides") if data["overrides"].empty?
|
|
65
|
+
end
|
|
66
|
+
structure.delete_if { |_, v| v.empty? }
|
|
67
|
+
|
|
68
|
+
# Add defaults if empty
|
|
69
|
+
if structure.empty?
|
|
70
|
+
structure = default_policies_structure
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
structure.to_yaml
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def default_policies_structure
|
|
77
|
+
{
|
|
78
|
+
"arbitration" => {
|
|
79
|
+
"global" => {
|
|
80
|
+
"strategy" => "all",
|
|
81
|
+
"max_responders" => nil
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"scheduling" => {
|
|
85
|
+
"global" => {
|
|
86
|
+
"max_concurrent_jobs" => 5,
|
|
87
|
+
"daily_token_limit" => 100_000,
|
|
88
|
+
"rate_limit_per_minute" => 20,
|
|
89
|
+
"backoff_strategy" => "exponential",
|
|
90
|
+
"topic_max_concurrent_jobs" => 1
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_policies!(parsed)
|
|
97
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_format") unless parsed.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
parsed.each do |type, data|
|
|
100
|
+
unless %w[arbitration scheduling].include?(type)
|
|
101
|
+
raise PolicyValidationError, t("admin.orchestration.unknown_policy_type", type: type)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
unless data.is_a?(Hash)
|
|
105
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_policy_structure", type: type)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if data["global"].present? && !data["global"].is_a?(Hash)
|
|
109
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_global_config", type: type)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if data["overrides"].present?
|
|
113
|
+
unless data["overrides"].is_a?(Array)
|
|
114
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_overrides", type: type)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
data["overrides"].each_with_index do |override, idx|
|
|
118
|
+
validate_override!(type, override, idx)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_override!(type, override, idx)
|
|
125
|
+
unless override.is_a?(Hash)
|
|
126
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_override_format", type: type, index: idx)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
unless %w[Creative Topic User].include?(override["scope_type"])
|
|
130
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_scope_type",
|
|
131
|
+
type: type, index: idx, scope_type: override["scope_type"])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
unless override["scope_id"].is_a?(Integer) && override["scope_id"].positive?
|
|
135
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_scope_id", type: type, index: idx)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
unless override["config"].is_a?(Hash)
|
|
139
|
+
raise PolicyValidationError, t("admin.orchestration.invalid_override_config", type: type, index: idx)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_policies!(parsed)
|
|
144
|
+
OrchestratorPolicy.transaction do
|
|
145
|
+
# Clear existing policies
|
|
146
|
+
OrchestratorPolicy.delete_all
|
|
147
|
+
|
|
148
|
+
parsed.each do |type, data|
|
|
149
|
+
# Create global policy
|
|
150
|
+
if data["global"].present?
|
|
151
|
+
OrchestratorPolicy.create!(
|
|
152
|
+
policy_type: type,
|
|
153
|
+
scope_type: nil,
|
|
154
|
+
scope_id: nil,
|
|
155
|
+
config: data["global"],
|
|
156
|
+
priority: 100,
|
|
157
|
+
enabled: true
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Create override policies
|
|
162
|
+
data["overrides"]&.each_with_index do |override, idx|
|
|
163
|
+
OrchestratorPolicy.create!(
|
|
164
|
+
policy_type: type,
|
|
165
|
+
scope_type: override["scope_type"],
|
|
166
|
+
scope_id: override["scope_id"],
|
|
167
|
+
config: override["config"],
|
|
168
|
+
priority: override["priority"] || (50 - idx),
|
|
169
|
+
enabled: true
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -173,10 +173,13 @@ module Collavre
|
|
|
173
173
|
id: @creative.id,
|
|
174
174
|
description: @creative.description
|
|
175
175
|
},
|
|
176
|
+
topic: {
|
|
177
|
+
id: @comment.topic_id
|
|
178
|
+
},
|
|
176
179
|
chat: {
|
|
177
180
|
content: @comment.content
|
|
178
181
|
}
|
|
179
|
-
}) unless @comment.private?
|
|
182
|
+
}) unless @comment.private? || response.present?
|
|
180
183
|
@comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
|
|
181
184
|
render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }, status: :created
|
|
182
185
|
else
|
|
@@ -488,6 +491,12 @@ module Collavre
|
|
|
488
491
|
aliases: [ "/cal" ],
|
|
489
492
|
description: I18n.t("collavre.comments.command_menu.calendar_description"),
|
|
490
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")
|
|
491
500
|
}
|
|
492
501
|
] + mcp_command_items
|
|
493
502
|
end
|
|
@@ -151,7 +151,11 @@ export default class extends Controller {
|
|
|
151
151
|
this.renderTypingIndicator()
|
|
152
152
|
}
|
|
153
153
|
if (data.agent_status) {
|
|
154
|
-
const { id, name, status } = data.agent_status
|
|
154
|
+
const { id, name, status, creative_id: agentCreativeId } = data.agent_status
|
|
155
|
+
// Only show typing indicator if agent is working on this specific creative
|
|
156
|
+
if (agentCreativeId && agentCreativeId !== this.creativeId) {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
155
159
|
if (status === 'thinking' || status === 'streaming') {
|
|
156
160
|
this.typingUsers[id] = name
|
|
157
161
|
} else {
|
|
@@ -8,7 +8,9 @@ module Collavre
|
|
|
8
8
|
# Resume existing task
|
|
9
9
|
task = agent_id_or_task
|
|
10
10
|
return if task.reload.status == "cancelled"
|
|
11
|
+
|
|
11
12
|
task.update!(status: "running")
|
|
13
|
+
agent = task.agent
|
|
12
14
|
else
|
|
13
15
|
# Create new task
|
|
14
16
|
agent = User.find(agent_id_or_task)
|
|
@@ -17,25 +19,74 @@ module Collavre
|
|
|
17
19
|
status: "running",
|
|
18
20
|
trigger_event_name: event_name,
|
|
19
21
|
trigger_event_payload: context,
|
|
20
|
-
agent: agent
|
|
22
|
+
agent: agent,
|
|
23
|
+
topic_id: context&.dig("topic", "id")
|
|
21
24
|
)
|
|
25
|
+
|
|
26
|
+
# Record task for loop breaker tracking
|
|
27
|
+
creative_id = context&.dig("creative", "id")
|
|
28
|
+
if creative_id
|
|
29
|
+
Orchestration::LoopBreaker.new(context).record_task(creative_id, agent.id)
|
|
30
|
+
end
|
|
22
31
|
end
|
|
23
32
|
|
|
33
|
+
# Reserve resources before starting work
|
|
34
|
+
tracker = Orchestration::ResourceTracker.for(agent)
|
|
35
|
+
tracker.reserve!(job_id || task.id)
|
|
36
|
+
|
|
24
37
|
begin
|
|
25
|
-
AiAgentService.new(task).call
|
|
26
|
-
|
|
38
|
+
response_content = AiAgentService.new(task).call
|
|
39
|
+
|
|
40
|
+
# Evaluate self-reflection if enabled
|
|
41
|
+
reflection_result = evaluate_self_reflection(task, response_content)
|
|
42
|
+
|
|
43
|
+
case reflection_result.action
|
|
44
|
+
when :retry
|
|
45
|
+
# Schedule retry with delay - don't release resources yet
|
|
46
|
+
schedule_self_reflection_retry(task, reflection_result, response_content)
|
|
47
|
+
Rails.logger.info(
|
|
48
|
+
"[AiAgentJob] Task #{task.id} scheduled for retry " \
|
|
49
|
+
"(attempt #{task.retry_count + 1}, confidence: #{reflection_result.confidence})"
|
|
50
|
+
)
|
|
51
|
+
nil # Exit without releasing resources or dequeuing
|
|
52
|
+
when :escalate
|
|
53
|
+
# Escalate to admins
|
|
54
|
+
Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content).escalate!(reflection_result)
|
|
55
|
+
tracker.release!(job_id || task.id, tokens_used: 0)
|
|
56
|
+
Rails.logger.info("[AiAgentJob] Task #{task.id} escalated after max retries")
|
|
57
|
+
else # :done
|
|
58
|
+
task.update!(status: "done")
|
|
59
|
+
tracker.release!(job_id || task.id, tokens_used: 0)
|
|
60
|
+
end
|
|
27
61
|
rescue ApprovalPendingError
|
|
28
62
|
# Task status already set to pending_approval by AiAgentService
|
|
29
|
-
# Don't
|
|
63
|
+
# Don't release resources yet - task will resume
|
|
30
64
|
Rails.logger.info("AiAgentJob paused for task #{task.id}: awaiting tool approval")
|
|
31
65
|
rescue CancelledError
|
|
32
66
|
# Task status already set to "cancelled" by Comment callback
|
|
67
|
+
tracker.release!(job_id || task.id, tokens_used: 0)
|
|
33
68
|
Rails.logger.info("AiAgentJob cancelled for task #{task.id}: trigger message deleted")
|
|
34
69
|
rescue StandardError => e
|
|
35
70
|
task.update!(status: "failed")
|
|
71
|
+
tracker.release!(job_id || task.id, tokens_used: 0)
|
|
36
72
|
Rails.logger.error("AiAgentJob failed for task #{task.id}: #{e.message}")
|
|
37
73
|
raise e
|
|
74
|
+
ensure
|
|
75
|
+
if task&.trigger_event_payload&.key?("topic") && %w[done failed cancelled escalated].include?(task.reload.status)
|
|
76
|
+
Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id)
|
|
77
|
+
end
|
|
38
78
|
end
|
|
39
79
|
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def evaluate_self_reflection(task, response_content)
|
|
84
|
+
Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content).evaluate
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def schedule_self_reflection_retry(task, result, response_content)
|
|
88
|
+
evaluator = Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content)
|
|
89
|
+
evaluator.schedule_retry!(result)
|
|
90
|
+
end
|
|
40
91
|
end
|
|
41
92
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# CronActionJob is executed by SolidQueue recurring tasks to post
|
|
5
|
+
# a scheduled message into a creative's topic, triggering the
|
|
6
|
+
# agent orchestration pipeline.
|
|
7
|
+
#
|
|
8
|
+
# Created dynamically via the cron_create MCP tool.
|
|
9
|
+
class CronActionJob < ApplicationJob
|
|
10
|
+
queue_as :default
|
|
11
|
+
|
|
12
|
+
def perform(creative_id:, topic_id:, agent_id:, message:)
|
|
13
|
+
creative = Creative.find_by(id: creative_id)
|
|
14
|
+
topic = Topic.find_by(id: topic_id)
|
|
15
|
+
agent = User.find_by(id: agent_id)
|
|
16
|
+
|
|
17
|
+
unless creative && topic && agent
|
|
18
|
+
Rails.logger.warn(
|
|
19
|
+
"[CronActionJob] Skipping: creative=#{creative_id} topic=#{topic_id} agent=#{agent_id} - record not found"
|
|
20
|
+
)
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
comment = creative.comments.create!(
|
|
25
|
+
content: message,
|
|
26
|
+
user: agent,
|
|
27
|
+
topic_id: topic.id,
|
|
28
|
+
private: false
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Dispatch system event to trigger agent orchestration pipeline
|
|
32
|
+
SystemEvents::Dispatcher.dispatch("comment_created", {
|
|
33
|
+
comment: {
|
|
34
|
+
id: comment.id,
|
|
35
|
+
content: comment.content,
|
|
36
|
+
user_id: comment.user_id
|
|
37
|
+
},
|
|
38
|
+
creative: {
|
|
39
|
+
id: creative.id,
|
|
40
|
+
description: creative.description
|
|
41
|
+
},
|
|
42
|
+
topic: {
|
|
43
|
+
id: topic.id
|
|
44
|
+
},
|
|
45
|
+
chat: {
|
|
46
|
+
content: comment.content
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
Rails.logger.info("[CronActionJob] Posted cron message to creative #{creative_id}, topic #{topic_id}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# StuckDetectorJob runs periodically to detect stuck tasks and creatives.
|
|
5
|
+
#
|
|
6
|
+
# This job should be scheduled to run every 5-10 minutes via cron or similar.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# StuckDetectorJob.perform_later
|
|
10
|
+
#
|
|
11
|
+
# To schedule with SolidQueue:
|
|
12
|
+
# # config/recurring.yml
|
|
13
|
+
# stuck_detector:
|
|
14
|
+
# class: Collavre::StuckDetectorJob
|
|
15
|
+
# schedule: every 10 minutes
|
|
16
|
+
#
|
|
17
|
+
class StuckDetectorJob < ApplicationJob
|
|
18
|
+
queue_as :default
|
|
19
|
+
|
|
20
|
+
def perform
|
|
21
|
+
detector = Orchestration::StuckDetector.new
|
|
22
|
+
result = detector.detect_and_escalate
|
|
23
|
+
|
|
24
|
+
if result.escalated_count > 0
|
|
25
|
+
Rails.logger.info(
|
|
26
|
+
"[StuckDetectorJob] Detected #{result.stuck_items.count} stuck items, " \
|
|
27
|
+
"escalated #{result.escalated_count}"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
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")
|
|
@@ -59,7 +59,7 @@ module Collavre
|
|
|
59
59
|
has_many :tags, class_name: "Collavre::Tag", dependent: :destroy
|
|
60
60
|
has_many :creative_expanded_states, class_name: "Collavre::CreativeExpandedState", dependent: :delete_all
|
|
61
61
|
has_many :invitations, class_name: "Collavre::Invitation", dependent: :delete_all
|
|
62
|
-
|
|
62
|
+
# github_repository_links association added by CollavreGithub engine
|
|
63
63
|
has_many :topics, class_name: "Collavre::Topic", dependent: :destroy
|
|
64
64
|
has_many :mcp_tools, dependent: :destroy
|
|
65
65
|
has_many :activity_logs, class_name: "Collavre::ActivityLog", dependent: :destroy
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# OrchestratorPolicy stores configuration for agent orchestration behavior.
|
|
5
|
+
#
|
|
6
|
+
# Table: orchestrator_policies
|
|
7
|
+
#
|
|
8
|
+
# Scope types:
|
|
9
|
+
# - nil (global): Default policy applied everywhere
|
|
10
|
+
# - "Creative": Policy for a specific creative/workspace
|
|
11
|
+
# - "Topic": Policy for a specific topic/conversation
|
|
12
|
+
# - "User": Policy for a specific agent
|
|
13
|
+
#
|
|
14
|
+
# Policy types:
|
|
15
|
+
# - matching: How agents are matched to messages
|
|
16
|
+
# - arbitration: How to select responders from candidates (floor control)
|
|
17
|
+
# - scheduling: When/how to execute agent jobs (resource management)
|
|
18
|
+
#
|
|
19
|
+
# Example configs:
|
|
20
|
+
#
|
|
21
|
+
# Arbitration policy (primary_first):
|
|
22
|
+
# {
|
|
23
|
+
# "strategy": "primary_first",
|
|
24
|
+
# "max_responders": 1,
|
|
25
|
+
# "primary_agent_id": 123
|
|
26
|
+
# }
|
|
27
|
+
#
|
|
28
|
+
# Arbitration policy (round_robin):
|
|
29
|
+
# {
|
|
30
|
+
# "strategy": "round_robin",
|
|
31
|
+
# "max_responders": 1
|
|
32
|
+
# }
|
|
33
|
+
#
|
|
34
|
+
class OrchestratorPolicy < ApplicationRecord
|
|
35
|
+
self.table_name = "orchestrator_policies"
|
|
36
|
+
|
|
37
|
+
# Scope types
|
|
38
|
+
SCOPE_TYPES = %w[Creative Topic User].freeze
|
|
39
|
+
|
|
40
|
+
# Policy types
|
|
41
|
+
POLICY_TYPES = %w[matching arbitration scheduling stuck_detection].freeze
|
|
42
|
+
|
|
43
|
+
# Arbitration strategies
|
|
44
|
+
ARBITRATION_STRATEGIES = %w[all primary_first round_robin bid].freeze
|
|
45
|
+
|
|
46
|
+
# Validations
|
|
47
|
+
validates :policy_type, presence: true, inclusion: { in: POLICY_TYPES }
|
|
48
|
+
validates :scope_type, inclusion: { in: SCOPE_TYPES }, allow_nil: true
|
|
49
|
+
validates :priority, presence: true, numericality: { only_integer: true }
|
|
50
|
+
|
|
51
|
+
# Polymorphic scope
|
|
52
|
+
belongs_to :scope, polymorphic: true, optional: true
|
|
53
|
+
|
|
54
|
+
# Scopes
|
|
55
|
+
scope :enabled, -> { where(enabled: true) }
|
|
56
|
+
scope :by_priority, -> { order(priority: :asc) }
|
|
57
|
+
scope :global, -> { where(scope_type: nil, scope_id: nil) }
|
|
58
|
+
scope :for_type, ->(type) { where(policy_type: type) }
|
|
59
|
+
|
|
60
|
+
# Class methods
|
|
61
|
+
class << self
|
|
62
|
+
# Find policies applicable to a given context
|
|
63
|
+
# Returns policies in priority order (global < creative < topic < agent)
|
|
64
|
+
def for_context(context, policy_type:)
|
|
65
|
+
policies = enabled.for_type(policy_type).by_priority
|
|
66
|
+
|
|
67
|
+
applicable = []
|
|
68
|
+
|
|
69
|
+
# Global policies
|
|
70
|
+
applicable.concat(policies.global.to_a)
|
|
71
|
+
|
|
72
|
+
# Creative-level policies
|
|
73
|
+
if context["creative"].present?
|
|
74
|
+
creative_id = context["creative"]["id"]
|
|
75
|
+
applicable.concat(
|
|
76
|
+
policies.where(scope_type: "Creative", scope_id: creative_id).to_a
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Topic-level policies
|
|
81
|
+
if context["topic"].present?
|
|
82
|
+
topic_id = context["topic"]["id"]
|
|
83
|
+
applicable.concat(
|
|
84
|
+
policies.where(scope_type: "Topic", scope_id: topic_id).to_a
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Agent-level policies are handled separately per-agent
|
|
89
|
+
|
|
90
|
+
# Note: We don't sort by priority globally here because scope hierarchy
|
|
91
|
+
# (global < creative < topic) takes precedence. Each scope level is
|
|
92
|
+
# already sorted by priority from the query.
|
|
93
|
+
applicable
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Find policies for a specific agent
|
|
97
|
+
def for_agent(agent_id, policy_type:)
|
|
98
|
+
enabled
|
|
99
|
+
.for_type(policy_type)
|
|
100
|
+
.where(scope_type: "User", scope_id: agent_id)
|
|
101
|
+
.by_priority
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Instance methods
|
|
106
|
+
|
|
107
|
+
# Get a config value with default
|
|
108
|
+
def config_value(key, default: nil)
|
|
109
|
+
config[key.to_s] || default
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check if this is a global policy
|
|
113
|
+
def global?
|
|
114
|
+
scope_type.nil? && scope_id.nil?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get the arbitration strategy
|
|
118
|
+
def arbitration_strategy
|
|
119
|
+
return nil unless policy_type == "arbitration"
|
|
120
|
+
|
|
121
|
+
config_value("strategy", default: "all")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/app/models/collavre/task.rb
CHANGED
|
@@ -6,5 +6,8 @@ module Collavre
|
|
|
6
6
|
has_many :task_actions, class_name: "Collavre::TaskAction", dependent: :destroy
|
|
7
7
|
|
|
8
8
|
validates :name, presence: true
|
|
9
|
+
|
|
10
|
+
scope :running_for_topic, ->(topic_id) { where(topic_id: topic_id, status: "running") }
|
|
11
|
+
scope :queued_for_topic, ->(topic_id) { where(topic_id: topic_id, status: "queued").order(:created_at) }
|
|
9
12
|
end
|
|
10
13
|
end
|
data/app/models/collavre/user.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Collavre
|
|
|
16
16
|
has_many :contacts, class_name: "Collavre::Contact", dependent: :destroy
|
|
17
17
|
has_many :contact_users, through: :contacts
|
|
18
18
|
has_many :contact_memberships, class_name: "Collavre::Contact", foreign_key: :contact_user_id, dependent: :destroy, inverse_of: :contact_user
|
|
19
|
-
|
|
19
|
+
# github_account association is added dynamically by collavre_github engine
|
|
20
20
|
has_many :tasks, class_name: "Collavre::Task", foreign_key: :agent_id, dependent: :destroy
|
|
21
21
|
|
|
22
22
|
# Associations that reference creatives - must be destroyed BEFORE creatives
|