claude-agent-sdk 0.27.0 → 0.28.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 760ec1fb0cf80f6e56d7b5d3adb20ca2956b209d0961020ae36fa52a13e28144
4
- data.tar.gz: 6ba365de4438e4081f9bfa47afadc4859d9a001bb6238c0b66f00437cb032626
3
+ metadata.gz: 5a12b68451568e5428820499fb4bf42401596477ea74d6b25a8fad3637b70178
4
+ data.tar.gz: d0465a73a3cbe17d3775d65d83978c16527548f9f752f8786b606f2c8c1c3713
5
5
  SHA512:
6
- metadata.gz: 672d6640b3bdefc6c5d9cf599c41b89b6259107f780981da382b18bfa56180788225d5e278ab9a7ef22e79a09a000b48147a1a285b74182b5f4254d8d8ec474e
7
- data.tar.gz: 0b6eff840de87f744de373bea259fd89a36ca1af33fbc2c678ac84296115b9a5fad841941feabaeb8ebda9c8333d6f4d72a380379c09c410e6358c571af1b26b
6
+ metadata.gz: 8051e5090a5361c74eb67506cec3021c69da07e9150b41cce91c674af759f7f03a3b9547e08abf0297f17bd32441cc3d04e1ea62fb92c47168841fd9991e5669
7
+ data.tar.gz: c70f056865cd07758f372b35bf7e8a14571f88b4ae4189c41ef26309a637068593293d1057aba021c8c4d184821a384962e7547048d66d2c615090067d796231
data/CHANGELOG.md CHANGED
@@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.28.0] - 2026-07-31
11
+
12
+ ### Changed
13
+ - **`callback_wrapper` now composes around timeout-bounded SessionStore adapter dispatch** (mirror-batcher appends and every resume-materialization store call), inside the timeout bound — on the worker thread in the default thread-hop mode, inside the cooperative timeout for inline-declared adapters. A Rails `executor.wrap` wrapper therefore covers an ActiveRecord-backed store adapter too: connections check out and back in per call instead of stranding on the throwaway thread. The bound itself is unchanged (`JoinTimeout` semantics, no-retry-on-timeout); a wrapper-raised error is treated like a store error (retryable on the batcher path). Previously the timeout path silently ignored the wrapper.
14
+
15
+ ### Fixed
16
+ - `SdkMcpServer#callback_scheduling=` / `#callback_wrapper=` now validate like their `ClaudeAgentOptions` counterparts: the String form (`"inline"`) is coerced to the Symbol, and invalid values raise `ArgumentError` at set time. Previously the bare accessors accepted anything, and because the dispatch layer special-cases only the exact Symbol `:inline`, a String or typo silently degraded to the thread hop — the opposite of what an inline host asked for.
17
+ - The once-per-process `:inline`-under-`isolation_level = :thread` warning is now emitted under a mutex, closing a benign race where two sessions connecting concurrently could both warn.
18
+
10
19
  ## [0.27.0] - 2026-07-31
11
20
 
12
21
  ### Added
