collavre_openclaw 0.6.4 → 0.6.6

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: '028dc9b0f4d77120602482cb67e02ccb7d54efa1ce69d850452a4fe548923e26'
4
- data.tar.gz: e3caf613040f7572a727ed5abd89dcb14f5ddc336ebd962006b4ad67bffed20a
3
+ metadata.gz: a41340e38120813ecf6b2776dc271d533bb3f0e9eb742200a2a844b06fbf0aa4
4
+ data.tar.gz: 17a1c6d6cd3c69c15025a843d93c2eb65b6df91ce6d145a21e9d2159331b7b53
5
5
  SHA512:
6
- metadata.gz: 69854771a368ea6200b04126e70d4939b0b70783adaae82c6ed43998879f009d3d87fed40c84c3a3f4aa0d9e90f08b350f23c84f427127e9c71236ee051744c0
7
- data.tar.gz: 9f2ef5b7a2f8d3980ddcb81c878a65a760545b35374e500e319b04131886297a95dfe864b8dccf617b383a15b6b66a8f48029af2f4cad35163b5bb38168c8dee
6
+ metadata.gz: 7c2e5dd42f34913f03a2d82458b8df854286164fc3089657786cb0b251945c2d59772f379a2d99f51f246c1fdbaf9f6534de6517ff7cafec06acdbf7f01638f6
7
+ data.tar.gz: 8c2a7e4c686854841702115e9d1587fb508566e96591fb0df30673f4b9467356e2268114d92f8e0f9be72fd207e66598f5b3ed4924179873499373dad0607c59
@@ -15,6 +15,12 @@ module CollavreOpenclaw
15
15
  # @param messages_input [Hash, Array] Hash { messages:, first_message:, context_changed:, system_prompt: }
16
16
  # from SessionContextResolver, or a plain Array from standalone callers (e.g., CompressJob).
17
17
  def chat(messages_input, tools: [], &block)
18
+ # Cleared on the way in, as the base #chat clears it — the flag describes
19
+ # the last request, and this branch never reaches `super`. A client is
20
+ # reused across a turn's calls, so a failure left standing would have
21
+ # every later one claiming it delivered nothing.
22
+ @last_handoff_failed = false
23
+ @handed_off = false
18
24
  normalized_vendor = vendor.to_s.downcase
19
25
  messages_data = normalize_messages_input(messages_input)
20
26
 
@@ -27,10 +33,16 @@ module CollavreOpenclaw
27
33
  # key?(:system_prompt) distinguishes "not provided" (Array input) from "explicitly nil" (incremental session).
28
34
  resolved_system_prompt = messages_data.key?(:system_prompt) ? messages_data[:system_prompt] : system_prompt
29
35
  user = context&.dig(:user)
36
+ # before_tool_call is the client's lifecycle checkpoint (terminal
37
+ # status + turn deadline). The base client fires it at RubyLLM
38
+ # tool-call boundaries; this path never reaches those, so the adapter
39
+ # gets the same callable to poll mid-stream instead.
30
40
  adapter = adapter_class.new(
31
41
  user: user,
32
42
  system_prompt: resolved_system_prompt,
33
- context: context
43
+ context: context,
44
+ lifecycle_check: @before_tool_call,
45
+ request_timeout_seconds: @request_timeout_seconds
34
46
  )
35
47
 
36
48
  response_content = nil
@@ -42,10 +54,17 @@ module CollavreOpenclaw
42
54
  error_message = e.message
43
55
  raise
44
56
  ensure
45
- # Honor no-log mode (e.g. inline typo correction on *unsubmitted* drafts).
46
- # Base Collavre::AiClient#chat gates logging behind @log_interactions; this
47
- # prepended adapter path bypasses super, so it must gate it too — otherwise
48
- # private drafts leak to ActivityLog for OpenClaw-backed agents.
57
+ # The adapter can know that the gateway accepted the request before
58
+ # the caller's streaming block raises (notably CancelledError). Copy
59
+ # both answers while unwinding as well as on a normal return, so
60
+ # AiAgentService can persist the actual handoff rather than infer it
61
+ # from how #chat exited.
62
+ @last_handoff_failed = adapter.last_handoff_failed?
63
+ @handed_off = adapter.handed_off?
64
+
65
+ # Honor no-log mode. Base Collavre::AiClient#chat gates logging behind
66
+ # @log_interactions; this prepended adapter path bypasses super, so it
67
+ # must gate logging too.
49
68
  if @log_interactions
