claude-agent-sdk 1.1.0 → 1.2.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +90 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +29 -11
  7. data/docs/configuration.md +164 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +84 -27
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/query.rb +228 -77
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +227 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  37. data/lib/claude_agent_sdk/types/options.rb +35 -5
  38. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  39. data/lib/claude_agent_sdk/version.rb +1 -1
  40. data/lib/claude_agent_sdk.rb +94 -46
  41. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  42. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  43. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  44. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  45. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  46. metadata +6 -4
@@ -136,14 +136,16 @@ module ClaudeAgentSDK
136
136
  end
137
137
  private_class_method :parse_streamed_message
138
138
 
139
- # Waiter for control responses awaited OFF the reactor — i.e. a control
140
- # method called from inside a hook/can_use_tool/SDK-MCP callback, which
141
- # runs on a FiberBoundary worker thread (Python supports this reentrancy
142
- # natively: callbacks are event-loop tasks and anyio.Event is
143
- # level-triggered). Duck-types Async::Condition#signal for the read
144
- # loop's signal sites; the unconditional token push makes it
145
- # level-triggered, closing the check-then-wait gap that an
146
- # edge-triggered Condition would lose across threads.
139
+ # Waiter for control responses awaited OFF the reactor that owns the
140
+ # Query — a control method called from inside a hook/can_use_tool/SDK-MCP
141
+ # callback, which runs on a FiberBoundary worker thread (Python supports
142
+ # this reentrancy natively: callbacks are event-loop tasks and
143
+ # anyio.Event is level-triggered), from any other plain thread, or from a
144
+ # fiber on another thread's reactor (see #send_control_request for the
145
+ # choice). Duck-types Async::Condition#signal for the read loop's signal
146
+ # sites; the unconditional token push makes it level-triggered, closing
147
+ # the check-then-wait gap that an edge-triggered Condition would lose
148
+ # across threads.
147
149
  class ThreadWaiter
148
150
  def initialize
149
151
  @queue = ::Queue.new
@@ -193,6 +195,8 @@ module ClaudeAgentSDK
193
195
 
194
196
  # Message stream
195
197
  @message_queue = Async::Queue.new
198
+ # Set once #receive_messages has consumed the read loop's end sentinel.
199
+ @stream_ended = false
196
200
  # Ends when the run is over, so the stdin-closing waiter can wake; see
197
201
  # #read_messages and @inflight_tasks below (Python #1088, #1190/#1279).
198
202
  # Work the CLI takes up after the run ended swaps in a fresh one
@@ -287,6 +291,10 @@ module ClaudeAgentSDK
287
291
  agents_dict = nil
288
292
  if @agents
289
293
  agents_dict = @agents.transform_values do |agent_def|
294
+ # A Hash stands for the AgentDefinition with the same attributes
295
+ # (the options signature allows either). Built through .new: the
296
+ # user wrote it, so a misspelled key raises as on the typed class.
297
+ agent_def = AgentDefinition.new(agent_def) if agent_def.is_a?(Hash)
290
298
  {
291
299
  description: agent_def.description,
292
300
  prompt: agent_def.prompt,
@@ -365,18 +373,15 @@ module ClaudeAgentSDK
365
373
  # Reactor-side agent for #close calls arriving from foreign threads
366
374
  # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
367
375
  # the owning thread's Fiber.scheduler, so the off-thread caller hands the
368
- # whole close over and waits. Transient: must never keep the reactor
369
- # alive, and is stopped automatically when the parent task finishes.
370
- # One-shot: after serving a close it is done; a reactor-side close wakes
371
- # it via @close_requests.close (pop -> nil) so it exits without serving.
376
+ # whole close over and waits. Transient: while it waits for a request it
377
+ # must never keep the reactor alive, and it is stopped automatically
378
+ # once the reactor has nothing else to run. One-shot: it passes the
379
+ # first request on to a task of its own (#serve_marshalled_close) and
380
+ # is done; a reactor-side close wakes it via @close_requests.close
381
+ # (pop -> nil) so it exits without serving.
372
382
  @close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do
373
- if (reply = @close_requests.pop)
374
- begin
375
- close
376
- ensure
377
- reply << true
378
- end
379
- end
383
+ reply = @close_requests.pop
384
+ serve_marshalled_close(reply) if reply
380
385
  end)
381
386
  end
382
387
 
@@ -669,9 +674,16 @@ module ClaudeAgentSDK
669
674
  end
670
675
 