13
- - **Fiber-native SessionStore adapters** (#47 phase 3): an adapter whose IO is entirely Fiber-scheduler-aware can declare it by defining an optional `callback_scheduling` method returning `:inline`. Every timeout-bounded store call the SDK makes (mirror-batcher appends; resume-materialization loads **and** the listing methods `list_sessions` / `list_session_summaries` / `list_subkeys`) then runs in place on the reactor fiber under a **cooperative** timeout — interrupted at the next suspension point with `ensure` blocks running — instead of on a throwaway thread with a hard `Thread#join` bound. The outward exception contract is unchanged (`FiberBoundary::JoinTimeout`); undeclared adapters are byte-for-byte untouched, and outside a reactor the hard bound still applies even for declared adapters. Cancellation reaches only the adapter's own fiber (offloaded work may still land afterwards), so timed-out appends are not retried in either mode and may remain permanently half-applied in the store — the drop is surfaced (`MirrorErrorMessage`, `batches_dropped?`) and the local transcript remains the source of truth. Invalid or raising declarations fail fast (`ArgumentError` at construction; conformance contract 17). Apps can opt a third-party fiber-native adapter in via `def store.callback_scheduling = :inline`.
22
+ - **Fiber-native SessionStore adapters** (#47 phase 3): an adapter whose IO is entirely Fiber-scheduler-aware can declare it by defining an optional `callback_scheduling` method returning `:inline`. Every timeout-bounded store call the SDK makes (mirror-batcher appends; resume-materialization loads **and** the listing methods `list_sessions` / `list_session_summaries` / `list_subkeys`) then runs in place on the reactor fiber under a **cooperative** timeout — interrupted at the next suspension point with `ensure` blocks running — instead of on a throwaway thread with a hard `Thread#join` bound. The outward exception contract is unchanged (`FiberBoundary::JoinTimeout`); undeclared adapters are byte-for-byte untouched, and outside a reactor the hard bound still applies even for declared adapters. Cancellation reaches only the adapter's own fiber (offloaded work may still land afterwards), so timed-out appends are not retried in either mode and may remain permanently half-applied in the store — the drop is surfaced (`MirrorErrorMessage`, `batches_dropped?`) and the local transcript remains the source of truth. An invalid declared value fails fast with `ArgumentError` at construction, and a declaration that itself raises propagates its own exception at the same construction point rather than mid-session (conformance contract 17 covers both). Apps can opt a third-party fiber-native adapter in via `def store.callback_scheduling = :inline`.
14
23
 
15
24
  ## [0.26.0] - 2026-07-31
16
25
 
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.27.0'
71
+ gem 'claude-agent-sdk', '~> 0.28.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
data/docs/rails.md CHANGED
@@ -35,6 +35,8 @@ end
35
35
 
36
36
  The wrapper is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc.
37
37
 
38
+ The wrapper also composes around every timeout-bounded `SessionStore` adapter call (mirror-batcher appends, resume-materialization loads and listings), inside the timeout bound — so an ActiveRecord-backed store adapter gets the same connection hygiene as your callbacks.
39
+
38
40
  When do you want this vs `callback_scheduling: :inline`? `callback_wrapper` + default `:thread` mode is the right choice for ordinary threaded hosts (Puma, threaded Sidekiq/solid_queue): it fixes connection hygiene without any fiber-isolation precondition. `:inline` is only for hosts that are fiber-isolated end to end (solid_queue fiber workers with `isolation_level = :fiber`); there the wrapper still applies — it simply runs in place on the reactor fiber.
39
41
 
40
42
  ## Fiber workers (solid_queue) and `callback_scheduling: :inline`
@@ -80,7 +82,7 @@ end
80
82
 
81
83
  Preconditions, spelled out: `:inline` is only correct when the process satisfies the same requirements as solid_queue's fiber workers — `isolation_level = :fiber`, Rails 7.2+ for AR, and no thread-keyed libraries used inside callbacks without a fiber-aware wrapper. The SDK warns once if it detects `:inline` under `isolation_level == :thread`. Everything else (Puma request threads, threaded Sidekiq/solid_queue workers) should stay on the default `callback_scheduling: :thread`.
82
84
 
83
- Note that `SessionStore` adapter calls (`#append` / `#load`) stay on threads by default even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor. The exception is an adapter that declares itself fiber-native via an optional `callback_scheduling` method returning `:inline` (see "Fiber-native adapters" in [docs/sessions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/sessions.md)); its calls then run on the reactor under a cooperative timeout.
85
+ Note that `SessionStore` adapter calls (`#append` / `#load`) stay on threads by default even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor. The exception is an adapter that declares itself fiber-native via an optional `callback_scheduling` method returning `:inline` (see "Fiber-native adapters" in [docs/sessions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/sessions.md)); its calls then run on the reactor under a cooperative timeout. In both cases a configured `callback_wrapper` composes around the adapter call inside the bound.
84
86
 
85
87
  ## ActionCable Streaming
86
88
 
data/docs/sessions.md CHANGED
@@ -218,6 +218,12 @@ session is set up; without a reactor the hard thread-hop bound still applies
218
218
  even for declared adapters. To opt in a third-party fiber-native adapter you
219
219
  don't own: `def store.callback_scheduling = :inline` (singleton method).
220
220
 
221
+ A session-configured `callback_wrapper` (see docs/rails.md) composes around
222
+ every one of these adapter calls, inside the timeout bound — on the worker
223
+ thread for default adapters, inside the cooperative timeout for inline
224
+ declarers (the cancellation passes through the wrapper un-swallowed and the
225
+ wrapper's `ensure` runs at cancellation).
226
+
221
227
  ### Store-backed helpers
222
228
 
223
229
  The browsing/mutation helpers above have store-backed counterparts that take a
@@ -38,7 +38,7 @@ module ClaudeAgentSDK
38
38
  # suspension points would leak across sessions). The one path a call
39
39
  # argument cannot cross (SDK-MCP dispatch through the mcp gem) carries
40
40
  # it via a scoped, invalidatable fiber-storage entry instead — see
41
- # SCHEDULING_KEY / SchedulingScope below.
41
+ # DISPATCH_KEY / CallbackDispatchScope below.
42
42
  #
43
43
  # A `timeout:` forces the thread hop — `Thread#join(timeout)` is a hard
44
44
  # bound that can abandon a wedged call, which cooperative `with_timeout`
@@ -88,16 +88,16 @@ module ClaudeAgentSDK
88
88
  class InlineCancellation < Exception; end # rubocop:disable Lint/InheritException
89
89
 
90
90
  # Fiber-storage key carrying the dispatching session's callback
91
- # scheduling mode across the SDK-MCP dispatch path (Query ->
92
- # MCP::Server -> dynamic tool class). Fiber storage is per-fiber, so
91
+ # dispatch pair (scheduling mode + wrapper) across the SDK-MCP dispatch
92
+ # path (Query -> MCP::Server -> dynamic tool class). Fiber storage is per-fiber, so
93
93
  # concurrent sessions with different modes sharing one SdkMcpServer
94
94
  # instance cannot cross-contaminate — unlike mutating the shared
95
95
  # server (last-writer-wins, persists past close) or a thread-local
96
96
  # (the reactor thread is shared by many fibers).
97
97
  # @api private
98
- SCHEDULING_KEY = :claude_agent_sdk_callback_scheduling
98
+ DISPATCH_KEY = :claude_agent_sdk_callback_dispatch
99
99
 
100
- # The value stored under SCHEDULING_KEY: a
100
+ # The value stored under DISPATCH_KEY: a
101
101
  # closable carrier rather than a bare symbol. Fiber-storage inheritance
102
102
  # copies the storage HASH but shares value REFERENCES, so every fiber
103
103
  # (and thread) created during a dispatch inherits this same object.
@@ -112,7 +112,7 @@ module ClaudeAgentSDK
112
112
  # invalidated) together, so a descendant that outlives the dispatch can
113
113
  # neither run with the session's mode nor with its wrapper.
114
114
  # @api private
115
- class SchedulingScope
115
+ class CallbackDispatchScope
116
116
  attr_reader :mode, :wrapper
117
117
 
118
118
  def initialize(mode, wrapper = nil)
@@ -175,30 +175,28 @@ module ClaudeAgentSDK
175
175
  # could falsely flag. In inline/no-scheduler mode `break` unwinds natively through the
176
176
  # wrapper's stack, so ensure-based wrappers (executor.wrap) are safe.
177
177
  #
178
- # The timeout path deliberately ignores the wrapper: it belongs to the
179
- # store-adapter carve-out (TranscriptMirrorBatcher, SessionResume),
180
- # which never carries user callbacks.
178
+ # On timeout-bounded calls (the store-adapter paths:
179
+ # TranscriptMirrorBatcher, SessionResume) the wrapper composes INSIDE
180
+ # the bound: on the worker thread within the `Thread#join` deadline in
181
+ # :thread mode (so `executor.wrap` covers an AR-backed store adapter —
182
+ # connections check back in when the call ends; on a timeout the
183
+ # abandoned worker still runs the wrapper's ensure whenever the wedged
184
+ # call eventually finishes), and inside the cooperative `with_timeout`
185
+ # in inline mode (the cancellation passes through the wrapper
186
+ # un-swallowed — InlineCancellation is not a StandardError — and the
187
+ # wrapper's ensure runs at cancellation).
181
188
  def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block)
182
- body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
189
+ body = wrapper ? -> { wrapper.call(block) } : block
183
190
  return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
184
191
 
185
192
  # Cooperative timeout for inline-declared store adapters: in place on
186
- # the reactor fiber, cancelled at the next suspension point. The
187
- # wrapper stays ignored on timeout paths (store adapters are never
188
- # wrapped), so `body` is the bare block here. The cancellation class
189
- # is a fresh per-invocation subclass: rescuing the shared base would
190
- # also catch an OUTER timeout's cancellation delivered while this
191
- # call is suspended (nested with_timeout — e.g. a store call inside a
192
- # timed inline hook), mis-attributing the outer deadline to this call
193
- # and letting execution continue past it. An outer cancellation is a
194
- # different subclass, so it propagates through untouched.
193
+ # the reactor fiber, cancelled at the next suspension point (delivery
194
+ # and translation mechanics live in .with_cooperative_timeout). The
195
+ # wrapper is already composed into `body`, so it sits inside the
196
+ # timeout scope.
195
197
  if timeout && scheduling == :inline && (task = Async::Task.current?)
196
- cancellation = Class.new(InlineCancellation)
197
- begin
198
- return task.with_timeout(timeout, cancellation, &block)
199
- rescue cancellation
200
- raise JoinTimeout, "timed out after #{timeout}s"
201
- end
198
+ expired = -> { JoinTimeout.new("timed out after #{timeout}s") }
199
+ return with_cooperative_timeout(task, timeout, on_timeout: expired) { body.call }
202
200
  end
203
201
 
204
202
  thread = Thread.new(&body)
@@ -209,6 +207,36 @@ module ClaudeAgentSDK
209
207
  thread.value
210
208
  end
211
209
 
210
+ # Run +block+ on +task+ bounded by a cooperative +timeout+: the deadline
211
+ # is delivered INSIDE user code as an InlineCancellation at the block's
212
+ # next suspension point (ensure blocks run; a callback's ordinary
213
+ # `rescue StandardError` cannot swallow it), then translated into the
214
+ # exception built by +on_timeout+ (a zero-arg callable) once control has
215
+ # returned from user code — each call site keeps its own outward
216
+ # contract (JoinTimeout on the store-adapter path in .invoke,
217
+ # Async::TimeoutError on the inline hook path in Query).
218
+ #
219
+ # The cancellation class is a fresh per-invocation subclass: rescuing
220
+ # the shared base would also catch an OUTER timeout's cancellation
221
+ # delivered while this call is suspended (nested with_timeout — e.g. an
222
+ # inline store call nested inside a timed inline hook), mis-attributing
223
+ # the outer deadline to this call and letting execution continue past
224
+ # it. An outer cancellation is a different subclass, so it propagates
225
+ # through untouched.
226
+ #
227
+ # Wrapper composition is the caller's choice — +block+ runs verbatim
228
+ # inside the timeout scope (.invoke composes the callback wrapper into
229
+ # its body beforehand; the hook path composes it inside the block).
230
+ # @api private
231
+ def with_cooperative_timeout(task, timeout, on_timeout:, &block)
232
+ cancellation = Class.new(InlineCancellation)
233
+ begin
234
+ task.with_timeout(timeout, cancellation, &block)
235
+ rescue cancellation
236
+ raise on_timeout.call
237
+ end
238
+ end
239
+
212
240
  # Invoke a user-supplied iteration block across the boundary. The thread
213
241
  # hop severs `break` from the surrounding loop, surfacing as
214
242
  # LocalJumpError(reason: :break) on the worker thread; translate it into
@@ -629,25 +629,21 @@ module ClaudeAgentSDK
629
629
  # Async::TimeoutError is a StandardError — a hook's ordinary
630
630
  # `rescue StandardError` would swallow the cancellation and
631
631
  # convert the expired hook into a success (or keep running past
632
- # the deadline). Inject a non-StandardError cancellation
633
- # instead, translated back once control leaves user code so the
634
- # outward contract (Async::TimeoutError) is unchanged. The
635
- # wrapper composes INSIDE with_timeout, and the cancellation
632
+ # the deadline). FiberBoundary.with_cooperative_timeout injects
633
+ # a non-StandardError cancellation instead (fresh subclass per
634
+ # scope the nested-scope rationale lives on the helper),
635
+ # translated back once control leaves user code so the outward
636
+ # contract (Async::TimeoutError) is unchanged. The wrapper
637
+ # composes INSIDE the timeout scope, and the cancellation
636
638
  # passes through it un-swallowed (InlineCancellation is not a
637
639
  # StandardError, so a wrapper's ordinary rescue can't eat it).
638
- # Fresh per-invocation subclass: rescuing the shared base would
639
- # also catch an OUTER timeout's cancellation (e.g. an inline
640
- # store call nested inside this hook already uses its own), so
641
- # each timeout scope must only consume its own deadline.
642
- begin
643
- cancellation = Class.new(FiberBoundary::InlineCancellation)
644
- Async::Task.current.with_timeout(timeout, cancellation) do
645
- FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
646
- callback.call(hook_input, request_data[:tool_use_id], context)
647
- end
640
+ FiberBoundary.with_cooperative_timeout(
641
+ Async::Task.current, timeout,
642
+ on_timeout: -> { Async::TimeoutError.new('execution expired') }
643
+ ) do
644
+ FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
645
+ callback.call(hook_input, request_data[:tool_use_id], context)
648
646
  end
649
- rescue cancellation
650
- raise Async::TimeoutError, 'execution expired'
651
647
  end
652
648
  else
653
649
  Async::Task.current.with_timeout(timeout) do
@@ -1011,16 +1007,16 @@ module ClaudeAgentSDK
1011
1007
  # SdkMcpServer#effective_callback_scheduling / _wrapper). Fiber
1012
1008
  # storage is per-fiber, so concurrent sessions cannot see each
1013
1009
  # other's value even across suspension points. The value is a
1014
- # closable SchedulingScope, closed + restored in the ensure below:
1010
+ # closable CallbackDispatchScope, closed + restored in the ensure below:
1015
1011
  # fibers/threads created during the dispatch inherit the same scope
1016
1012
  # OBJECT (storage inheritance copies the hash, shares references), so
1017
1013
  # closing it invalidates the mode for every inheritor at once — a
1018
1014
  # child task that outlives the dispatch cannot carry the session mode
1019
1015
  # into later direct server calls, and nothing stays stamped on
1020
1016
  # long-lived fibers.
1021
- previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
1022
- dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
1023
- Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
1017
+ previous_dispatch = Fiber[FiberBoundary::DISPATCH_KEY]
1018
+ dispatch_scope = FiberBoundary::CallbackDispatchScope.new(@callback_scheduling, @callback_wrapper)
1019
+ Fiber[FiberBoundary::DISPATCH_KEY] = dispatch_scope
1024
1020
 
1025
1021
  # Convert server_name to symbol if needed for hash lookup
1026
1022
  server_key = @sdk_mcp_servers.key?(server_name) ? server_name : server_name.to_sym
@@ -1072,7 +1068,7 @@ module ClaudeAgentSDK
1072
1068
  }
1073
1069
  ensure
1074
1070
  dispatch_scope&.close
1075
- Fiber[FiberBoundary::SCHEDULING_KEY] = previous_scheduling
1071
+ Fiber[FiberBoundary::DISPATCH_KEY] = previous_dispatch
1076
1072
  end
1077
1073
 
1078
1074
  def handle_mcp_initialize(server, message)
@@ -88,19 +88,46 @@ module ClaudeAgentSDK
88
88
  # storage instead (see #effective_callback_scheduling) — a server
89
89
  # shared by concurrent sessions with different modes is never mutated,
90
90
  # so modes cannot cross-contaminate or persist past a session.
91
- attr_accessor :callback_scheduling
91
+ attr_reader :callback_scheduling
92
92
 
93
93
  # Default callback wrapper for DIRECT invocations of this server
94
94
  # (call_tool / read_resource / get_prompt outside a session). When a
95
95
  # session dispatches to this server, the session's own wrapper arrives
96
96
  # via fiber storage instead (see #effective_callback_wrapper) — same
97
97
  # never-mutate-the-shared-server rule as callback_scheduling.
98
- attr_accessor :callback_wrapper
98
+ attr_reader :callback_wrapper
99
+
100
+ # Same coercion + whitelist rule as ClaudeAgentOptions#callback_scheduling
101
+ # (String form coerced to Symbol, anything else raises). FiberBoundary
102
+ # only special-cases the exact Symbol :inline, so an unvalidated value —
103
+ # e.g. the String "inline" or a typo — would silently degrade to the
104
+ # thread hop, which is precisely what an inline host must not get. Unlike
105
+ # the options setter there is no nil form: the server always holds a
106
+ # concrete default (:thread from #initialize).
107
+ def callback_scheduling=(value)
108
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
109
+ unless ClaudeAgentOptions::CALLBACK_SCHEDULING_MODES.include?(mode)
110
+ raise ArgumentError,
111
+ 'callback_scheduling must be one of ' \
112
+ "#{ClaudeAgentOptions::CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} (got #{value.inspect})"
113
+ end
114
+
115
+ @callback_scheduling = mode
116
+ end
117
+
118
+ # Same rule as ClaudeAgentOptions#callback_wrapper: callable or nil.
119
+ # Validated at set time so a non-callable fails here, not later as a
120
+ # NoMethodError inside a tool dispatch.
121
+ def callback_wrapper=(value)
122
+ raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
123
+
124
+ @callback_wrapper = value
125
+ end
99
126
 
100
127
  # Internal — public only so the dynamic tool classes can reach it. The
101
128
  # (scheduling mode, wrapper) pair for the current invocation, resolved
102
129
  # from ONE liveness decision: the dispatching session's pair when a
103
- # live SchedulingScope is in fiber storage (set by Query around the
130
+ # live CallbackDispatchScope is in fiber storage (set by Query around the
104
131
  # dispatch), else this server's own defaults. The pair MUST be resolved
105
132
  # together — deciding `active?` once per accessor lets the scope close
106
133
  # between the two reads (the dispatch's ensure runs concurrently with a
@@ -111,7 +138,7 @@ module ClaudeAgentSDK
111
138
  # @api private
112
139
  # @return [Array(Symbol, #call)] `[scheduling, wrapper]`
113
140
  def effective_callback_dispatch
114
- scope = Fiber[FiberBoundary::SCHEDULING_KEY]
141
+ scope = Fiber[FiberBoundary::DISPATCH_KEY]
115
142
  if scope&.active?
116
143
  [scope.mode, scope.wrapper]
117
144
  else
@@ -87,13 +87,14 @@ module ClaudeAgentSDK
87
87
  # locate the projects dir (already repointed at the temp dir when resuming
88
88
  # from a store). Eager flush mode zeroes the buffer thresholds so every
89
89
  # transcript_mirror frame triggers a background flush.
90
- def build_mirror_batcher(store:, env:, on_error:, eager: false)
90
+ def build_mirror_batcher(store:, env:, on_error:, eager: false, callback_wrapper: nil)
91
91
  TranscriptMirrorBatcher.new(
92
92
  store: store,
93
93
  projects_dir: SessionStores.projects_dir(env),
94
94
  on_error: on_error,
95
95
  max_pending_entries: eager ? 0 : TranscriptMirrorBatcher::MAX_PENDING_ENTRIES,
96
- max_pending_bytes: eager ? 0 : TranscriptMirrorBatcher::MAX_PENDING_BYTES
96
+ max_pending_bytes: eager ? 0 : TranscriptMirrorBatcher::MAX_PENDING_BYTES,
97
+ callback_wrapper: callback_wrapper
97
98
  )
98
99
  end
99
100
 
@@ -112,6 +113,10 @@ module ClaudeAgentSDK
112
113
  # point) so an invalid callback_scheduling declaration fails fast here,
113
114
  # before any store IO or temp-dir work.
114
115
  scheduling = SessionStores.store_callback_scheduling(store)
116
+ # The session's callback_wrapper composes around every store call below,
117
+ # inside the timeout bound (see FiberBoundary.invoke) — so e.g. Rails'
118
+ # executor.wrap covers an AR-backed adapter during materialization too.
119
+ wrapper = options.callback_wrapper
115
120
  project_key = Sessions.project_key_for_directory(options.cwd)
116
121
 
117
122
  resolved =
@@ -120,9 +125,9 @@ module ClaudeAgentSDK
120
125
  # prevent traversal and match every other resume path.
121
126
  return nil unless options.resume.match?(Sessions::UUID_RE)
122
127
 
123
- load_candidate(store, project_key, options.resume, timeout_s, scheduling)
128
+ load_candidate(store, project_key, options.resume, timeout_s, scheduling, wrapper)
124
129
  else
125
- resolve_continue_candidate(store, project_key, timeout_s, scheduling)
130
+ resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
126
131
  end
127
132
  return nil if resolved.nil?
128
133
 
@@ -137,7 +142,7 @@ module ClaudeAgentSDK
137
142
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
138
143
  copy_auth_files(tmp_base, options.env)
139
144
 
140
- materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling) if SessionStore.implements?(store, :list_subkeys)
145
+ materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) if SessionStore.implements?(store, :list_subkeys)
141
146
  rescue Exception # rubocop:disable Lint/RescueException