50
69
  log_interaction(
51
70
  messages: messages_data[:messages],
@@ -3,6 +3,16 @@ require "json"
3
3
 
4
4
  module CollavreOpenclaw
5
5
  class OpenclawAdapter
6
+ class LifecycleCheckError < StandardError
7
+ attr_reader :original
8
+
9
+ def initialize(original)
10
+ @original = original
11
+ super(original.message)
12
+ set_backtrace(original.backtrace)
13
+ end
14
+ end
15
+
6
16
  # Pure transport adapter for OpenClaw AI Gateway.
7
17
  # Session context filtering (full vs incremental) is handled upstream
8
18
  # by SessionContextResolver — this adapter sends exactly what it receives.
@@ -16,19 +26,62 @@ module CollavreOpenclaw
16
26
  # Same Topic, multiple users → shared context
17
27
  # Different Topics → isolated sessions
18
28
 
19
- def initialize(user:, system_prompt:, context: {})
29
+ # lifecycle_check: callable polled during a chat so the turn can observe
30
+ # an external terminal status or its wall-clock deadline. It has to be
31
+ # injected here because this adapter's tools run remotely on the gateway —
32
+ # there is no local tool-call boundary, and the caller's streaming block
33
+ # fires only on text, which a tool-only run never emits. A raise from it
34
+ # (Collavre::CancelledError or a subclass) unwinds past both transports'
35
+ # fallback rescues.
36
+ def initialize(user:, system_prompt:, context: {}, lifecycle_check: nil, request_timeout_seconds: nil)
20
37
  @user = user
21
38
  @system_prompt = system_prompt
22
39
  @context = context
40
+ @lifecycle_check = wrap_lifecycle_check(lifecycle_check)
41
+ @request_timeout_seconds = request_timeout_seconds
42
+ @last_handoff_failed = false
43
+ @handed_off = false
44
+ end
45
+
46
+ # Did the last #chat end without the gateway ever receiving the payload?
47
+ #
48
+ # The same question Collavre::AiClient#last_handoff_failed? answers for its
49
+ # own provider path, and it has to be answered here as well because
50
+ # AiClientExtension#chat routes past that method entirely: every failure
51
+ # below turns into a streamed error plus nil, and nil is also what an
52
+ # ordinary empty answer returns. Orchestration::DeliveryRecord needs the two
53
+ # apart, since a turn that handed the gateway nothing delivered nothing
54
+ # while AiAgentJob still marks it `done`.
55
+ #
56
+ # The proxy is the same one the base client uses: nothing streamed means
57
+ # nothing was handed over. An error *after* deltas is not one of these — the
58
+ # gateway had the payload and answered part of it, so the comments that turn
59
+ # swallowed did reach the agent.
60
+ #
61
+ # The question is asked of the whole #chat, not of a transport: one call can
62
+ # stream over the WebSocket and then fall back to HTTP, so each transport's
63
+ # own response buffer answers only for its own attempt. @handed_off spans
64
+ # both, and is reset once per #chat.
65
+ def last_handoff_failed?
66
+ @last_handoff_failed
67
+ end
68
+
69
+ # The same fact, asked positively — see AiClient#handed_off?. A turn the
70
+ # user stops mid-answer never reaches the line that sets the flag above.
71
+ def handed_off?
72
+ @handed_off
23
73
  end
24
74
 
25
75
  # @param messages_data [Hash] { messages:, first_message:, context_changed: }
26
76
  def chat(messages_data, &block)
77
+ @last_handoff_failed = false
78
+ @handed_off = false
27
79
  parse_messages_data!(messages_data)
28
80
 
29
81
  unless @user&.gateway_url.present?
30
82
  Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
31
83
  yield "Error: OpenClaw Gateway URL not configured" if block_given?
84
+ @last_handoff_failed = true
32
85
  return nil
33
86
  end
34
87
 
@@ -36,6 +89,7 @@ module CollavreOpenclaw
36
89
  Rails.logger.error("[CollavreOpenclaw] No API key configured for user #{@user&.id}")
37
90
  yield "Error: OpenClaw API key not configured or decryption failed. " \
38
91
  "Please re-enter the API key in AI agent settings." if block_given?
92
+ @last_handoff_failed = true
39
93
  return nil
40
94
  end
41
95
 
@@ -96,6 +150,7 @@ module CollavreOpenclaw
96
150
 
97
151
  def chat_via_websocket(&block)
98
152
  response_content = +""
153
+ errored = false
99
154
 
100
155
  begin
101
156
  client = ConnectionManager.instance.connection_for(@user)
@@ -107,29 +162,55 @@ module CollavreOpenclaw
107
162
  session_key: session_key,
108
163
  message: payload[:message],
109
164
  attachments: payload[:attachments],
110
- on_run_id: method(:persist_run_id_on_comment)
165
+ on_run_id: method(:handle_run_id),
166
+ lifecycle_check: @lifecycle_check
111
167
  ) do |event|