671
676
  # The run is over: wake the stdin-closing waiter. Idempotent.
677
+ #
678
+ # The run is marked ended BEFORE the ceiling is cleared: stopping the
679
+ # sleeper task yields to whatever else is ready, and a #stream_input task
680
+ # that writes its next message in that gap must find the run already
681
+ # ended, so that #reopen_run gives the message a run of its own. Cleared
682
+ # first, the message joined the run that was about to end, and stdin
683
+ # closed before the message's own run had produced a frame.
672
684
  def end_run
673
- clear_run_end_ceiling
674
685
  @run_end.end!
686
+ clear_run_end_ceiling
675
687
  end
676
688
 
677
689
  # Reopen an ended run for work that started after it ended. A waiter the
@@ -842,15 +854,42 @@ module ClaudeAgentSDK
842
854
  raise
843
855
  rescue StandardError => e
844
856
  send_control_error(request_id, e.message)
857
+ rescue *FiberBoundary::CALLBACK_FAILURES => e
858
+ # What is left of the list: a callback (or its callback_wrapper)
859
+ # failing outside StandardError — NotImplementedError, LoadError,
860
+ # SystemStackError, SecurityError. No `rescue StandardError` on the
861
+ # way here caught it, not even the one in #handle_sdk_mcp_request that
862
+ # turns a resource or prompt handler's failure into its JSON-RPC
863
+ # error, so the answer an ordinary failure gets is built here.
864
+ # Unanswered, it would also end this task with an exception Async
865
+ # treats as fatal for the whole reactor.
866
+ respond_to_callback_failure(request_id, request_data, e.message)
867
+ end
868
+
869
+ # Answers the request, then leaves the process-exit exception to its
870
+ # caller to re-raise: the response an ordinary exception from the
871
+ # callback would have produced, naming the exception by class.
872
+ #
873
+ # The text has the format of FiberBoundary.process_exit_message, built
874
+ # here from the normalized message instead of calling it: that method
875
+ # joins the class name to the raw message, which raises
876
+ # Encoding::CompatibilityError for an encoding that is not
877
+ # ASCII-compatible (UTF-16). Raised in the rescue clause this runs in,
878
+ # that error would leave the request unanswered and replace the exit.
879
+ # +error+ is only read, never changed.
880
+ def respond_to_process_exit(request_id, request_data, error)
881
+ detail = wire_text(error.message)
882
+ message = detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}"
883
+ respond_to_callback_failure(request_id, request_data, message)
845
884
  end
846
885
 
847
886
  # The response an ordinary exception from the callback would have
848
- # produced, with the process-exit exception named by class: an error
849
- # control response for hooks / can_use_tool; for SDK MCP requests an
850
- # in-band isError result (tools/call) or a JSON-RPC internal error
851
- # (resources/read, prompts/get), inside a successful control response.
852
- def respond_to_process_exit(request_id, request_data, error)
853
- message = FiberBoundary.process_exit_message(error)
887
+ # produced, with +message+ as its text: an error control response for
888
+ # hooks / can_use_tool; for SDK MCP requests an in-band isError result
889
+ # (tools/call) or a JSON-RPC internal error (resources/read,
890
+ # prompts/get), inside a successful control response.
891
+ def respond_to_callback_failure(request_id, request_data, message)
892
+ message = wire_text(message)
854
893
  mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
855
894
  return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
856
895
 
@@ -867,10 +906,19 @@ module ClaudeAgentSDK
867
906
  response: { mcp_response: mcp_response }
868
907
  }
869
908
  }))
909
+ rescue JSON::GeneratorError
910
+ # Not reachable through the text, which #wire_text made encodable.
911
+ # Kept because an exception leaving this method would take the place
912
+ # of the process exit the caller is about to re-raise.
913
+ send_control_error(request_id, message)
870
914
  rescue CLIConnectionError
871
915
  nil # the CLI is already gone; nothing is waiting for the answer
872
916
  end
873
917
 
918
+ # Called from the rescue clauses of #handle_control_request, where an
919
+ # exception raised while building the answer has no rescue left: the
920
+ # request would stay unanswered. So the text goes through #wire_text, and
921
+ # whatever JSON.generate still rejects is replaced by a fixed one.
874
922
  def send_control_error(request_id, message)
875
923
  error_response = {
876
924
  type: 'control_response',
@@ -878,10 +926,16 @@ module ClaudeAgentSDK
878
926
  subtype: 'error',
879
927
  request_id: request_id,
880
928
  requestId: request_id,
881
- error: message
929
+ error: wire_text(message)
882
930
  }
