collavre 0.2.5 → 0.3.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/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 +3 -0
- 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/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 +106 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +128 -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 +275 -0
- data/app/services/collavre/system_events/context_builder.rb +32 -0
- 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 +7 -0
- data/config/locales/comments.ko.yml +7 -0
- data/config/routes.rb +0 -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: bb2d48043704a517d8970498aa1057e5eaa29133304fe4fb82ebe37ac96b8458
|
|
4
|
+
data.tar.gz: d517609fd42be9255120f2faafe4acaf0fa36a2797c96021c5502acb6206af66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca97eb7ec6d858f67b1d3da6f7db767d15a7ba16a14b3dcb883c051a8bef55b2d679441ff8eb32cd9455528ef977f74430e535553cf9511d74024a23d7c8d0e7
|
|
7
|
+
data.tar.gz: a5cccbe54450cc225d25935ab85720302f61354785dfac02c1170933369c470767786a7c2fc683a93f907ffdc6ce5a34cb2abd1fec8b77aa6fbce9408c68873d
|
|
@@ -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 main_app.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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -32,11 +32,19 @@ module Collavre
|
|
|
32
32
|
rendering_context["creative"] = creative.as_json if creative
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Add agent collaboration context
|
|
36
|
+
agent_context = build_agent_context(creative)
|
|
37
|
+
rendering_context.merge!(agent_context)
|
|
38
|
+
|
|
35
39
|
rendered_system_prompt = AiSystemPromptRenderer.new(
|
|
36
40
|
template: @agent.system_prompt,
|
|
37
41
|
context: rendering_context
|
|
38
42
|
).render
|
|
39
43
|
|
|
44
|
+
# Append collaboration guide to system prompt
|
|
45
|
+
collaboration_prompt = build_collaboration_prompt(creative)
|
|
46
|
+
rendered_system_prompt = "#{rendered_system_prompt}\n\n#{collaboration_prompt}" if collaboration_prompt.present?
|
|
47
|
+
|
|
40
48
|
# Create a placeholder comment to stream into
|
|
41
49
|
target_comment_id = @context.dig("comment", "id")
|
|
42
50
|
@original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
|
|
@@ -109,6 +117,8 @@ module Collavre
|
|
|
109
117
|
|
|
110
118
|
# Broadcast "idle" status
|
|
111
119
|
broadcast_agent_status("idle")
|
|
120
|
+
|
|
121
|
+
@response_content
|
|
112
122
|
end
|
|
113
123
|
rescue ApprovalPendingError => e
|
|
114
124
|
handle_approval_pending(e)
|
|
@@ -154,7 +164,8 @@ module Collavre
|
|
|
154
164
|
agent_id: @agent.id,
|
|
155
165
|
agent_name: @agent.display_name,
|
|
156
166
|
task_id: @task.id,
|
|
157
|
-
content: content
|
|
167
|
+
content: content,
|
|
168
|
+
source_creative_id: @creative.id
|
|
158
169
|
)
|
|
159
170
|
end
|
|
160
171
|
|
|
@@ -236,6 +247,26 @@ module Collavre
|
|
|
236
247
|
.update_all(comment_id: to_comment.id)
|
|
237
248
|
end
|
|
238
249
|
|
|
250
|
+
def build_agent_context(creative)
|
|
251
|
+
return {} unless creative
|
|
252
|
+
|
|
253
|
+
Orchestration::AgentContextBuilder.new(
|
|
254
|
+
agent: @agent,
|
|
255
|
+
creative: creative,
|
|
256
|
+
sender: @context["sender"]
|
|
257
|
+
).build
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def build_collaboration_prompt(creative)
|
|
261
|
+
return nil unless creative
|
|
262
|
+
|
|
263
|
+
Orchestration::AgentContextBuilder.new(
|
|
264
|
+
agent: @agent,
|
|
265
|
+
creative: creative,
|
|
266
|
+
sender: @context["sender"]
|
|
267
|
+
).to_collaboration_prompt
|
|
268
|
+
end
|
|
269
|
+
|
|
239
270
|
def handle_approval_pending(error)
|
|
240
271
|
# Clean up placeholder comment if exists
|
|
241
272
|
@reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
|
|
@@ -83,10 +83,11 @@ module Collavre
|
|
|
83
83
|
rescue CancelledError
|
|
84
84
|
raise # Re-raise cancellation errors without catching them
|
|
85
85
|
rescue StandardError => e
|
|
86
|
-
error_message = e.message
|
|
87
|
-
Rails.logger.error "AI Client error: #{
|
|
86
|
+
error_message = "[#{e.class.name}] #{e.message}"
|
|
87
|
+
Rails.logger.error "AI Client error: #{error_message}"
|
|
88
|
+
Rails.logger.error "Partial response length: #{response_content.length} chars" if response_content.present?
|
|
88
89
|
Rails.logger.debug e.backtrace.join("\n")
|
|
89
|
-
yield "AI Error: #{
|
|
90
|
+
yield "\n\n⚠️ AI Error: #{error_message}" if block_given?
|
|
90
91
|
nil
|
|
91
92
|
ensure
|
|
92
93
|
log_interaction(
|
|
@@ -26,7 +26,10 @@ module Collavre
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def static_commands
|
|
29
|
-
[
|
|
29
|
+
[
|
|
30
|
+
Collavre::Comments::CalendarCommand.new(comment: comment, user: user),
|
|
31
|
+
Collavre::Comments::TopicCommand.new(comment: comment, user: user)
|
|
32
|
+
]
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
def mcp_commands
|