112
168
  case event[:state]
113
169
  when "delta"
114
170
  if event[:text].present?
115
171
  response_content << event[:text]
116
- yield event[:text] if block_given?
172
+ @handed_off = true
173
+ emit_to_caller(event[:text], &block)
117
174
  end
118
175
  when "final"
119
176
  # If no deltas were streamed, final contains the full text
120
177
  if response_content.blank? && event[:text].present?
121
178
  response_content << event[:text]
122
- yield event[:text] if block_given?
179
+ @handed_off = true
180
+ emit_to_caller(event[:text], &block)
123
181
  end
124
182
  when "error"
125
183
  error_msg = event[:text] || "Unknown error"
126
- yield "OpenClaw Error: #{error_msg}" if block_given?
184
+ errored = true
185
+ emit_to_caller("OpenClaw Error: #{error_msg}", &block)
127
186
  when "aborted"
128
187
  # User or system aborted
129
188
  end
130
189
  end
131
190
 
191
+ # The gateway answered with an error and nothing else: see
192
+ # #last_handoff_failed?. Decided after the stream rather than in the
193
+ # branch above, so a delta that arrives either side of the error still
194
+ # counts as the payload having got through.
195
+ #
196
+ # Inert behind a client that surfaces a run id, since #handle_run_id has
197
+ # already answered by the time any event arrives. Kept for one that does
198
+ # not: an error event is then the only evidence either way, and reading
199
+ # it as a delivery would be the losing mistake.
200
+ @last_handoff_failed = true if errored && !@handed_off
132
201
  response_content.presence
202
+ rescue Collavre::CancelledError
203
+ # Raised out of the caller's streaming block when the task hit a
204
+ # terminal status or its turn deadline. The turn is already over —
205
+ # falling back to HTTP would issue a second provider request (and
206
+ # repeat tool side effects) instead of releasing this worker.
207
+ raise
208
+ rescue LifecycleCheckError => e
209
+ # A lifecycle poll is application control flow, not evidence that the
210
+ # WebSocket transport failed. In particular, falling back after the
211
+ # gateway acknowledged chat.send could submit the same tool-bearing
212
+ # payload twice. Restore the callback's original exception unchanged.
213
+ raise e.original
133
214
  rescue CollavreOpenclaw::ConnectionError,
134
215
  CollavreOpenclaw::TimeoutError => e
135
216
  Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
@@ -138,6 +219,7 @@ module CollavreOpenclaw
138
219
  Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
139
220
  error_msg = "OpenClaw Error: #{e.message}"
140
221
  yield error_msg if block_given?
222
+ @last_handoff_failed = !@handed_off
141
223
  nil
142
224
  rescue StandardError => e
143
225
  Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
@@ -147,6 +229,21 @@ module CollavreOpenclaw
147
229
  end
148
230
  end
149
231
 