883
931
  }
884
- writeln(JSON.generate(error_response))
932
+ line = begin
933
+ JSON.generate(error_response)
934
+ rescue JSON::GeneratorError
935
+ error_response[:response][:error] = 'Control request failed; its error message could not be encoded as JSON'
936
+ JSON.generate(error_response)
937
+ end
938
+ writeln(line)
885
939
  rescue CLIConnectionError
886
940
  # EOF/close can invalidate a callback after the peer has gone away.
887
941
  # Only this best-effort reply is discarded; read errors still reach
@@ -889,6 +943,27 @@ module ClaudeAgentSDK
889
943
  nil
890
944
  end
891
945
 
946
+ # Error text as JSON.generate accepts it. An exception message can hold
947
+ # anything: a multibyte character cut by byteslice, the raw bytes of a
948
+ # subprocess or an HTTP body, a driver's own encoding. Valid UTF-8
949
+ # passes through. A UTF-8, BINARY or US-ASCII string is read as UTF-8,
950
+ # with U+FFFD in place of each byte that is not valid there. Any other
951
+ # encoding is transcoded, so valid text in it survives, with U+FFFD for
952
+ # what cannot be converted.
953
+ def wire_text(text)
954
+ text = text.to_s
955
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
956
+
957
+ if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
958
+ text.dup.force_encoding(Encoding::UTF_8).scrub
959
+ else
960
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
961
+ end
962
+ rescue EncodingError
963
+ # No converter for the declared encoding (a dummy one such as UTF-7).
964
+ text.dup.force_encoding(Encoding::UTF_8).scrub
965
+ end
966
+
892
967
  def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion
893
968
  raise 'canUseTool callback is not provided' unless @can_use_tool
894
969
 
@@ -1272,36 +1347,24 @@ module ClaudeAgentSDK
1272
1347
  { mcp_response: mcp_response }
1273
1348
  end
1274
1349
 
1275
- def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key
1276
- # Handle typed output objects
1277
- return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
1278
-
1350
+ # What a hook callback returned, as the object the CLI reads. A typed
1351
+ # output (or anything else with #to_h) contributes its own Hash; a typed
1352
+ # value inside a Hash, such as a *HookSpecificOutput under
1353
+ # hook_specific_output, does the same. The keys of the result are then
1354
+ # normalized, so a Hash written in snake_case or with String keys means
1355
+ # what the typed output means (HookOutputKeys) — and so does the Hash a
1356
+ # typed output carried through as its hook_specific_output.
1357
+ def convert_hook_output_for_cli(hook_output)
1358
+ if hook_output.is_a?(Hash)
1359
+ hook_output = hook_output.transform_values do |value|
1360
+ value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value
1361
+ end
1362
+ elsif hook_output.respond_to?(:to_h)
1363
+ hook_output = hook_output.to_h
1364
+ end
1279
1365
  return {} unless hook_output.is_a?(Hash)
1280
1366
 
1281
- # Convert Ruby hash with symbol keys to CLI format
1282
- # Handle special keywords that might be Ruby-safe versions
1283
- converted = {}
1284
- hook_output.each do |key, value|
1285
- converted_key = case key
1286
- when :async_, 'async_' then 'async'
1287
- when :continue_, 'continue_' then 'continue'
1288
- when :hook_specific_output then 'hookSpecificOutput'
1289
- when :suppress_output then 'suppressOutput'
1290
- when :stop_reason then 'stopReason'
1291
- when :system_message then 'systemMessage'
1292
- when :async_timeout then 'asyncTimeout'
1293
- else key.to_s
1294
- end
1295
-
1296
- # Recursively convert nested objects
1297
- converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash)
1298
- value.to_h
1299
- else
1300
- value
1301
- end
1302
- converted[converted_key] = converted_value
1303
- end
1304
- converted
1367
+ HookOutputKeys.normalize(hook_output)
1305
1368
  end
1306
1369
 
1307
1370
  def send_control_request(request)
@@ -1316,10 +1379,25 @@ module ClaudeAgentSDK
1316
1379
  # RuntimeError; the eventual response dropped by the key? guard).
1317
1380
  task = Async::Task.current?
1318
1381
 
