collavre_openclaw 0.6.2 → 0.6.3
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/jobs/collavre_openclaw/callback_processor_job.rb +22 -0
- data/app/models/collavre_openclaw/processed_ai_run.rb +72 -0
- data/app/services/collavre_openclaw/openclaw_adapter.rb +16 -1
- data/app/services/collavre_openclaw/proactive_message_handler.rb +5 -3
- data/app/services/collavre_openclaw/session_abort_service.rb +45 -0
- data/app/services/collavre_openclaw/websocket_client.rb +12 -1
- data/config/initializers/session_abort.rb +5 -0
- data/db/migrate/20260609040000_create_openclaw_processed_ai_runs.rb +15 -0
- data/lib/collavre_openclaw/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a5a10c4a59247ea763b3f635005c08375186c6bb279dfa9e00ea5e5533ae438
|
|
4
|
+
data.tar.gz: '097534bec297da15507b18e1f060990471fc54e9076ba5f81065a36ecb88b262'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 02f5238a8bae2f7061113189918774ebef66a855e26383127f42c3838658124721d465449751fdf0629d5ec52ffc399a5325d91a661a838bf25deb39cb0f1563
|
|
7
|
+
data.tar.gz: b9089349ca80e5f4f575da36f38f590f4a6607e9cd14dba8dece7c570a3d7f100d6abec58974647da1d4c33b4f00ad47ac31c9c5ec6c75761e9d7e1e587ff36c
|
|
@@ -60,6 +60,10 @@ module CollavreOpenclaw
|
|
|
60
60
|
content = payload[:content] || payload[:message]
|
|
61
61
|
thread_id = payload[:thread_id] || payload[:topic_id] || payload.dig(:context, :thread_id) || payload.dig(:context, :topic_id)
|
|
62
62
|
parent_comment_id = payload[:parent_comment_id] || payload.dig(:context, :parent_comment_id)
|
|
63
|
+
# Accept both casings: the HTTP CallbacksController path forwards the raw
|
|
64
|
+
# Gateway payload, which uses camelCase `runId`.
|
|
65
|
+
run_id = payload[:run_id] || payload[:runId] ||
|
|
66
|
+
payload.dig(:context, :run_id) || payload.dig(:context, :runId)
|
|
63
67
|
|
|
64
68
|
unless creative_id.present?
|
|
65
69
|
Rails.logger.error("[CollavreOpenclaw] Proactive message missing creative_id")
|
|
@@ -74,6 +78,7 @@ module CollavreOpenclaw
|
|
|
74
78
|
context = {
|
|
75
79
|
thread_id: thread_id,
|
|
76
80
|
parent_comment_id: parent_comment_id,
|
|
81
|
+
openclaw_run_id: run_id,
|
|
77
82
|
proactive: true
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -105,6 +110,14 @@ module CollavreOpenclaw
|
|
|
105
110
|
end
|
|
106
111
|
|
|
107
112
|
effective_creative = creative.effective_origin
|
|
113
|
+
run_id = context[:openclaw_run_id].presence
|
|
114
|
+
|
|
115
|
+
# The Gateway broadcasts a run to every process, so non-initiating ones see
|
|
116
|
+
# the final as "proactive" — suppress them via the run's tombstone.
|
|
117
|
+
if run_id && CollavreOpenclaw::ProcessedAiRun.processed?(run_id)
|
|
118
|
+
Rails.logger.warn("[CollavreOpenclaw] Duplicate run #{run_id} suppressed for creative #{creative_id} (already processed)")
|
|
119
|
+
return CollavreOpenclaw::ProcessedAiRun.comment_for(run_id)
|
|
120
|
+
end
|
|
108
121
|
|
|
109
122
|
# Dedup: skip if an identical comment was recently created
|
|
110
123
|
existing = Collavre::Comment
|
|
@@ -132,6 +145,15 @@ module CollavreOpenclaw
|
|
|
132
145
|
end
|
|
133
146
|
|
|
134
147
|
comment = Collavre::Comment.create!(comment_attrs)
|
|
148
|
+
|
|
149
|
+
# Unique run_id row is the backstop for a concurrent same-run race: the
|
|
150
|
+
# loser discards its duplicate and returns the winner.
|
|
151
|
+
if run_id && !CollavreOpenclaw::ProcessedAiRun.claim_proactive(run_id, comment)
|
|
152
|
+
Rails.logger.warn("[CollavreOpenclaw] Race on run #{run_id} resolved; discarding duplicate comment #{comment.id}")
|
|
153
|
+
comment.destroy
|
|
154
|
+
return CollavreOpenclaw::ProcessedAiRun.comment_for(run_id)
|
|
155
|
+
end
|
|
156
|
+
|
|
135
157
|
Rails.logger.info("[CollavreOpenclaw] Created AI comment #{comment.id} on creative #{creative_id}")
|
|
136
158
|
|
|
137
159
|
comment
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module CollavreOpenclaw
|
|
2
|
+
# Durable idempotency key for OpenClaw Gateway runs. Owned by this engine so
|
|
3
|
+
# the run concept stays off core collavre's general-purpose comments table.
|
|
4
|
+
# comment_id is set when a comment owns the run; ON DELETE SET NULL keeps the
|
|
5
|
+
# row alive as a tombstone after the comment is destroyed (review-fold).
|
|
6
|
+
class ProcessedAiRun < ApplicationRecord
|
|
7
|
+
self.table_name = "openclaw_processed_ai_runs"
|
|
8
|
+
|
|
9
|
+
belongs_to :comment, class_name: "Collavre::Comment", optional: true
|
|
10
|
+
|
|
11
|
+
validates :run_id, presence: true
|
|
12
|
+
|
|
13
|
+
def self.processed?(run_id)
|
|
14
|
+
return false if run_id.blank?
|
|
15
|
+
|
|
16
|
+
exists?(run_id: run_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.comment_for(run_id)
|
|
20
|
+
return nil if run_id.blank?
|
|
21
|
+
|
|
22
|
+
find_by(run_id: run_id)&.comment
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns false when another process already claimed the run, so the caller
|
|
26
|
+
# discards its duplicate.
|
|
27
|
+
def self.claim_proactive(run_id, comment)
|
|
28
|
+
return true if run_id.blank?
|
|
29
|
+
|
|
30
|
+
create!(run_id: run_id, comment: comment)
|
|
31
|
+
true
|
|
32
|
+
rescue ActiveRecord::RecordNotUnique
|
|
33
|
+
false
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Rails.logger.warn("[CollavreOpenclaw::ProcessedAiRun] Failed to claim run #{run_id}: #{e.message}")
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The canonical (activity-logged) reply wins: on a lost race it reclaims the
|
|
40
|
+
# run from the proactive duplicate and destroys it, leaving one comment.
|
|
41
|
+
def self.claim_canonical(run_id, comment)
|
|
42
|
+
return if run_id.blank? || comment.nil?
|
|
43
|
+
|
|
44
|
+
create!(run_id: run_id, comment: comment)
|
|
45
|
+
rescue ActiveRecord::RecordNotUnique
|
|
46
|
+
reclaim_for(run_id, comment)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
Rails.logger.warn("[CollavreOpenclaw::ProcessedAiRun] Failed to claim run #{run_id}: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.reclaim_for(run_id, canonical)
|
|
52
|
+
row = find_by(run_id: run_id)
|
|
53
|
+
return if row.nil?
|
|
54
|
+
|
|
55
|
+
duplicate = row.comment
|
|
56
|
+
if duplicate.nil? || duplicate.id == canonical.id
|
|
57
|
+
row.update_column(:comment_id, canonical.id)
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
row.update_column(:comment_id, canonical.id)
|
|
62
|
+
duplicate.destroy
|
|
63
|
+
Rails.logger.warn(
|
|
64
|
+
"[CollavreOpenclaw::ProcessedAiRun] Reclaimed run #{run_id} for comment #{canonical.id}; " \
|
|
65
|
+
"removed proactive duplicate #{duplicate.id}"
|
|
66
|
+
)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Rails.logger.warn("[CollavreOpenclaw::ProcessedAiRun] Failed to reclaim run #{run_id}: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
private_class_method :reclaim_for
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -106,7 +106,8 @@ module CollavreOpenclaw
|
|
|
106
106
|
client.chat_send(
|
|
107
107
|
session_key: session_key,
|
|
108
108
|
message: payload[:message],
|
|
109
|
-
attachments: payload[:attachments]
|
|
109
|
+
attachments: payload[:attachments],
|
|
110
|
+
on_run_id: method(:persist_run_id_on_comment)
|
|
110
111
|
) do |event|
|
|
111
112
|
case event[:state]
|
|
112
113
|
when "delta"
|
|
@@ -146,6 +147,20 @@ module CollavreOpenclaw
|
|
|
146
147
|
end
|
|
147
148
|
end
|
|
148
149
|
|
|
150
|
+
# Claim the run for the solicited reply so the same run's final, re-delivered
|
|
151
|
+
# as "proactive" to other processes, is suppressed. This reply is canonical
|
|
152
|
+
# (it carries the activity log), so it reclaims on a lost race.
|
|
153
|
+
def persist_run_id_on_comment(run_id)
|
|
154
|
+
return if run_id.blank?
|
|
155
|
+
|
|
156
|
+
comment = @context[:comment]
|
|
157
|
+
return unless comment.respond_to?(:id) && comment.id.present?
|
|
158
|
+
|
|
159
|
+
CollavreOpenclaw::ProcessedAiRun.claim_canonical(run_id, comment)
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
Rails.logger.warn("[CollavreOpenclaw] Failed to persist run_id on comment: #{e.message}")
|
|
162
|
+
end
|
|
163
|
+
|
|
149
164
|
def build_ws_chat_payload
|
|
150
165
|
{
|
|
151
166
|
message: format_message_for_ws,
|
|
@@ -150,7 +150,7 @@ module CollavreOpenclaw
|
|
|
150
150
|
effective_session_key = session_key || buffer&.dig(:session_key)
|
|
151
151
|
context = self.class.parse_session_key(effective_session_key)
|
|
152
152
|
|
|
153
|
-
dispatch_to_job(user, content, context)
|
|
153
|
+
dispatch_to_job(user, content, context, run_id)
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def handle_error(run_id, _user, payload)
|
|
@@ -208,7 +208,7 @@ module CollavreOpenclaw
|
|
|
208
208
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
-
def dispatch_to_job(user, content, context)
|
|
211
|
+
def dispatch_to_job(user, content, context, run_id = nil)
|
|
212
212
|
creative_id = context[:creative_id]
|
|
213
213
|
|
|
214
214
|
unless creative_id.present?
|
|
@@ -231,9 +231,11 @@ module CollavreOpenclaw
|
|
|
231
231
|
"type" => "proactive",
|
|
232
232
|
"content" => content,
|
|
233
233
|
"creative_id" => creative_id,
|
|
234
|
+
"run_id" => run_id,
|
|
234
235
|
"context" => {
|
|
235
236
|
"creative_id" => creative_id,
|
|
236
|
-
"thread_id" => context[:topic_id]
|
|
237
|
+
"thread_id" => context[:topic_id],
|
|
238
|
+
"run_id" => run_id
|
|
237
239
|
}
|
|
238
240
|
}
|
|
239
241
|
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreOpenclaw
|
|
4
|
+
# Aborts an in-flight OpenClaw gateway session for a cancelled task.
|
|
5
|
+
# Registered into Collavre::AgentSessionAbort so core stays vendor-agnostic.
|
|
6
|
+
class SessionAbortService
|
|
7
|
+
def self.call(agent:, task:, creative: nil, comment: nil)
|
|
8
|
+
new(agent: agent, task: task, creative: creative, comment: comment).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(agent:, task:, creative: nil, comment: nil)
|
|
12
|
+
@agent = agent
|
|
13
|
+
@task = task
|
|
14
|
+
@creative = creative
|
|
15
|
+
@comment = comment
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless defined?(CollavreOpenclaw::ConnectionManager)
|
|
20
|
+
|
|
21
|
+
creative = @creative || @task.creative ||
|
|
22
|
+
Collavre::Creative.find_by(id: payload.dig("creative", "id"))
|
|
23
|
+
comment = @comment || Collavre::Comment.find_by(id: payload.dig("comment", "id"))
|
|
24
|
+
|
|
25
|
+
adapter = CollavreOpenclaw::OpenclawAdapter.new(
|
|
26
|
+
user: @agent,
|
|
27
|
+
system_prompt: "",
|
|
28
|
+
context: {
|
|
29
|
+
creative: creative,
|
|
30
|
+
user: @agent,
|
|
31
|
+
task: @task,
|
|
32
|
+
comment: comment
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
conn = CollavreOpenclaw::ConnectionManager.instance.connection_for(@agent)
|
|
36
|
+
conn.chat_abort(session_key: adapter.session_key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def payload
|
|
42
|
+
@task.trigger_event_payload || {}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -154,9 +154,11 @@ module CollavreOpenclaw
|
|
|
154
154
|
# @param message [String]
|
|
155
155
|
# @param attachments [Array<Hash>, nil]
|
|
156
156
|
# @param idempotency_key [String]
|
|
157
|
+
# @param on_run_id [#call, nil] called with the resolved Gateway runId before
|
|
158
|
+
# streaming, so callers can persist it as a cross-process idempotency key.
|
|
157
159
|
# @yield [Hash] chat events with :state, :text, :message keys
|
|
158
160
|
# @return [String, nil] final response text
|
|
159
|
-
def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, &block)
|
|
161
|
+
def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, on_run_id: nil, &block)
|
|
160
162
|
ensure_connected!
|
|
161
163
|
touch_activity!
|
|
162
164
|
|
|
@@ -198,6 +200,15 @@ module CollavreOpenclaw
|
|
|
198
200
|
end
|
|
199
201
|
end
|
|
200
202
|
|
|
203
|
+
# Surface runId before streaming; guard so a faulty callback can't abort the stream.
|
|
204
|
+
if on_run_id
|
|
205
|
+
begin
|
|
206
|
+
on_run_id.call(actual_run_id)
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] on_run_id callback failed: #{e.message}")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
201
212
|
# Stream events until final/error/aborted
|
|
202
213
|
last_seq = nil
|
|
203
214
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateOpenclawProcessedAiRuns < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Unique run_id guarantees at most one comment per Gateway run across all
|
|
3
|
+
# processes and reconnects. ON DELETE SET NULL keeps the row as a tombstone
|
|
4
|
+
# after its comment is destroyed (review-fold), so re-deliveries stay deduped.
|
|
5
|
+
def change
|
|
6
|
+
create_table :openclaw_processed_ai_runs do |t|
|
|
7
|
+
t.string :run_id, null: false
|
|
8
|
+
t.references :comment, null: true, foreign_key: { on_delete: :nullify }
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :openclaw_processed_ai_runs, :run_id, unique: true
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_openclaw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -81,14 +81,17 @@ files:
|
|
|
81
81
|
- app/jobs/collavre_openclaw/callback_processor_job.rb
|
|
82
82
|
- app/models/collavre_openclaw/application_record.rb
|
|
83
83
|
- app/models/collavre_openclaw/pending_callback.rb
|
|
84
|
+
- app/models/collavre_openclaw/processed_ai_run.rb
|
|
84
85
|
- app/services/collavre_openclaw/ai_client_extension.rb
|
|
85
86
|
- app/services/collavre_openclaw/connection_manager.rb
|
|
86
87
|
- app/services/collavre_openclaw/em_reactor.rb
|
|
87
88
|
- app/services/collavre_openclaw/openclaw_adapter.rb
|
|
88
89
|
- app/services/collavre_openclaw/proactive_message_handler.rb
|
|
90
|
+
- app/services/collavre_openclaw/session_abort_service.rb
|
|
89
91
|
- app/services/collavre_openclaw/websocket_client.rb
|
|
90
92
|
- config/initializers/ai_client_extension.rb
|
|
91
93
|
- config/initializers/integration_settings.rb
|
|
94
|
+
- config/initializers/session_abort.rb
|
|
92
95
|
- config/locales/en.yml
|
|
93
96
|
- config/locales/ko.yml
|
|
94
97
|
- config/routes.rb
|
|
@@ -102,6 +105,7 @@ files:
|
|
|
102
105
|
- db/migrate/20260202140000_rename_api_token_to_api_key_in_openclaw_accounts.rb
|
|
103
106
|
- db/migrate/20260202150001_migrate_pending_callbacks_to_user.rb
|
|
104
107
|
- db/migrate/20260202150002_drop_openclaw_accounts.rb
|
|
108
|
+
- db/migrate/20260609040000_create_openclaw_processed_ai_runs.rb
|
|
105
109
|
- lib/collavre_openclaw.rb
|
|
106
110
|
- lib/collavre_openclaw/configuration.rb
|
|
107
111
|
- lib/collavre_openclaw/engine.rb
|