232
+ # The gateway answered chat.send with a run id, which is the handoff: it has
233
+ # the whole payload and the run may already be calling tools. Everything
234
+ # after this — a run that goes quiet until the read timeout, an HTTP
235
+ # fallback that fails before yielding — costs an *answer*, not the delivery,
236
+ # and the comments this turn swallowed are with the agent either way.
237
+ #
238
+ # Reaching here at all is the acknowledgement: WebsocketClient#chat_send
239
+ # calls this straight after send_rpc returns, and send_rpc raises on a read
240
+ # timeout and on an RPC error. Set before persisting, because the handoff
241
+ # happened whether or not we can write the run id down.
242
+ def handle_run_id(run_id)
243
+ @handed_off = true
244
+ persist_run_id_on_comment(run_id)
245
+ end
246
+
150
247
  # Claim the run for the solicited reply so the same run's final, re-delivered
151
248
  # as "proactive" to other processes, is suppressed. This reply is canonical
152
249
  # (it carries the activity log), so it reclaims on a lost race.
@@ -289,15 +386,27 @@ module CollavreOpenclaw
289
386
 
290
387
  stream_response(payload) do |chunk|
291
388
  response_content << chunk
292
- yield chunk if block_given?
389
+ @handed_off = true
390
+ emit_to_caller(chunk, &block)
293
391
  end
294
392
 
295
393
  response_content.presence
394
+ rescue Collavre::CancelledError
395
+ # Same contract as the WebSocket path: a cancelled/deadline-failed
396
+ # turn must release the worker, not be rewritten as a provider error.
397
+ raise
398
+ rescue LifecycleCheckError => e
399
+ # Do not rewrite application/lifecycle failures into provider text.
400
+ raise e.original
296
401
  rescue StandardError => e
297
402
  Rails.logger.error("[CollavreOpenclaw] HTTP chat error: #{e.message}\n" \
298
403
  "#{e.backtrace.first(5).join("\n")}")
299
404
  error_msg = "OpenClaw Error: #{e.message}"
300
405
  yield error_msg if block_given?
406
+ # Not `response_content.blank?`: this may be the fallback behind a
407
+ # WebSocket that already streamed, and that content is in the caller's
408
+ # hands even though this method's buffer is empty.
409
+ @last_handoff_failed = !@handed_off
301
410
  nil
302
411
  end
303
412
  end
@@ -443,7 +552,13 @@ module CollavreOpenclaw
443
552
  req.options.on_data = proc do |chunk, _size, env|
444
553
  if env&.status.nil? || (env.status >= 200 && env.status < 300)
445
554
  buffer << chunk
555
+ @handed_off = true if env&.response_headers&.[]("content-type")&.include?("application/json")
446
556
  process_sse_buffer(buffer, &block)
557
+ # A peer can keep the socket active indefinitely by sending an
558
+ # event without its terminating blank line. Completed events are
559
+ # checked inside the parser after handoff classification; poll
560
+ # here only when an unterminated fragment remains.
561
+ @lifecycle_check&.call if buffer.present?
447
562
  else
448
563
  buffer << chunk
449
564
  end
@@ -455,9 +570,15 @@ module CollavreOpenclaw
455
570
  raise parse_error_message(response.status, error_body)
456
571
  end
457
572
 
573
+ json_response = response.headers["content-type"]&.include?("application/json")
574
+ # A completed successful JSON response proves the gateway accepted the
575
+ # payload just as an SSE data event does. Record that before the final
576
+ # lifecycle check, which may abort parsing because the task became
577
+ # terminal while this response was in flight.
578
+ @handed_off = true if json_response
458
579
  process_sse_buffer(buffer, final: true, &block)
459
580
 
460
- if response.headers["content-type"]&.include?("application/json")
581
+ if json_response
461
582
  handle_json_response(response.body, &block)
462
583
  end
463
584
 
@@ -465,16 +586,20 @@ module CollavreOpenclaw
465
586
  rescue Faraday::TimeoutError
466
587
  retries += 1
467
588
  if retries <= max_retries
589
+ @lifecycle_check&.call
468
590
  Rails.logger.warn("[CollavreOpenclaw] Timed out, retrying (#{retries}/#{max_retries})...")
469
591
  sleep(1 * retries)
592
+ @lifecycle_check&.call
470
593
  retry
471
594
  end
472
595
  raise "OpenClaw request timed out after #{max_retries + 1} attempts"
473
596
  rescue Faraday::ConnectionFailed => e
474
597
  retries += 1