1319
- # Reactor callers wait on an Async::Condition; worker-thread callers
1320
- # on a ThreadWaiter. Register atomically with the terminal-state check
1321
- # so EOF cannot strand a sender that missed the final broadcast.
1322
- waiter = task ? Async::Condition.new : ThreadWaiter.new
1382
+ # The waiter is chosen by reactor OWNERSHIP, not by "has a task". Only
1383
+ # a fiber of the reactor that owns this Query (the one #start ran on,
1384
+ # whose read loop does the signaling) checks its result slot and parks
1385
+ # with no chance for the read loop to run in between, so only there is
1386
+ # the edge-triggered Async::Condition safe. Every other caller races
1387
+ # the read loop between its check and its park and gets the
1388
+ # level-triggered ThreadWaiter: a FiberBoundary worker thread, a plain
1389
+ # thread, and also a fiber on ANOTHER thread's reactor (`Sync {
1390
+ # client.interrupt }` on a request thread while the session lives on a
1391
+ # background reactor; a Sync block inside a :thread-mode callback).
1392
+ # Given a Condition, such a fiber could lose its wakeup on async >=
1393
+ # 2.29, and on async 2.10-2.28 the cross-thread signal raised
1394
+ # FiberError in the read loop, ending the whole session. The deadline
1395
+ # mechanism (#await_control_response) is still chosen by "has a task".
1396
+ #
1397
+ # Register atomically with the terminal-state check so EOF cannot
1398
+ # strand a sender that missed the final broadcast.
1399
+ on_owning_reactor = task && Fiber.scheduler.equal?(@owning_scheduler)
1400
+ waiter = on_owning_reactor ? Async::Condition.new : ThreadWaiter.new
1323
1401
  request_id = @request_counter_mutex.synchronize do
1324
1402
  raise @control_stream_error if @control_stream_error
1325
1403
 
@@ -1369,7 +1447,12 @@ module ClaudeAgentSDK
1369
1447
  # only this deadline is translated, not an outer task's cancellation.
1370
1448
  FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do
1371
1449
  yield
1372
- waiter.wait until @pending_control_results.key?(request_id)
1450
+ # A ThreadWaiter here belongs to a fiber on a reactor that does not
1451
+ # own this Query: Thread::Queue#pop parks that fiber through its
1452
+ # own scheduler, and the deadline cancels it like any suspension.
1453
+ until @pending_control_results.key?(request_id)
1454
+ waiter.is_a?(ThreadWaiter) ? waiter.wait(nil) : waiter.wait
1455
+ end
1373
1456
  end
1374
1457
  else
1375
1458
  # Only schedulerless callers use stdlib Timeout. A fresh, private
@@ -1717,10 +1800,21 @@ module ClaudeAgentSDK
1717
1800
  # open until the run ends so hooks/SDK MCP control replies can still
1718
1801
  # be written (the run's end or process exit is guaranteed to signal).
1719
1802
  # - No complete message ever reached the CLI (empty stream, or the
1720
- # stream raised before the first write): no result can ever arrive,
1721
- # so waiting would park query() forever beside an idle CLI. Close
1722
- # stdin so the CLI sees EOF and exits. Deliberate improvement over
1723
- # Python, which leaves stdin open and hangs on this path.
1803
+ # stream raised before the first write): no result is owed, so there
1804
+ # is nothing to wait for. Close stdin at once so the CLI sees EOF and
1805
+ # exits, as the Python and TypeScript SDKs do.
1806
+ # This is a trade-off, not a free win. An empty stream is also how a
1807
+ # caller says "send nothing, just resume", and a resumed session can
1808
+ # have work of its own: a tool call that a PreToolUse hook deferred
1809
+ # is re-run by the CLI on resume. If that tool is served by an SDK
1810
+ # MCP server, the CLI's request for it finds stdin already closed and
1811
+ # the CLI exits with an error (ProcessError, exit code 1;
1812
+ # anthropics/claude-agent-sdk-python#1226). Waiting instead would
1813
+ # hang every empty resume that has nothing pending: a CLI that is
1814
+ # idle with no input reports no session state (seen with 2.1.286),
1815
+ # so the two cases cannot be told apart without a signal from the
1816
+ # CLI. Until there is one, resume a deferred tool through Client,
1817
+ # which keeps stdin open.
1724
1818
  unless @closed
1725
1819
  if wrote_message
1726
1820
  wait_for_result_and_end_input
@@ -1748,8 +1842,21 @@ module ClaudeAgentSDK
1748
1842
  # reception — ResultMessage dropped, the query reported as complete, and
