collavre_openclaw 0.2.2 → 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/jobs/collavre_openclaw/callback_processor_job.rb +16 -1
- data/app/services/collavre_openclaw/openclaw_adapter.rb +5 -1
- data/app/services/collavre_openclaw/proactive_message_handler.rb +24 -2
- data/app/services/collavre_openclaw/websocket_client.rb +80 -10
- data/lib/collavre_openclaw/configuration.rb +4 -0
- data/lib/collavre_openclaw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7022c888e2fbe72f646eba7d022d489ff1458da405058638c0b15bc426076ec
|
|
4
|
+
data.tar.gz: 4b822a95fba2cba72b9ceda1d8a92f24919b8982221d8ef4ce1ed6cddea4ade0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 26df63f943760253bba51dd0667e06a6fdac8821a5982e49163d9d6114f94eed5ff110f47d4af58531e22a966f7efa669f3cacafb608c3446b9d025530b9806f
|
|
7
|
+
data.tar.gz: 33b8d566a78989521fe384add9bdedbdce668422c9658176c4c9cd2aa5620e31fa3fe247f7aefb82fd045ba33573bcfce391f2d3f88c8788c32a6d4737ce3cdb
|
|
@@ -2,6 +2,8 @@ module CollavreOpenclaw
|
|
|
2
2
|
class CallbackProcessorJob < ApplicationJob
|
|
3
3
|
queue_as :default
|
|
4
4
|
|
|
5
|
+
DEDUP_WINDOW = 5.seconds
|
|
6
|
+
|
|
5
7
|
def perform(user_id, payload)
|
|
6
8
|
@user = User.find_by(id: user_id)
|
|
7
9
|
return unless @user
|
|
@@ -98,9 +100,22 @@ module CollavreOpenclaw
|
|
|
98
100
|
return
|
|
99
101
|
end
|
|
100
102
|
|
|
103
|
+
effective_creative = creative.effective_origin
|
|
104
|
+
|
|
105
|
+
# Dedup: skip if an identical comment was recently created
|
|
106
|
+
existing = Collavre::Comment
|
|
107
|
+
.where(user: @user, creative: effective_creative, content: content)
|
|
108
|
+
.where("created_at > ?", DEDUP_WINDOW.ago)
|
|
109
|
+
.first
|
|
110
|
+
|
|
111
|
+
if existing
|
|
112
|
+
Rails.logger.warn("[CollavreOpenclaw] Duplicate comment suppressed for creative #{creative_id} (existing comment #{existing.id})")
|
|
113
|
+
return existing
|
|
114
|
+
end
|
|
115
|
+
|
|
101
116
|
# Build comment attributes
|
|
102
117
|
comment_attrs = {
|
|
103
|
-
creative:
|
|
118
|
+
creative: effective_creative,
|
|
104
119
|
user: @user,
|
|
105
120
|
content: content,
|
|
106
121
|
private: false
|
|
@@ -35,7 +35,11 @@ module CollavreOpenclaw
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Try WebSocket first, fall back to HTTP
|
|
38
|
-
|
|
38
|
+
# Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
|
|
39
|
+
if CollavreOpenclaw.config.transport == "http"
|
|
40
|
+
Rails.logger.info("[CollavreOpenclaw] Using HTTP transport (forced by config)")
|
|
41
|
+
chat_via_http(messages, &block)
|
|
42
|
+
elsif websocket_available?
|
|
39
43
|
chat_via_websocket(messages, &block)
|
|
40
44
|
else
|
|
41
45
|
chat_via_http(messages, &block)
|
|
@@ -21,8 +21,12 @@ module CollavreOpenclaw
|
|
|
21
21
|
# How often (seconds) to check for stale buffers.
|
|
22
22
|
SWEEP_INTERVAL = 60
|
|
23
23
|
|
|
24
|
+
# Cooldown (seconds) to remember dispatched runIds and suppress duplicates.
|
|
25
|
+
DISPATCHED_RUN_TTL = 60
|
|
26
|
+
|
|
24
27
|
def initialize
|
|
25
|
-
@buffers = {}
|
|
28
|
+
@buffers = {} # runId → { text:, session_key:, connection_owner_id:, created_at: }
|
|
29
|
+
@dispatched_runs = {} # runId → monotonic timestamp (dedup for broadcast+nodeSend duplicates)
|
|
26
30
|
@mutex = Mutex.new
|
|
27
31
|
@last_sweep_at = monotonic_now
|
|
28
32
|
end
|
|
@@ -116,7 +120,18 @@ module CollavreOpenclaw
|
|
|
116
120
|
def handle_final(run_id, user, session_key, payload)
|
|
117
121
|
final_text = extract_text(payload)
|
|
118
122
|
|
|
119
|
-
buffer = @mutex.synchronize
|
|
123
|
+
buffer = @mutex.synchronize do
|
|
124
|
+
# Check if we already dispatched this runId (broadcast+nodeSend duplicate)
|
|
125
|
+
if @dispatched_runs.key?(run_id)
|
|
126
|
+
Rails.logger.info(
|
|
127
|
+
"[CollavreOpenclaw::Proactive] Suppressing duplicate final for runId=#{run_id}"
|
|
128
|
+
)
|
|
129
|
+
@buffers.delete(run_id)
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@buffers.delete(run_id)
|
|
134
|
+
end
|
|
120
135
|
|
|
121
136
|
# Prefer final text (complete message) over buffered deltas (fragments).
|
|
122
137
|
# Fall back to buffered deltas only when final text is empty.
|
|
@@ -128,6 +143,9 @@ module CollavreOpenclaw
|
|
|
128
143
|
|
|
129
144
|
return unless content.present?
|
|
130
145
|
|
|
146
|
+
# Mark as dispatched before enqueuing to prevent duplicates
|
|
147
|
+
@mutex.synchronize { @dispatched_runs[run_id] = monotonic_now }
|
|
148
|
+
|
|
131
149
|
# Use session_key from final event, falling back to buffered
|
|
132
150
|
effective_session_key = session_key || buffer&.dig(:session_key)
|
|
133
151
|
context = self.class.parse_session_key(effective_session_key)
|
|
@@ -166,6 +184,7 @@ module CollavreOpenclaw
|
|
|
166
184
|
|
|
167
185
|
@last_sweep_at = now
|
|
168
186
|
cutoff = now - BUFFER_TTL
|
|
187
|
+
dispatched_cutoff = now - DISPATCHED_RUN_TTL
|
|
169
188
|
|
|
170
189
|
@mutex.synchronize do
|
|
171
190
|
stale_ids = @buffers.each_with_object([]) do |(run_id, buf), ids|
|
|
@@ -179,6 +198,9 @@ module CollavreOpenclaw
|
|
|
179
198
|
"(older than #{BUFFER_TTL}s)"
|
|
180
199
|
)
|
|
181
200
|
end
|
|
201
|
+
|
|
202
|
+
# Sweep expired dispatched run entries
|
|
203
|
+
@dispatched_runs.delete_if { |_id, ts| ts < dispatched_cutoff }
|
|
182
204
|
end
|
|
183
205
|
end
|
|
184
206
|
|
|
@@ -19,6 +19,8 @@ module CollavreOpenclaw
|
|
|
19
19
|
# - Rails threads call public methods which bridge via EM.next_tick + Queue
|
|
20
20
|
class WebsocketClient
|
|
21
21
|
PROTOCOL_VERSION = 3
|
|
22
|
+
COMPLETED_RUN_COOLDOWN = 5 # seconds to suppress late-arriving events for completed runs
|
|
23
|
+
SEEN_EVENT_TTL = 30 # seconds to remember (runId, seq) pairs for dedup
|
|
22
24
|
|
|
23
25
|
attr_reader :user, :state
|
|
24
26
|
|
|
@@ -32,6 +34,8 @@ module CollavreOpenclaw
|
|
|
32
34
|
@connect_waiters = [] # Queues for threads waiting on in-progress connect
|
|
33
35
|
@pending_requests = {} # id → { queue:, timer: }
|
|
34
36
|
@pending_runs = {} # runId → Queue (for chat.send streaming)
|
|
37
|
+
@completed_runs = {} # runId → monotonic timestamp (cooldown for late events)
|
|
38
|
+
@seen_chat_events = {} # "runId:seq" → monotonic timestamp (broadcast+nodeSend dedup)
|
|
35
39
|
@proactive_handler = nil
|
|
36
40
|
@reconnect_attempts = 0
|
|
37
41
|
@last_activity_at = nil
|
|
@@ -169,6 +173,8 @@ module CollavreOpenclaw
|
|
|
169
173
|
end
|
|
170
174
|
|
|
171
175
|
# Stream events until final/error/aborted
|
|
176
|
+
last_seq = nil
|
|
177
|
+
|
|
172
178
|
loop do
|
|
173
179
|
event = wait_with_timeout(run_queue, config.read_timeout, "chat response")
|
|
174
180
|
|
|
@@ -178,12 +184,27 @@ module CollavreOpenclaw
|
|
|
178
184
|
raise ChatError, event[:error]
|
|
179
185
|
end
|
|
180
186
|
|
|
187
|
+
# Gateway may broadcast + nodeSend the same event, causing
|
|
188
|
+
# duplicates on the same WebSocket. Skip already-seen seqs.
|
|
189
|
+
seq = event[:seq]
|
|
190
|
+
if seq && last_seq && seq <= last_seq
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
last_seq = seq if seq
|
|
194
|
+
|
|
181
195
|
case event[:state]
|
|
182
196
|
when "delta"
|
|
183
197
|
text = extract_event_text(event)
|
|
184
198
|
if text.present?
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
# Gateway sends accumulated content (full text so far) in
|
|
200
|
+
# each delta event. Compute the incremental delta for callers.
|
|
201
|
+
delta = if response_text.present? && text.start_with?(response_text)
|
|
202
|
+
text[response_text.length..]
|
|
203
|
+
else
|
|
204
|
+
text
|
|
205
|
+
end
|
|
206
|
+
response_text.replace(text)
|
|
207
|
+
yield({ state: "delta", text: delta }) if delta.present? && block_given?
|
|
187
208
|
end
|
|
188
209
|
when "final"
|
|
189
210
|
text = extract_event_text(event)
|
|
@@ -205,6 +226,11 @@ module CollavreOpenclaw
|
|
|
205
226
|
@pending_runs.delete(actual_run_id) if actual_run_id
|
|
206
227
|
# Also clean up idempotency_key if send_rpc failed before we got a runId
|
|
207
228
|
@pending_runs.delete(idempotency_key) if idempotency_key
|
|
229
|
+
|
|
230
|
+
# Record completed runs so late-arriving events are suppressed
|
|
231
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
|
+
@completed_runs[actual_run_id] = now if actual_run_id
|
|
233
|
+
@completed_runs[idempotency_key] = now if idempotency_key && idempotency_key != actual_run_id
|
|
208
234
|
end
|
|
209
235
|
end
|
|
210
236
|
|
|
@@ -329,17 +355,18 @@ module CollavreOpenclaw
|
|
|
329
355
|
end
|
|
330
356
|
|
|
331
357
|
def send_connect_request(challenge_payload)
|
|
332
|
-
|
|
333
|
-
|
|
358
|
+
# Use valid client constants from OpenClaw protocol schema.
|
|
359
|
+
# "gateway-client" and "backend" are appropriate for server-side integrations.
|
|
360
|
+
# Do NOT include partial `device` — it requires full attestation (publicKey, signature, signedAt).
|
|
334
361
|
|
|
335
362
|
params = {
|
|
336
363
|
minProtocol: PROTOCOL_VERSION,
|
|
337
364
|
maxProtocol: PROTOCOL_VERSION,
|
|
338
365
|
client: {
|
|
339
|
-
id: "
|
|
366
|
+
id: "gateway-client",
|
|
340
367
|
version: CollavreOpenclaw::VERSION,
|
|
341
368
|
platform: "ruby",
|
|
342
|
-
mode: "
|
|
369
|
+
mode: "backend"
|
|
343
370
|
},
|
|
344
371
|
role: "operator",
|
|
345
372
|
scopes: [ "operator.read", "operator.write" ],
|
|
@@ -348,10 +375,7 @@ module CollavreOpenclaw
|
|
|
348
375
|
permissions: {},
|
|
349
376
|
auth: { token: @user.llm_api_key },
|
|
350
377
|
locale: "en-US",
|
|
351
|
-
userAgent: "collavre-openclaw/#{CollavreOpenclaw::VERSION}"
|
|
352
|
-
device: {
|
|
353
|
-
id: device_id
|
|
354
|
-
}
|
|
378
|
+
userAgent: "collavre-openclaw/#{CollavreOpenclaw::VERSION}"
|
|
355
379
|
}
|
|
356
380
|
|
|
357
381
|
request_id = SecureRandom.uuid
|
|
@@ -398,6 +422,14 @@ module CollavreOpenclaw
|
|
|
398
422
|
|
|
399
423
|
def handle_chat_event(payload)
|
|
400
424
|
run_id = payload[:runId]
|
|
425
|
+
seq = payload[:seq]
|
|
426
|
+
|
|
427
|
+
# Dedup: Gateway sends identical events via broadcast() + nodeSendToSession().
|
|
428
|
+
# Skip if we've already seen this (runId, seq) pair.
|
|
429
|
+
if run_id && seq && duplicate_chat_event?(run_id, seq)
|
|
430
|
+
Rails.logger.debug("[CollavreOpenclaw::WS] Skipping duplicate chat event (runId=#{run_id}, seq=#{seq})")
|
|
431
|
+
return
|
|
432
|
+
end
|
|
401
433
|
|
|
402
434
|
# Check if this is a response to a pending chat.send
|
|
403
435
|
run_queue = @mutex.synchronize { @pending_runs[run_id] }
|
|
@@ -405,6 +437,9 @@ module CollavreOpenclaw
|
|
|
405
437
|
if run_queue
|
|
406
438
|
# Known run — forward to the waiting thread
|
|
407
439
|
run_queue.push(payload)
|
|
440
|
+
elsif recently_completed_run?(run_id)
|
|
441
|
+
# Late-arriving event for a run we already finished — suppress it
|
|
442
|
+
Rails.logger.info("[CollavreOpenclaw::WS] Suppressing late event for completed runId=#{run_id}")
|
|
408
443
|
elsif @proactive_handler
|
|
409
444
|
# Unknown run — proactive message from Gateway (cron/heartbeat)
|
|
410
445
|
Rails.logger.info("[CollavreOpenclaw::WS] Proactive message received (runId=#{run_id})")
|
|
@@ -414,6 +449,41 @@ module CollavreOpenclaw
|
|
|
414
449
|
end
|
|
415
450
|
end
|
|
416
451
|
|
|
452
|
+
# Check if this (runId, seq) pair was already seen. Records it if new.
|
|
453
|
+
# Gateway emits each event via broadcast() AND nodeSendToSession(),
|
|
454
|
+
# delivering the identical payload (same runId + seq) twice.
|
|
455
|
+
def duplicate_chat_event?(run_id, seq)
|
|
456
|
+
key = "#{run_id}:#{seq}"
|
|
457
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
458
|
+
|
|
459
|
+
@mutex.synchronize do
|
|
460
|
+
# Sweep expired entries periodically
|
|
461
|
+
if @seen_chat_events.size > 100
|
|
462
|
+
@seen_chat_events.delete_if { |_k, ts| now - ts > SEEN_EVENT_TTL }
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
if @seen_chat_events.key?(key)
|
|
466
|
+
true
|
|
467
|
+
else
|
|
468
|
+
@seen_chat_events[key] = now
|
|
469
|
+
false
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Check if a runId was recently completed (within cooldown window).
|
|
475
|
+
# Also sweeps expired entries to prevent unbounded growth.
|
|
476
|
+
def recently_completed_run?(run_id)
|
|
477
|
+
@mutex.synchronize do
|
|
478
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
479
|
+
|
|
480
|
+
# Sweep expired entries
|
|
481
|
+
@completed_runs.delete_if { |_id, ts| now - ts > COMPLETED_RUN_COOLDOWN }
|
|
482
|
+
|
|
483
|
+
@completed_runs.key?(run_id)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
417
487
|
def handle_tick(_payload)
|
|
418
488
|
# Respond with a tick acknowledgment (poll response)
|
|
419
489
|
send_frame({
|
|
@@ -22,6 +22,9 @@ module CollavreOpenclaw
|
|
|
22
22
|
# WebSocket connect timeout (seconds)
|
|
23
23
|
attr_accessor :ws_connect_timeout
|
|
24
24
|
|
|
25
|
+
# Transport mode: "auto" (WebSocket first, HTTP fallback), "http" (HTTP only)
|
|
26
|
+
attr_accessor :transport
|
|
27
|
+
|
|
25
28
|
def initialize
|
|
26
29
|
@open_timeout = ENV.fetch("OPENCLAW_OPEN_TIMEOUT", 10).to_i
|
|
27
30
|
@read_timeout = ENV.fetch("OPENCLAW_READ_TIMEOUT", 180).to_i # 3 minutes for AI responses
|
|
@@ -30,6 +33,7 @@ module CollavreOpenclaw
|
|
|
30
33
|
@ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
|
|
31
34
|
@ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
|
|
32
35
|
@ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
|
|
36
|
+
@transport = ENV.fetch("OPENCLAW_TRANSPORT", "http")
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Legacy accessor for backward compatibility
|