475
598
  if retries <= max_retries
599
+ @lifecycle_check&.call
476
600
  Rails.logger.warn("[CollavreOpenclaw] Connection failed, retrying (#{retries}/#{max_retries})...")
477
601
  sleep(1 * retries)
602
+ @lifecycle_check&.call
478
603
  retry
479
604
  end
480
605
  raise "Failed to connect to OpenClaw after #{max_retries + 1} attempts: #{e.message}"
@@ -510,16 +635,60 @@ module CollavreOpenclaw
510
635
 
511
636
  def process_sse_buffer(buffer, final: false, &block)
512
637
  while (idx = buffer.index("\n\n"))
638
+ # The HTTP transport has no event loop to poll from; this parser is
639
+ # its one checkpoint that runs for every received chunk, including
640
+ # tool/comment events that never yield text to the caller's block.
513
641
  event_data = buffer.slice!(0, idx + 2)
642
+ # Record delivery first for application data: receiving a successful
643
+ # data event proves the gateway accepted the payload and may already
644
+ # have run tools, even when it contains no caller-visible text. SSE
645
+ # comments are transport keepalives, so they cross the lifecycle check
646
+ # without claiming the agent received anything.
647
+ @handed_off = true if sse_data_event?(event_data)
648
+ @lifecycle_check&.call
514
649
  parse_sse_event(event_data, &block)
515
650
  end
516
651
 
517
652
  if final && buffer.present?
653
+ @handed_off = true if sse_data_event?(buffer)
654
+ @lifecycle_check&.call
518
655
  parse_sse_event(buffer, &block)
519
656
  buffer.clear
520
657
  end
521
658
  end
522
659
 
660
+ def wrap_lifecycle_check(check)
661
+ return nil unless check
662
+
663
+ lambda do |*args|
664
+ check.call(*args)
665
+ rescue Collavre::CancelledError
666
+ raise
667
+ rescue StandardError => e
668
+ raise LifecycleCheckError.new(e)
669
+ end
670
+ end
671
+
672
+ # Exceptions raised by the service's streaming callback are application
673
+ # control flow, not transport failures. Wrap them so the adapter's rescue
674
+ # chain cannot start an HTTP fallback after WebSocket handoff.
675
+ def emit_to_caller(value)
676
+ return unless block_given?
677
+
678
+ yield value
679
+ rescue Collavre::CancelledError
680
+ raise
681
+ rescue StandardError => e
682
+ raise LifecycleCheckError.new(e)
683
+ end
684
+
685
+ def sse_data_event?(event_str)
686
+ event_str.each_line.any? do |line|
687
+ stripped = line.strip
688
+ stripped.start_with?("data:") && stripped.delete_prefix("data:").strip.present?
689
+ end
690
+ end
691
+
523
692
  def parse_sse_event(event_str, &block)
524
693
  event_str.each_line do |line|
525
694
  line = line.strip
@@ -549,8 +718,15 @@ module CollavreOpenclaw
549
718
 
550
719
  def build_connection
551
720
  Faraday.new do |builder|
552
- builder.options.timeout = CollavreOpenclaw.config.read_timeout
553
- builder.options.open_timeout = CollavreOpenclaw.config.open_timeout
721
+ remaining_deadline = @request_timeout_seconds&.call
722
+ builder.options.timeout = [
723
+ CollavreOpenclaw.config.read_timeout,
724
+ remaining_deadline
725
+ ].compact.min
726
+ builder.options.open_timeout = [
727
+ CollavreOpenclaw.config.open_timeout,
728
+ remaining_deadline
729
+ ].compact.min
554
730
  builder.adapter Faraday.default_adapter
555
731
  end
556
732
  end
@@ -158,9 +158,14 @@ module CollavreOpenclaw
158
158
  # streaming, so callers can persist it as a cross-process idempotency key.
159
159
  # @yield [Hash] chat events with :state, :text, :message keys
160
160
  # @return [String, nil] final response text
161
- def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, on_run_id: nil, &block)
161
+ def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, on_run_id: nil,
162
+ lifecycle_check: nil, &block)
162
163
  ensure_connected!
163
164
  touch_activity!