1749
1843
  # on_error never fired. `while` propagates it like any other error.
1750
1844
  while true # rubocop:disable Style/InfiniteLoop
1845
+ # End of stream is sticky. The read loop enqueues ONE sentinel and is
1846
+ # gone, so once a receive has consumed it, every later one must end
1847
+ # at once rather than wait on a queue nothing writes to any more
1848
+ # (Python closes the send side of its stream, so later iterations
1849
+ # end at once there too). The end is remembered, not put back on the
1850
+ # queue: a receive can be made after the reactor has finished, and
1851
+ # async < 2.29 cannot enqueue outside a task. Anything still queued,
1852
+ # such as a mirror error reported after the end, is delivered first.
1853
+ break if @stream_ended && @message_queue.empty?
1854
+
1751
1855
  message = @message_queue.dequeue
1752
- break if message[:type] == 'end'
1856
+ if message[:type] == 'end'
1857
+ @stream_ended = true
1858
+ break
1859
+ end
1753
1860
  raise message[:error] if message[:type] == 'error'
1754
1861
 
1755
1862
  block.call(message)
@@ -1764,14 +1871,18 @@ module ClaudeAgentSDK
1764
1871
  # plain user threads — don't have; stopping from one raised NoMethodError
1765
1872
  # and left the read/child tasks running. Such callers hand the close to
1766
1873
  # the reactor-side watcher (spawned in #start) and wait for it to finish,
1767
- # so close semantics are identical regardless of the calling thread.
1874
+ # so close semantics are identical regardless of the calling thread. The
1875
+ # reactor waits for a close it was handed: it stays alive until the
1876
+ # transport's teardown is done, even when the session's own task has
1877
+ # nothing left to do.
1768
1878
  def close
1769
1879
  if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
1770
1880
  marshal_close_to_reactor
1771
1881
  else
1772
- # Same scheduler (reactor-side caller, including the watcher itself),
1773
- # or no live watcher: when the reactor is gone its task fibers are
1774
- # dead, so stopping them no longer touches Fiber.scheduler.
1882
+ # Same scheduler (reactor-side caller, including the task serving a
1883
+ # marshalled close), or no live reactor-side task to hand it to: when
1884
+ # the reactor is gone its task fibers are dead, so stopping them no
1885
+ # longer touches Fiber.scheduler.
1775
1886
  close_now
1776
1887
  end
1777
1888
  end
@@ -1852,8 +1963,8 @@ module ClaudeAgentSDK
1852
1963
  # surfaces as Async::Stop (Python parity: a hook that awaits
1853
1964
  # disconnect() gets CancelledError). Applied ONLY inside the trees of
1854
1965
  # the tasks being stopped: the reactor-side caller (Client#disconnect
1855
- # from the connect task, the close watcher) and foreign threads keep
1856
- # the plain path, unchanged.
1966
+ # from the connect task, the task serving a marshalled close) and
1967
+ # foreign threads keep the plain path, unchanged.
1857
1968
  #
1858
1969
  # The deferred Stop SUPERSEDES anything the teardown raises: async
1859
1970
  # raises it from defer_stop's ensure with an explicit `cause:`, so a
@@ -1929,11 +2040,51 @@ module ClaudeAgentSDK
1929
2040
  nil
1930
2041
  end
1931
2042
 