142
147
  # Any failure after mkdtemp leaves tmp_base (which may already hold a
143
148
  # .credentials.json copy) on disk with no path for the caller to clean
@@ -153,8 +158,8 @@ module ClaudeAgentSDK
153
158
  # -- Helpers --
154
159
 
155
160
  # Load entries for session_id; return [session_id, entries] or nil if empty.
156
- def load_candidate(store, project_key, session_id, timeout_s, scheduling)
157
- entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling) do
161
+ def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper)
162
+ entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling, wrapper) do
158
163
  store.load('project_key' => project_key, 'session_id' => session_id)
159
164
  end
160
165
  return nil if entries.nil? || entries.empty?
@@ -166,13 +171,13 @@ module ClaudeAgentSDK
166
171
  # transcripts are mirrored as ordinary top-level keys and often have the
167
172
  # highest mtime, so walk newest->oldest and skip them so --continue resumes
168
173
  # the user's conversation, not a subagent's.
169
- def resolve_continue_candidate(store, project_key, timeout_s, scheduling)
170
- sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling) do
174
+ def resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
175
+ sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling, wrapper) do
171
176
  store.list_sessions(project_key)
172
177
  end
173
178
  return nil if sessions.nil? || sessions.empty?
174
179
 
175
- sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
180
+ sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
176
181
 