165
+ # Connection establishment can block after the service-level preflight.
166
+ # Recheck before registering queues or scheduling chat.send so a Stop
167
+ # that won during the handshake cannot start remote tool side effects.
168
+ force_lifecycle_check(lifecycle_check) if lifecycle_check
164
169
 
165
170
  idempotency_key ||= SecureRandom.uuid
166
171
  actual_run_id = nil
@@ -186,34 +191,51 @@ module CollavreOpenclaw
186
191
  }
187
192
  rpc_params[:attachments] = attachments if attachments.present?
188
193
 
189
- response = send_rpc("chat.send", rpc_params, request_id: rpc_request_id)
190
-
191
- # The EM thread already registered @pending_runs[actual_run_id] in
192
- # handle_response. Clean up the idempotency_key entry if a different
193
- # runId was assigned.
194
- actual_run_id = response&.dig(:runId) || idempotency_key
195
- if actual_run_id != idempotency_key
196
- @mutex.synchronize do
197
- @pending_runs.delete(idempotency_key)
198
- # Ensure runId is registered (may already be from handle_response)
199
- @pending_runs[actual_run_id] ||= run_queue
194
+ acknowledge_response = lambda do |response|
195
+ # The EM thread already registered @pending_runs[actual_run_id] in
196
+ # handle_response. Clean up the idempotency_key entry if a different
197
+ # runId was assigned.
198
+ actual_run_id = response&.dig(:runId) || idempotency_key
199
+ if actual_run_id != idempotency_key
200
+ @mutex.synchronize do
201
+ @pending_runs.delete(idempotency_key)
202
+ # Ensure runId is registered (may already be from handle_response)
203
+ @pending_runs[actual_run_id] ||= run_queue
204
+ end
200
205
  end
201
- end
202
206
 
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}")
207
+ # Surface runId before streaming; guard so a faulty callback can't abort the stream.
208
+ if on_run_id
209
+ begin
210
+ on_run_id.call(actual_run_id)
211
+ rescue StandardError => e
212
+ Rails.logger.warn("[CollavreOpenclaw::WS] on_run_id callback failed: #{e.message}")
213
+ end
209
214
  end
210
215
  end
216
+ response = send_rpc(
217
+ "chat.send",
218
+ rpc_params,
219
+ request_id: rpc_request_id,
220
+ lifecycle_check: lifecycle_check,
221
+ &acknowledge_response
222
+ )
223
+ # Compatibility with test/custom send_rpc replacements that return a
224
+ # response but do not invoke the acknowledgement callback.
225
+ acknowledge_response.call(response) unless actual_run_id
211
226
 
212
227
  # Stream events until final/error/aborted
213
228
  last_seq = nil
214
229
 
215
230
  loop do
216
- event = wait_with_timeout(run_queue, config.read_timeout, "chat response")
231
+ # The caller's block fires only on text deltas, and a run doing its
232
+ # tool work on the gateway can emit none for the whole turn. This is
233
+ # the checkpoint that does not depend on text: the wait below polls
234
+ # once before every event and once per idle slice, so both a flood of
235
+ # non-text events and total silence cross it. A raise unwinds through
236
+ # the adapter's CancelledError re-raise instead of holding this worker
237
+ # until read_timeout.
238
+ event = wait_for_chat_event(run_queue, config.read_timeout, lifecycle_check)
217
239
 
218
240
  break if event[:done]
219
241
 
@@ -268,6 +290,7 @@ module CollavreOpenclaw
268
290
  @pending_runs.delete(actual_run_id) if actual_run_id
269
291
  # Also clean up idempotency_key if send_rpc failed before we got a runId
270
292
  @pending_runs.delete(idempotency_key) if idempotency_key
293
+ @rpc_run_registrations.delete(rpc_request_id) if rpc_request_id
271
294
 
272
295
  # Record completed runs so late-arriving events are suppressed
273
296
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -599,7 +622,11 @@ module CollavreOpenclaw
599
622
  # @param request_id [String, nil] Pre-generated request ID. Used by chat_send
600
623
  # to correlate the RPC response with the run_queue for EM-thread runId
601
624
  # registration (see handle_response). If nil, a random UUID is generated.