1932
- # Hand the close to the reactor and wait for completion. Polls watcher
1933
- # liveness instead of waiting forever: if the reactor shuts down
1934
- # concurrently (the transient watcher is stopped without serving the
1935
- # request), no reply will ever arrive — fall back to a direct close,
1936
- # which is safe once the reactor's fibers are dead.
2043
+ # Reactor side of a marshalled close; runs on the close watcher, which
2044
+ # must not run the close itself. The watcher is transient, and a reactor
2045
+ # does not wait for transient tasks: stopping the read task releases the
2046
+ # session's own task (the end sentinel), and if that was the last
2047
+ # non-transient task the reactor winds down and stops the watcher at the
2048
+ # first suspension point of the transport teardown. SubprocessCLITransport
2049
+ # then TERMs the CLI instead of closing stdin and granting the grace
2050
+ # period that lets it finish its last session write, and the caller is
2051
+ # released before the child is reaped.
2052
+ #
2053
+ # So the close runs in a task of its own that the reactor does wait for
2054
+ # (bounded by the transport's teardown). `defer_stop` on the watcher is
2055
+ # not enough: async 2.10 terminates the tasks of a finished reactor with
2056
+ # repeated stops, and a deferral survives only the first.
2057
+ #
2058
+ # The task is a SIBLING of the watcher, created under the watcher's
2059
+ # current parent. A reactor counts only its direct children, so a child
2060
+ # of the transient watcher would not hold it open — and that is what
2061
+ # `parent.async` creates once the task that started the session is gone
2062
+ # and async has re-parented the watcher to the reactor (Scheduler#async
2063
+ # adopts the current task as the parent).
2064
+ #
2065
+ # @close_watcher follows the close: the waiting caller polls it for
2066
+ # liveness (#marshal_close_to_reactor), so it has to name the task that
2067
+ # will answer. Switched before the new task can suspend, while the
2068
+ # watcher that spawned it is still alive.
2069
+ def serve_marshalled_close(reply)
2070
+ body = FiberBoundary.capture_otel_context do |task|
2071
+ @close_watcher = task
2072
+ begin
2073
+ close
2074
+ ensure
2075
+ reply << true
2076
+ end
2077
+ end
2078
+ Async::Task.new(Async::Task.current.parent, &body).run
2079
+ end
2080
+
2081
+ # Hand the close to the reactor and wait for completion. Polls the
2082
+ # liveness of the reactor-side task that is to answer (@close_watcher:
2083
+ # the idle watcher, then the task serving the close) instead of waiting
2084
+ # forever: if the reactor shuts down concurrently (the transient watcher
2085
+ # is stopped without serving the request), no reply will ever arrive —
2086
+ # fall back to a direct close, which is safe once the reactor's fibers
2087
+ # are dead.
1937
2088
  def marshal_close_to_reactor
1938
2089
  reply = ::Thread::Queue.new
1939
2090
  @close_requests << reply
@@ -57,11 +57,18 @@ module ClaudeAgentSDK
57
57
  # the interlock keeps a reload from unloading code under it until the
58
58
  # whole SDK call returns.
59
59
  # 3. Otherwise (production: no reloading, concurrency allowed) — run the
60
- # callback inside `Rails.application.executor.wrap`.
60
+ # callback inside the executor (see .run_in_executor).
61
61
  #
62
62
  # The configuration is read on every call, so the same wrapper is correct
63
63
  # in every environment.
64
64
  #
65
+ # The wrapper reports nothing to `Rails.error`, in any branch. An exception
66
+ # that escapes a callback reaches the code that called the SDK, whose own
67
+ # layer (the request middleware, ActiveJob) reports it with its context;
68
+ # the failures the SDK handles itself — a hook or tool error answered to
69
+ # the CLI, a swallowed observer error, a cancellation — are not errors of
70
+ # the application.
71
+ #
65
72
  # @return [Proc] a callable suitable for `callback_wrapper:`
66
73
  # @example config/initializers/claude_agent_sdk.rb
67
74
  # ClaudeAgentSDK.configure do |config|
@@ -71,7 +78,7 @@ module ClaudeAgentSDK
71
78
  lambda do |invocation|
72
79
  app = ::Rails.application
73
80
  next invocation.call if app.nil? || app.executor.active?
74
- next app.executor.wrap { invocation.call } unless executor_locks?(app.config)
81
+ next run_in_executor(app.executor, invocation) unless executor_locks?(app.config)
75
82
 
76
83
  begin
77
84
  invocation.call
@@ -81,6 +88,24 @@ module ClaudeAgentSDK
81
88
  end
82
89
  end
83
90
 
91
+ # `executor.wrap { invocation.call }` minus its error report. `wrap`
92
+ # rescues what passes through it (every Exception on Rails 8.1, 8.0.2+
93
+ # and 7.2.3+; StandardError before) and reports it to `Rails.error` as
94
+ # unhandled, from a callback thread that has none of the caller's
95
+ # context — and Rails then skips the same exception as already reported
96
+ # when it reaches the request or job. It reported the SDK's own
97
+ # cancellations and the callback failures the SDK answers or swallows,
98
+ # too. `run!` / `complete!` run the same hooks.
99
+ def self.run_in_executor(executor, invocation)
100
+ execution = executor.run!
101
+ begin
102
+ invocation.call
103
+ ensure
104
+ execution.complete!
105
+ end
106
+ end
107
+ private_class_method :run_in_executor
108
+
84
109
  # Whether railties registered a process-wide lock hook on the executor
85
110
  # (Rails::Application::Finisher, initializer
86
111
  # :configure_executor_for_concurrency).