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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd95c0741d901e7dd551d64362a7cce7d0b0f7f9b4a87a3469990ccc1479e340
4
- data.tar.gz: 7db937c777a492b23e19037e83bf3dd4245a46ff8333e9de6c6e9e55e2960f81
3
+ metadata.gz: 3a5a10c4a59247ea763b3f635005c08375186c6bb279dfa9e00ea5e5533ae438
4
+ data.tar.gz: '097534bec297da15507b18e1f060990471fc54e9076ba5f81065a36ecb88b262'
5
5
  SHA512:
6
- metadata.gz: 701cfb138ae6d0f25da60fcea13e56970647490ec32ab24cb6ec26e06c93ea250f9af1efed0fc9ddc7790b4f8d703e5ca7b2c8730902720e1a0e2d061216c6f9
7
- data.tar.gz: 53315260ffb37347b9289080de544e65b19b207943d8784a341eeda0d042175e80753d3a43bf70f42b0ec087ba919827ed7fc50338dbf5d68d63a6daafd40039
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,5 @@
1
+ Rails.application.config.to_prepare do
2
+ if defined?(Collavre::AgentSessionAbort)
3
+ Collavre::AgentSessionAbort.register("openclaw", CollavreOpenclaw::SessionAbortService)
4
+ end
5
+ end
@@ -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
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.6.2"
2
+ VERSION = "0.6.3"
3
3
  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.2
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