602
- def send_rpc(method, params, request_id: nil)
625
+ # @param lifecycle_check [#call, nil] Optional cooperative cancellation
626
+ # check polled while waiting for an RPC response.
627
+ # @yield [Hash] successful response payload before a deferred lifecycle
628
+ # error is re-raised, allowing chat.send to persist its acknowledged runId.
629
+ def send_rpc(method, params, request_id: nil, lifecycle_check: nil, &on_response)
603
630
  request_id ||= SecureRandom.uuid
604
631
  queue = Queue.new
605
632
 
@@ -616,13 +643,25 @@ module CollavreOpenclaw
616
643
  })
617
644
  end
618
645
 
619
- result = wait_with_timeout(queue, config.read_timeout, method)
646
+ result = if lifecycle_check
647
+ wait_for_rpc_response(queue, config.read_timeout, method, lifecycle_check)
648
+ else
649
+ wait_with_timeout(queue, config.read_timeout, method)
650
+ end
620
651
  if result[:error]
621
652
  raise RpcError, "#{method} failed: #{result[:error]}"
622
653
  end
623
- result[:payload]
654
+
655
+ payload = result[:payload]
656
+ on_response&.call(payload)
657
+ raise result[:deferred_lifecycle_error] if result[:deferred_lifecycle_error]
658
+
659
+ payload
624
660
  ensure
625
- @mutex.synchronize { @pending_requests.delete(request_id) }
661
+ @mutex.synchronize do
662
+ @pending_requests.delete(request_id)
663
+ @rpc_run_registrations.delete(request_id)
664
+ end
626
665
  end
627
666
 
628
667
  def send_frame(frame)
@@ -697,6 +736,102 @@ module CollavreOpenclaw
697
736
  @user.email.split("@").first
698
737
  end
699
738
 
739
+ # Seconds between lifecycle_check invocations while waiting on a silent
740
+ # gateway. Matches AgentLifecycleManager::CANCEL_CHECK_INTERVAL — the
741
+ # check self-throttles at that interval, so polling faster buys nothing.
742
+ LIFECYCLE_POLL_INTERVAL = 1.0
743
+
744
+ # Wait for the next chat event. Without a lifecycle_check this is one
745
+ # blocking pop bounded by read_timeout, exactly as before. With one, the
746
+ # same total bound is kept but the wait is sliced so the check runs
747
+ # between slices — a run that goes quiet doing gateway-side tool work is
748
+ # otherwise unobservable until read_timeout (30 minutes by default).
749
+ def wait_for_chat_event(queue, timeout_seconds, lifecycle_check)
750
+ return wait_with_timeout(queue, timeout_seconds, "chat response") unless lifecycle_check
751
+
752
+ event = wait_with_lifecycle_poll(queue, timeout_seconds, "chat response", lifecycle_check)
753
+ force_lifecycle_check(lifecycle_check) if terminal_chat_event?(event)
754
+ event
755
+ end
756
+
757
+ # A chat.send acknowledgement is the provider handoff record: handle_response
758
+ # registers the Gateway runId before queueing it. If cancellation becomes
759
+ # visible at the same time, consume that acknowledgement first so chat_send
760
+ # can surface the runId and clean up its registration. The following chat
761
+ # event wait will still observe cancellation before consuming any run event.
762
+ def wait_for_rpc_response(queue, timeout_seconds, operation, lifecycle_check)
763
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
764
+ loop do
765
+ result = pop_queue_nonblock(queue)
766
+ if result
767
+ force_lifecycle_check(lifecycle_check) unless result[:ok]
768
+ return result
769
+ end
770
+
771
+ begin
772
+ lifecycle_check.call
773
+ rescue StandardError => error
774
+ # Close the narrow race where the response arrived while the
775
+ # lifecycle check was reading terminal state from the database. This
776
+ # includes wrapped lifecycle infrastructure failures as well as
777
+ # cancellation. Only a successful response proves handoff; an RPC
778
+ # error must not replace the lifecycle failure that just won.
779
+ result = pop_queue_nonblock(queue)
780
+ if result&.dig(:ok)
781
+ result[:deferred_lifecycle_error] = error
782
+ return result
783
+ end
784
+ raise
785
+ end
786
+
787
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
788
+ raise TimeoutError, "#{operation} timed out after #{timeout_seconds}s" if remaining <= 0
789
+
790
+ result = queue.pop(timeout: [ LIFECYCLE_POLL_INTERVAL, remaining ].min)
791
+ if result
792
+ force_lifecycle_check(lifecycle_check) unless result[:ok]
793
+ return result
794
+ end
795
+ end
796
+ end
797
+
798
+ def wait_with_lifecycle_poll(queue, timeout_seconds, operation, lifecycle_check)
799
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
800
+ loop do
801
+ lifecycle_check.call
802
+
803
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
804
+ raise TimeoutError, "#{operation} timed out after #{timeout_seconds}s" if remaining <= 0
805
+
806
+ event = queue.pop(timeout: [ LIFECYCLE_POLL_INTERVAL, remaining ].min)
807
+ return event if event
808
+ end
809
+ end
810
+
811
+ def terminal_chat_event?(event)
812
+ event[:done] || %w[final error aborted].include?(event[:state])
813
+ end
814
+
815
+ # Production lifecycle checks accept a force flag that bypasses their
816
+ # one-second database polling throttle. Keep zero-argument test/custom
817
+ # callables compatible while still checking at a terminal event boundary.
818
+ def force_lifecycle_check(lifecycle_check)
819
+ parameters = if lifecycle_check.respond_to?(:parameters)
820
+ lifecycle_check.parameters
821
+ else
822
+ lifecycle_check.method(:call).parameters
823
+ end
824
+ accepts_argument = parameters.any? { |type, _name| %i[req opt rest].include?(type) }
825
+
826
+ accepts_argument ? lifecycle_check.call(true) : lifecycle_check.call
827
+ end
828
+
829
+ def pop_queue_nonblock(queue)
830
+ queue.pop(true)
831
+ rescue ThreadError
832
+ nil
833
+ end
834
+
700
835
  def wait_with_timeout(queue, timeout_seconds, operation)