177
182
  sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
178
183
  sid = cand['session_id']
@@ -182,7 +187,7 @@ module ClaudeAgentSDK
182
187
  # --continue O(sum of transcript sizes) instead of O(candidates).
183
188
  next if sidechain_flags&.fetch(sid, false)
184
189
 
185
- loaded = load_candidate(store, project_key, sid, timeout_s, scheduling)
190
+ loaded = load_candidate(store, project_key, sid, timeout_s, scheduling, wrapper)
186
191
  next if loaded.nil?
187
192
 
188
193
  first = loaded[1][0]
@@ -198,10 +203,10 @@ module ClaudeAgentSDK
198
203
  # fails (callers then fall back to checking each full load). The per-load
199
204
  # isSidechain check above stays even on the summary path: a missing or
200
205
  # stale sidecar row costs one extra load, never a wrong resume.
201
- def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
206
+ def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
202
207
  return nil unless SessionStore.implements?(store, :list_session_summaries)
203
208
 
204
- rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling) do
209
+ rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do
205
210
  store.list_session_summaries(project_key)
206
211
  end
207
212
  Array(rows).each_with_object({}) do |row, acc|
@@ -239,9 +244,11 @@ module ClaudeAgentSDK
239
244
  # still complete. An adapter declaring `callback_scheduling -> :inline`