701
836
  # Use Queue#pop(timeout:) instead of Timeout.timeout to avoid Thread.raise corruption
702
837
  result = queue.pop(timeout: timeout_seconds)
@@ -11,5 +11,16 @@ Rails.application.config.to_prepare do
11
11
  Collavre::AiClient.register_adapter("openclaw", CollavreOpenclaw::OpenclawAdapter)
12
12
  Rails.logger.info("[CollavreOpenclaw] Registered OpenClaw adapter")
13
13
  end
14
+
15
+ # OpenClaw uses stateful, incremental sessions and appears in the AI-agent
16
+ # vendor dropdown. Register both into core so core needs no OpenClaw name of
17
+ # its own to decide session support or list the vendor. Both registrations
18
+ # are idempotent, so re-running on reload is safe.
19
+ if Collavre::AiClient.respond_to?(:register_session_vendor)
20
+ Collavre::AiClient.register_session_vendor("openclaw")
21
+ end
22
+ if Collavre::AiClient.respond_to?(:register_vendor_option)
23
+ Collavre::AiClient.register_vendor_option("OpenClaw", "openclaw")
24
+ end
14
25
  end
15
26
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An OpenClaw gateway error banner marks a task result the agent never actually
4
+ # produced, so the trigger loop must retry it without consuming an iteration.
5
+ # Register the OpenClaw-specific signature into the core trigger-loop infra-error
6
+ # registry so core keeps only vendor-neutral patterns. Runs on boot and every
7
+ # reload; registration is idempotent.
8
+ Rails.application.config.to_prepare do
9
+ if defined?(Collavre::TriggerLoopCheckJob)
10
+ Collavre::TriggerLoopCheckJob.register_infrastructure_error_pattern(/OpenClaw Error/i)
11
+ end
12
+ end
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.6.4"
2
+ VERSION = "0.6.6"
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.4
4
+ version: 0.6.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -92,6 +92,7 @@ files:
92
92
  - config/initializers/ai_client_extension.rb
93
93
  - config/initializers/integration_settings.rb
94
94
  - config/initializers/session_abort.rb
95
+ - config/initializers/trigger_loop_patterns.rb
95
96
  - config/locales/en.yml
96
97
  - config/locales/ko.yml
97
98
  - config/routes.rb