240
245
  # (probed once at materialization entry) instead runs in place under a
241
246
  # cooperative timeout when a reactor is present — outside one, the hard
242
- # thread-hop bound still applies.
243
- def with_timeout(timeout_s, what, scheduling = :thread, &block)
244
- FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, &block)
247
+ # thread-hop bound still applies. The session's callback_wrapper
248
+ # composes inside the bound (see FiberBoundary.invoke); a wrapper-raised
249
+ # error surfaces like a store error, with the same context message.
250
+ def with_timeout(timeout_s, what, scheduling = :thread, wrapper = nil, &block)
251
+ FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &block)
245
252
  rescue FiberBoundary::JoinTimeout
246
253
  raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
247
254
  rescue RuntimeError
@@ -371,9 +378,9 @@ module ClaudeAgentSDK
371
378
  end
372
379
 
373
380
  # Load and write all subagent transcripts/metadata under session_id.
374
- def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling)
381
+ def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
375
382
  session_dir = File.join(project_dir, session_id)
376
- subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling) do
383
+ subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do
377
384
  store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
378
385
  end
379
386
 
@@ -385,7 +392,7 @@ module ClaudeAgentSDK
385
392
  next
386
393
  end
387
394
 
388
- sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling) do
395
+ sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling, wrapper) do
389
396
  store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
390
397
  end
391
398
  next if sub_entries.nil? || sub_entries.empty?
@@ -51,12 +51,19 @@ module ClaudeAgentSDK
51
51
  # @param projects_dir [String] base dir for file_path -> SessionKey mapping
52
52
  # @param on_error [#call] called as on_error.call(key, message) after a batch
53
53
  # exhausts retries; must not raise
54
+ # @param callback_wrapper [#call, nil] the session's
55
+ # ClaudeAgentOptions#callback_wrapper, composed around each #append
56
+ # inside the timeout bound (worker thread in :thread mode, cooperative
57
+ # timeout in :inline mode) — so e.g. Rails' executor.wrap covers an
58
+ # AR-backed adapter and checks connections back in per call
54
59
  def initialize(store:, projects_dir:, on_error:, send_timeout: SEND_TIMEOUT_SECONDS,
55
- max_pending_entries: MAX_PENDING_ENTRIES, max_pending_bytes: MAX_PENDING_BYTES)
60
+ max_pending_entries: MAX_PENDING_ENTRIES, max_pending_bytes: MAX_PENDING_BYTES,
61
+ callback_wrapper: nil)
56
62
  @store = store
57
63
  @projects_dir = projects_dir
58
64
  @on_error = on_error
59
65
  @send_timeout = send_timeout
66
+ @callback_wrapper = callback_wrapper
60
67
  # Probed ONCE here so an invalid callback_scheduling declaration fails
61
68
  # at construction, not mid-session inside a flush.
62
69
  @store_scheduling = SessionStores.store_callback_scheduling(store)
@@ -248,9 +255,13 @@ module ClaudeAgentSDK
248
255
  # place on the reactor under a cooperative timeout (interrupted at its
249
256
  # next suspension point, ensure blocks run) — same JoinTimeout contract
250
257
  # and same no-retry semantics (see append_with_retry: offloaded work
251
- # may outlive the cancellation).
258
+ # may outlive the cancellation). The session's callback_wrapper composes
259
+ # inside the bound (see FiberBoundary.invoke); a wrapper-raised error is
260
+ # indistinguishable from a store error here — [:error, e], retryable.
252
261
  def invoke_append(key, entries)
253
- FiberBoundary.invoke(timeout: @send_timeout, scheduling: @store_scheduling) { @store.append(key, entries) }
262
+ FiberBoundary.invoke(timeout: @send_timeout, scheduling: @store_scheduling, wrapper: @callback_wrapper) do
263
+ @store.append(key, entries)
264
+ end
254
265
  [:ok, nil]
255
266
  rescue FiberBoundary::JoinTimeout
256
267
  [:timeout, "append timed out after #{@send_timeout}s"]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.27.0'
4
+ VERSION = '0.28.0'
5
5
  end
@@ -114,6 +114,11 @@ module ClaudeAgentSDK
114
114
  FiberBoundary.invoke(&block)
115
115
  end
116
116
 
117
+ # Guards the once-per-process flag below: two sessions connecting
118
+ # concurrently must not both pass the unset check and warn twice.
119
+ INLINE_ISOLATION_WARN_LOCK = Mutex.new
120
+ private_constant :INLINE_ISOLATION_WARN_LOCK
121
+
117
122
  # Internal: warn once per process when :inline callback scheduling is
118
123
  # enabled while ActiveSupport reports thread isolation — the host then
119
124
  # almost certainly violates inline mode's fiber-isolation precondition
@@ -121,11 +126,14 @@ module ClaudeAgentSDK
121
126
  # defined? probing only; the SDK never loads ActiveSupport itself.
122
127
  def self.check_inline_isolation(scheduling)
123
128
  return unless scheduling == :inline
124
- return if @inline_isolation_warned
125
129
  return unless defined?(ActiveSupport::IsolatedExecutionState)
126
130
  return unless ActiveSupport::IsolatedExecutionState.isolation_level == :thread
127
131
 
128
- @inline_isolation_warned = true
132
+ INLINE_ISOLATION_WARN_LOCK.synchronize do
133
+ return if @inline_isolation_warned
134
+
135
+ @inline_isolation_warned = true
136
+ end
129
137
  warn 'ClaudeAgentSDK: callback_scheduling: :inline is enabled but ' \
130
138
  'ActiveSupport::IsolatedExecutionState.isolation_level is :thread. ' \
131
139
  'Inline callbacks run on reactor fibers that share one thread, so ' \
@@ -539,7 +547,8 @@ module ClaudeAgentSDK
539
547
  store: configured_options.session_store,
540
548
  env: configured_options.env,
541
549
  on_error: ->(key, message) { query_handler.report_mirror_error(key, message) },
542
- eager: configured_options.session_store_flush.to_s == 'eager'
550
+ eager: configured_options.session_store_flush.to_s == 'eager',
551
+ callback_wrapper: callback_wrapper
543
552
  )
544
553
  )
545
554
  end
@@ -1149,7 +1158,8 @@ module ClaudeAgentSDK
1149
1158
  store: options.session_store,
1150
1159
  env: options.env,
1151
1160
  on_error: ->(key, message) { @query_handler.report_mirror_error(key, message) },
1152
- eager: options.session_store_flush.to_s == 'eager'
1161
+ eager: options.session_store_flush.to_s == 'eager',
1162
+ callback_wrapper: @callback_wrapper
1153
1163
  )
1154
1164
  @query_handler.set_transcript_mirror_batcher(batcher)
1155
1165
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors