claude-agent-sdk 0.26.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: d4e4fcd2fa1c18916f41409482c8edfe3367ea5b06db9a90b80ed168f339a45e
4
- data.tar.gz: 3726daa4aa431b4dba5cd181433066bc8f541efbe13984799c8642bfc671e75e
3
+ metadata.gz: 5a12b68451568e5428820499fb4bf42401596477ea74d6b25a8fad3637b70178
4
+ data.tar.gz: d0465a73a3cbe17d3775d65d83978c16527548f9f752f8786b606f2c8c1c3713
5
5
  SHA512:
6
- metadata.gz: 00cb37c301d1c1eae778bd55b0d19330e21b6069a0943a6cd7a7931da4d32d8da847c8362f102d530aed8cdcf66f28eb9ca57ac248ed5ce2cbcd5609b3435ab0
7
- data.tar.gz: 0bcabed11ba7e49c78ab383bf1186d39cd65873e1190f13a7cbfc102f1c2c7f0226fc8604818f3fc4cf3706760334abb5bc82a7c9ab6e6c9d1e178fd3a1c3002
6
+ metadata.gz: 8051e5090a5361c74eb67506cec3021c69da07e9150b41cce91c674af759f7f03a3b9547e08abf0297f17bd32441cc3d04e1ea62fb92c47168841fd9991e5669
7
+ data.tar.gz: c70f056865cd07758f372b35bf7e8a14571f88b4ae4189c41ef26309a637068593293d1057aba021c8c4d184821a384962e7547048d66d2c615090067d796231
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ 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
+
19
+ ## [0.27.0] - 2026-07-31
20
+
21
+ ### Added
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`.
23
+
10
24
  ## [0.26.0] - 2026-07-31
11
25
 
12
26
  ### Added
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.26.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`) intentionally stay on threads even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor.
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
@@ -154,7 +154,8 @@ during resume materialization, default `60_000`).
154
154
  > Python and TypeScript SDKs.
155
155
  >
156
156
  > The temp dir is deleted at disconnect — **unless the mirror dropped batches**
157
- > (adapter failures that exhausted retries, surfaced as `MirrorErrorMessage`):
157
+ > (terminal append failures timeouts immediately, other failures after up to
158
+ > three attempts — surfaced as `MirrorErrorMessage`):
158
159
  > the store copy is then incomplete and the temp dir holds the only copy of the
159
160
  > dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
160
161
  > and warns with the preserved path so you can import them into the store.
@@ -175,6 +176,54 @@ Copy-in reference adapters for **S3, Redis, and Postgres** live in
175
176
  [`examples/session_stores/`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md), each with a
176
177
  production checklist.
177
178
 
179
+ #### Fiber-native adapters
180
+
181
+ By default the SDK runs every timeout-bounded adapter call (`#append` from the
182
+ mirror batcher, `#load` and friends during resume materialization) on a
183
+ throwaway thread with a hard `Thread#join` timeout, so a wedged adapter can
184
+ never stall the reactor. An adapter whose IO is **entirely
185
+ Fiber-scheduler-aware** (e.g. built on `async`-native clients) can opt out of
186
+ the thread hop by declaring it:
187
+
188
+ ```ruby
189
+ class MyAsyncStore
190
+ def callback_scheduling = :inline
191
+ # append/load ...
192
+ end
193
+ ```
194
+
195
+ Declaring `:inline` means the calls run in place on the reactor fiber under a
196
+ **cooperative** timeout. Three consequences to understand before opting in:
197
+
198
+ - The declaration covers **every method the SDK invokes on the adapter** —
199
+ `append`, `load`, `list_sessions`, `list_session_summaries`,
200
+ `list_subkeys` — not just append/load: resume materialization runs the
201
+ listing calls inline too. **All** blocking inside all of them must yield to
202
+ the scheduler. Scheduler-opaque blocking (CPU-bound work, GVL-holding C
203
+ extensions, native drivers the scheduler can't see) stalls every job on
204
+ that worker **and** the cooperative timeout cannot fire while it blocks.
205
+ - Cancellation semantics change: a timed-out call is interrupted at its next
206
+ suspension point and its `ensure` blocks run, instead of being abandoned on
207
+ a thread. The cancellation reaches only the adapter's **own fiber** — work
208
+ the adapter offloaded (descendant tasks, an already-issued remote write)
209
+ may still land afterwards. Timed-out appends are therefore **not retried**
210
+ (same as thread mode; a retry would race that still-landing work), and the
211
+ interrupted append may remain permanently **half-applied** in the store.
212
+ The drop is surfaced like every dropped batch — `MirrorErrorMessage` on
213
+ the stream, `batches_dropped?` on the batcher — and the local transcript
214
+ remains the source of truth, so nothing is lost from the session itself.
215
+
216
+ Anything other than `:thread`/`:inline` raises `ArgumentError` when the
217
+ session is set up; without a reactor the hard thread-hop bound still applies
218
+ even for declared adapters. To opt in a third-party fiber-native adapter you
219
+ don't own: `def store.callback_scheduling = :inline` (singleton method).
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
+
178
227
  ### Store-backed helpers
179
228
 
180
229
  The browsing/mutation helpers above have store-backed counterparts that take a
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'async'
4
+
3
5
  module ClaudeAgentSDK
4
6
  # Internal. Consumers of the SDK should never need this directly.
5
7
  #
@@ -36,13 +38,21 @@ module ClaudeAgentSDK
36
38
  # suspension points would leak across sessions). The one path a call
37
39
  # argument cannot cross (SDK-MCP dispatch through the mcp gem) carries
38
40
  # it via a scoped, invalidatable fiber-storage entry instead — see
39
- # SCHEDULING_KEY / SchedulingScope below.
41
+ # DISPATCH_KEY / CallbackDispatchScope below.
40
42
  #
41
- # A `timeout:` always forces the thread hop regardless of scheduling
42
- # `Thread#join(timeout)` is a hard bound that can abandon a wedged call,
43
- # which cooperative `with_timeout` cancellation cannot guarantee. The
44
- # store-adapter call sites (TranscriptMirrorBatcher, SessionResume) rely
45
- # on this to never stall the reactor on a wedged adapter.
43
+ # A `timeout:` forces the thread hop `Thread#join(timeout)` is a hard
44
+ # bound that can abandon a wedged call, which cooperative `with_timeout`
45
+ # cancellation cannot guarantee — unless the caller passes
46
+ # `scheduling: :inline` inside a reactor, where the bound becomes a
47
+ # cooperative `with_timeout` cancellation instead (issue #47 phase 3:
48
+ # fiber-native SessionStore adapters that declare
49
+ # `callback_scheduling -> :inline`). The store-adapter call sites
50
+ # (TranscriptMirrorBatcher, SessionResume) default to the thread hop so
51
+ # an undeclared (possibly scheduler-opaque) adapter can never stall the
52
+ # reactor; a declared-inline adapter accepts that a scheduler-opaque
53
+ # blocking call would stall it AND escape the cooperative deadline.
54
+ # Outside a reactor the hard bound applies even to inline-declared
55
+ # adapters — the timeout guarantee is never lost.
46
56
  #
47
57
  # The thread hop severs `break`/`return`/`next` from the surrounding method,
48
58
  # so SDK loops yielding user callbacks must keep loop control outside the
@@ -65,8 +75,9 @@ module ClaudeAgentSDK
65
75
  class JoinTimeout < StandardError; end
66
76
 
67
77
  # Cancellation injected into INLINE user callbacks by timeout
68
- # enforcement (hook timeouts under scheduling: :inline) — user code
69
- # should let it propagate. Deliberately
78
+ # enforcement (hook timeouts under scheduling: :inline; store-adapter
79
+ # timeouts for adapters declaring callback_scheduling :inline) — user
80
+ # code should let it propagate. Deliberately
70
81
  # NOT a StandardError: the exception is raised inside user code at a
71
82
  # suspension point, and a callback's ordinary `rescue StandardError`
72
83
  # must not be able to swallow the cancellation and convert an expired
@@ -77,16 +88,16 @@ module ClaudeAgentSDK
77
88
  class InlineCancellation < Exception; end # rubocop:disable Lint/InheritException
78
89
 
79
90
  # Fiber-storage key carrying the dispatching session's callback
80
- # scheduling mode across the SDK-MCP dispatch path (Query ->
81
- # 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
82
93
  # concurrent sessions with different modes sharing one SdkMcpServer
83
94
  # instance cannot cross-contaminate — unlike mutating the shared
84
95
  # server (last-writer-wins, persists past close) or a thread-local
85
96
  # (the reactor thread is shared by many fibers).
86
97
  # @api private
87
- SCHEDULING_KEY = :claude_agent_sdk_callback_scheduling
98
+ DISPATCH_KEY = :claude_agent_sdk_callback_dispatch
88
99
 
89
- # The value stored under SCHEDULING_KEY: a
100
+ # The value stored under DISPATCH_KEY: a
90
101
  # closable carrier rather than a bare symbol. Fiber-storage inheritance
91
102
  # copies the storage HASH but shares value REFERENCES, so every fiber
92
103
  # (and thread) created during a dispatch inherits this same object.
@@ -101,7 +112,7 @@ module ClaudeAgentSDK
101
112
  # invalidated) together, so a descendant that outlives the dispatch can
102
113
  # neither run with the session's mode nor with its wrapper.
103
114
  # @api private
104
- class SchedulingScope
115
+ class CallbackDispatchScope
105
116
  attr_reader :mode, :wrapper
106
117
 
107
118
  def initialize(mode, wrapper = nil)
@@ -133,10 +144,16 @@ module ClaudeAgentSDK
133
144
  # Run the given block on a plain thread when a Fiber scheduler is active.
134
145
  # Returns the block's value. Exceptions propagate to the caller.
135
146
  #
136
- # With +timeout+ (seconds) the thread hop happens unconditionally even
137
- # without a scheduler or with `scheduling: :inline` — so the bound is
138
- # enforced in plain synchronous code too; JoinTimeout is raised when it
139
- # expires.
147
+ # With +timeout+ (seconds) the thread hop happens regardless of a
148
+ # scheduler being active, so the bound is enforced in plain synchronous
149
+ # code too; JoinTimeout is raised when it expires. Exception: with
150
+ # `scheduling: :inline` INSIDE an Async task, the block instead runs in
151
+ # place under a cooperative `with_timeout` — the deadline is delivered
152
+ # as InlineCancellation at the block's next suspension point (its
153
+ # ensure blocks run; not swallowable by `rescue StandardError`) and
154
+ # surfaces as the same JoinTimeout, so callers need no changes. When
155
+ # inline is requested but no Async task is present, the hard
156
+ # thread-hop bound applies — the timeout guarantee is never lost.
140
157
  #
141
158
  # With `scheduling: :inline` (and no timeout) the block runs in place on
142
159
  # the current fiber, scheduler or not. The caller opts in via
@@ -158,13 +175,30 @@ module ClaudeAgentSDK
158
175
  # could falsely flag. In inline/no-scheduler mode `break` unwinds natively through the
159
176
  # wrapper's stack, so ensure-based wrappers (executor.wrap) are safe.
160
177
  #
161
- # The timeout path deliberately ignores the wrapper: it belongs to the
162
- # store-adapter carve-out (TranscriptMirrorBatcher, SessionResume),
163
- # 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).
164
188
  def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block)
165
- body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
189
+ body = wrapper ? -> { wrapper.call(block) } : block
166
190
  return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
167
191
 
192
+ # Cooperative timeout for inline-declared store adapters: in place on
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.
197
+ if timeout && scheduling == :inline && (task = Async::Task.current?)
198
+ expired = -> { JoinTimeout.new("timed out after #{timeout}s") }
199
+ return with_cooperative_timeout(task, timeout, on_timeout: expired) { body.call }
200
+ end
201
+
168
202
  thread = Thread.new(&body)
169
203
  thread.report_on_exception = false
170
204
  return thread.value if timeout.nil?
@@ -173,6 +207,36 @@ module ClaudeAgentSDK
173
207
  thread.value
174
208
  end
175
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
+
176
240
  # Invoke a user-supplied iteration block across the boundary. The thread
177
241
  # hop severs `break` from the surrounding loop, surfacing as
178
242
  # LocalJumpError(reason: :break) on the worker thread; translate it into
@@ -270,8 +270,9 @@ module ClaudeAgentSDK
270
270
  end
271
271
 
272
272
  # Synthesize a `mirror_error` system message and put it on the SDK message
273
- # stream so consumers learn a mirror batch was dropped after exhausting
274
- # retries. Non-blocking: the message queue is unbounded, so unlike the
273
+ # stream so consumers learn a mirror batch was dropped (timeouts
274
+ # immediately, other failures after up to three attempts).
275
+ # Non-blocking: the message queue is unbounded, so unlike the
275
276
  # Python SDK there is no buffer-full drop path.
276
277
  def report_mirror_error(key, error)
277
278
  session_id = key && (key['session_id'] || key[:session_id])
@@ -628,20 +629,21 @@ module ClaudeAgentSDK
628
629
  # Async::TimeoutError is a StandardError — a hook's ordinary
629
630
  # `rescue StandardError` would swallow the cancellation and
630
631
  # convert the expired hook into a success (or keep running past
631
- # the deadline). Inject a non-StandardError cancellation
632
- # instead, translated back once control leaves user code so the
633
- # outward contract (Async::TimeoutError) is unchanged. The
634
- # 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
635
638
  # passes through it un-swallowed (InlineCancellation is not a
636
639
  # StandardError, so a wrapper's ordinary rescue can't eat it).
637
- begin
638
- Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
639
- FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
640
- callback.call(hook_input, request_data[:tool_use_id], context)
641
- 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)
642
646
  end
643
- rescue FiberBoundary::InlineCancellation
644
- raise Async::TimeoutError, 'execution expired'
645
647
  end
646
648
  else
647
649
  Async::Task.current.with_timeout(timeout) do
@@ -1005,16 +1007,16 @@ module ClaudeAgentSDK
1005
1007
  # SdkMcpServer#effective_callback_scheduling / _wrapper). Fiber
1006
1008
  # storage is per-fiber, so concurrent sessions cannot see each
1007
1009
  # other's value even across suspension points. The value is a
1008
- # closable SchedulingScope, closed + restored in the ensure below:
1010
+ # closable CallbackDispatchScope, closed + restored in the ensure below:
1009
1011
  # fibers/threads created during the dispatch inherit the same scope
1010
1012
  # OBJECT (storage inheritance copies the hash, shares references), so
1011
1013
  # closing it invalidates the mode for every inheritor at once — a
1012
1014
  # child task that outlives the dispatch cannot carry the session mode
1013
1015
  # into later direct server calls, and nothing stays stamped on
1014
1016
  # long-lived fibers.
1015
- previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
1016
- dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
1017
- 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
1018
1020
 
1019
1021
  # Convert server_name to symbol if needed for hash lookup
1020
1022
  server_key = @sdk_mcp_servers.key?(server_name) ? server_name : server_name.to_sym
@@ -1066,7 +1068,7 @@ module ClaudeAgentSDK
1066
1068
  }
1067
1069
  ensure
1068
1070
  dispatch_scope&.close
1069
- Fiber[FiberBoundary::SCHEDULING_KEY] = previous_scheduling
1071
+ Fiber[FiberBoundary::DISPATCH_KEY] = previous_dispatch
1070
1072
  end
1071
1073
 
1072
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
 
@@ -108,6 +109,14 @@ module ClaudeAgentSDK
108
109
  return nil if options.resume.nil? && !options.continue_conversation
109
110
 
110
111
  timeout_s = options.load_timeout_ms / 1000.0
112
+ # Probed ONCE at materialization entry (the resume path's construction
113
+ # point) so an invalid callback_scheduling declaration fails fast here,
114
+ # before any store IO or temp-dir work.
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
111
120
  project_key = Sessions.project_key_for_directory(options.cwd)
112
121
 
113
122
  resolved =
@@ -116,9 +125,9 @@ module ClaudeAgentSDK
116
125
  # prevent traversal and match every other resume path.
117
126
  return nil unless options.resume.match?(Sessions::UUID_RE)
118
127
 
119
- load_candidate(store, project_key, options.resume, timeout_s)
128
+ load_candidate(store, project_key, options.resume, timeout_s, scheduling, wrapper)
120
129
  else
121
- resolve_continue_candidate(store, project_key, timeout_s)
130
+ resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
122
131
  end
123
132
  return nil if resolved.nil?
124
133
 
@@ -133,7 +142,7 @@ module ClaudeAgentSDK
133
142
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
134
143
  copy_auth_files(tmp_base, options.env)
135
144
 
136
- materialize_subkeys(store, project_dir, project_key, session_id, timeout_s) 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)
137
146
  rescue Exception # rubocop:disable Lint/RescueException
138
147
  # Any failure after mkdtemp leaves tmp_base (which may already hold a
139
148
  # .credentials.json copy) on disk with no path for the caller to clean
@@ -149,8 +158,8 @@ module ClaudeAgentSDK
149
158
  # -- Helpers --
150
159
 
151
160
  # Load entries for session_id; return [session_id, entries] or nil if empty.
152
- def load_candidate(store, project_key, session_id, timeout_s)
153
- entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}") 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
154
163
  store.load('project_key' => project_key, 'session_id' => session_id)
155
164
  end
156
165
  return nil if entries.nil? || entries.empty?
@@ -162,13 +171,13 @@ module ClaudeAgentSDK
162
171
  # transcripts are mirrored as ordinary top-level keys and often have the
163
172
  # highest mtime, so walk newest->oldest and skip them so --continue resumes
164
173
  # the user's conversation, not a subagent's.
165
- def resolve_continue_candidate(store, project_key, timeout_s)
166
- sessions = with_timeout(timeout_s, 'SessionStore#list_sessions') 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
167
176
  store.list_sessions(project_key)
168
177
  end
169
178
  return nil if sessions.nil? || sessions.empty?
170
179
 
171
- sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s)
180
+ sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
172
181
 
173
182
  sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
174
183
  sid = cand['session_id']
@@ -178,7 +187,7 @@ module ClaudeAgentSDK
178
187
  # --continue O(sum of transcript sizes) instead of O(candidates).
179
188
  next if sidechain_flags&.fetch(sid, false)
180
189
 
181
- loaded = load_candidate(store, project_key, sid, timeout_s)
190
+ loaded = load_candidate(store, project_key, sid, timeout_s, scheduling, wrapper)
182
191
  next if loaded.nil?
183
192
 
184
193
  first = loaded[1][0]
@@ -194,10 +203,10 @@ module ClaudeAgentSDK
194
203
  # fails (callers then fall back to checking each full load). The per-load
195
204
  # isSidechain check above stays even on the summary path: a missing or
196
205
  # stale sidecar row costs one extra load, never a wrong resume.
197
- def sidechain_flags_from_summaries(store, project_key, timeout_s)
206
+ def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
198
207
  return nil unless SessionStore.implements?(store, :list_session_summaries)
199
208
 
200
- rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries') do
209
+ rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do
201
210
  store.list_session_summaries(project_key)
202
211
  end
203
212
  Array(rows).each_with_object({}) do |row, acc|
@@ -227,13 +236,19 @@ module ClaudeAgentSDK
227
236
 
228
237
  # Run a store call (user code) on a plain thread bounded by timeout_s,
229
238
  # re-raising failures/timeouts as RuntimeError with context. The thread hop
230
- # (FiberBoundary with a timeout always hops) both keeps the async scheduler
231
- # out of the user's store code AND enforces load_timeout_ms unconditionally
232
- # — including when materialization runs outside an Async reactor, where a
233
- # direct call would let a hung adapter block connect forever. A timed-out
234
- # worker is left running (not killed) since it may still complete.
235
- def with_timeout(timeout_s, what, &block)
236
- FiberBoundary.invoke(timeout: timeout_s, &block)
239
+ # (the default for FiberBoundary with a timeout) both keeps the async
240
+ # scheduler out of the user's store code AND enforces load_timeout_ms
241
+ # unconditionally — including when materialization runs outside an Async
242
+ # reactor, where a direct call would let a hung adapter block connect
243
+ # forever. A timed-out worker is left running (not killed) since it may
244
+ # still complete. An adapter declaring `callback_scheduling -> :inline`
245
+ # (probed once at materialization entry) instead runs in place under a
246
+ # cooperative timeout when a reactor is present — outside one, the hard
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)
237
252
  rescue FiberBoundary::JoinTimeout
238
253
  raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
239
254
  rescue RuntimeError
@@ -363,9 +378,9 @@ module ClaudeAgentSDK
363
378
  end
364
379
 
365
380
  # Load and write all subagent transcripts/metadata under session_id.
366
- def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s)
381
+ def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
367
382
  session_dir = File.join(project_dir, session_id)
368
- subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}") do
383
+ subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do
369
384
  store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
370
385
  end
371
386
 
@@ -377,7 +392,7 @@ module ClaudeAgentSDK
377
392
  next
378
393
  end
379
394
 
380
- sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}") do
395
+ sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling, wrapper) do
381
396
  store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
382
397
  end
383
398
  next if sub_entries.nil? || sub_entries.empty?
@@ -32,6 +32,32 @@ module ClaudeAgentSDK
32
32
  # - entries: raw JSONL transcript objects (opaque pass-through blobs)
33
33
  # - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
34
34
  # - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
35
+ #
36
+ # FIBER-NATIVE ADAPTERS (issue #47 phase 3): an adapter whose IO is
37
+ # entirely Fiber-scheduler-aware may additionally define an optional
38
+ # `callback_scheduling` method returning `:inline` (`:thread` = default
39
+ # behavior). The SDK then runs the adapter's timeout-bounded calls in place
40
+ # on the reactor fiber under a cooperative timeout instead of on a
41
+ # throwaway thread with a hard `Thread#join` bound. The declaration covers
42
+ # EVERY method the SDK invokes on the adapter — append, load,
43
+ # list_sessions, list_session_summaries, list_subkeys — not just
44
+ # append/load: resume materialization inlines the listing calls too. Only
45
+ # the adapter author can make this call — declare :inline ONLY if every
46
+ # blocking operation in every method yields to the scheduler;
47
+ # scheduler-opaque blocking stalls every job on the worker AND escapes the
48
+ # cooperative deadline. A timed-out inline call is interrupted at its next
49
+ # suspension point (ensure blocks run) rather than abandoned. The
50
+ # cancellation reaches only the adapter's own fiber — work the adapter
51
+ # offloaded (descendant tasks, an already-issued remote write) may still
52
+ # land afterwards — so timed-out appends are NOT retried (same as thread
53
+ # mode) and a cancelled append may remain permanently half-applied in the
54
+ # store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
55
+ # the local transcript remains the source of truth; the
56
+ # dedupe-by-entry-uuid recommendation above stays advisory. The method is deliberately NOT defined here: the SDK probes
57
+ # `respond_to?(:callback_scheduling)` (see
58
+ # SessionStores.store_callback_scheduling), so pure duck-typed adapters
59
+ # stay minimal, and an app can opt a third-party fiber-native adapter in
60
+ # via a singleton method (`def store.callback_scheduling = :inline`).
35
61
  class SessionStore
36
62
  # True if +store+ overrides +method+ rather than inheriting the base
37
63
  # implementation that raises NotImplementedError. Works for both subclasses
@@ -249,8 +275,31 @@ module ClaudeAgentSDK
249
275
 
250
276
  # Internal SessionStore support functions (path mapping, option validation).
251
277
  module SessionStores
278
+ STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
279
+
252
280
  module_function
253
281
 
282
+ # Where an adapter's timeout-bounded #append/#load calls run: :thread
283
+ # (default — hard-bounded thread hop) unless the adapter declares
284
+ # fiber-nativeness via an optional `callback_scheduling` method returning
285
+ # :inline (String form coerced, matching ClaudeAgentOptions). Probed
286
+ # respond_to?-style like the rest of the subsystem. Called once at
287
+ # construction time (batcher initialize, resume-materialization entry)
288
+ # so an invalid declaration fails fast there, not mid-session.
289
+ def store_callback_scheduling(store)
290
+ return :thread unless store.respond_to?(:callback_scheduling)
291
+
292
+ value = store.callback_scheduling
293
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
294
+ unless STORE_CALLBACK_SCHEDULING_MODES.include?(mode)
295
+ raise ArgumentError,
296
+ 'session_store#callback_scheduling must return one of ' \
297
+ "#{STORE_CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} (got #{value.inspect})"
298
+ end
299
+
300
+ mode
301
+ end
302
+
254
303
  # Derive a SessionKey from an absolute transcript file path.
255
304
  #
256
305
  # Main: <projects_dir>/<project_key>/<session_id>.jsonl
@@ -13,9 +13,11 @@ module ClaudeAgentSDK
13
13
 
14
14
  module_function
15
15
 
16
- # Assert the 16 SessionStore behavioral contracts against an adapter.
16
+ # Assert the 17 SessionStore behavioral contracts against an adapter.
17
17
  #
18
18
  # Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
19
+ # Contract 17 (Ruby extension) validates the optional
20
+ # `callback_scheduling` fiber-nativeness declaration (:thread/:inline).
19
21
  # Contract 16 (Ruby extension) locks one-row-per-session `list_sessions`
20
22
  # under multiple appends — the naive one-row-per-append implementation
21
23
  # passed every other contract and then showed N duplicate sessions in
@@ -62,6 +64,7 @@ module ClaudeAgentSDK
62
64
  has_delete = optional?(probe, 'delete', skip_optional)
63
65
  has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional)
64
66
 
67
+ check_callback_scheduling_declaration(fresh)
65
68
  check_append_and_load(fresh, has_list_sessions)
66
69
  check_list_sessions(fresh) if has_list_sessions
67
70
  check_list_session_summaries(fresh, has_list_sessions, has_delete) if has_list_summaries
@@ -71,6 +74,30 @@ module ClaudeAgentSDK
71
74
  nil
72
75
  end
73
76
 
77
+ # -- Optional: callback_scheduling declaration ---------------------------
78
+
79
+ # 17. an adapter declaring the optional callback_scheduling method (issue
80
+ # #47 phase 3: fiber-native adapters) must return :thread or :inline —
81
+ # the SDK probes it at construction and raises ArgumentError otherwise,
82
+ # so a bad declaration would fail every session using the store. Runs
83
+ # first because the SDK probes before any store IO; skipped entirely for
84
+ # non-declaring adapters (the probe defaults to :thread).
85
+ def check_callback_scheduling_declaration(fresh)
86
+ store = fresh.call
87
+ return unless store.respond_to?(:callback_scheduling)
88
+
89
+ begin
90
+ SessionStores.store_callback_scheduling(store)
91
+ rescue ArgumentError => e
92
+ assert(false, "callback_scheduling declaration must be :thread or :inline (#{e.message})")
93
+ rescue StandardError, NotImplementedError => e
94
+ # A raising declaration is as fatal as a bad value — the SDK probes
95
+ # it at construction. Report through the harness's documented
96
+ # ConformanceError instead of leaking the raw exception.
97
+ assert(false, "callback_scheduling declaration raised #{e.class}: #{e.message}")
98
+ end
99
+ end
100
+
74
101
  # -- Required: append + load -------------------------------------------
75
102
 
76
103
  def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/MethodLength
@@ -347,8 +374,9 @@ module ClaudeAgentSDK
347
374
  "SessionStore conformance failed: #{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}"
348
375
  end
349
376
 
350
- private_class_method :check_append_and_load, :check_list_sessions, :check_list_session_summaries,
351
- :check_delete, :check_list_subkeys, :check_uuid_dedupe_contract, :key, :entry,
377
+ private_class_method :check_callback_scheduling_declaration, :check_append_and_load, :check_list_sessions,
378
+ :check_list_session_summaries, :check_delete, :check_list_subkeys,
379
+ :check_uuid_dedupe_contract, :key, :entry,
352
380
  :epoch_ms?, :optional?, :summaries_by_id, :assert, :assert_eq
353
381
  end
354
382
  end
@@ -28,7 +28,14 @@ module ClaudeAgentSDK
28
28
  # The semaphore serializes appends, but a #send that exceeds send_timeout is
29
29
  # abandoned (its worker thread keeps running) and the next drain proceeds, so
30
30
  # two #append calls for the SAME key can briefly overlap. SessionStore#append
31
- # must be thread-safe per key (see that method's contract).
31
+ # must be thread-safe per key (see that method's contract). For adapters
32
+ # declaring `callback_scheduling -> :inline` the timed-out call is instead
33
+ # cancelled cooperatively (interrupted at its next suspension point, ensure
34
+ # runs) — but only on the adapter's own fiber; work the adapter offloaded
35
+ # may still land, so timeouts are not retried in either mode and the
36
+ # cancelled append may remain permanently HALF-applied in the store. The
37
+ # drop is surfaced (MirrorErrorMessage, batches_dropped?); the local
38
+ # transcript remains the source of truth.
32
39
  class TranscriptMirrorBatcher
33
40
  # Eager-flush thresholds (exposed for tests).
34
41
  MAX_PENDING_ENTRIES = 500
@@ -44,12 +51,22 @@ module ClaudeAgentSDK
44
51
  # @param projects_dir [String] base dir for file_path -> SessionKey mapping
45
52
  # @param on_error [#call] called as on_error.call(key, message) after a batch
46
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
47
59
  def initialize(store:, projects_dir:, on_error:, send_timeout: SEND_TIMEOUT_SECONDS,
48
- 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)
49
62
  @store = store
50
63
  @projects_dir = projects_dir
51
64
  @on_error = on_error
52
65
  @send_timeout = send_timeout
66
+ @callback_wrapper = callback_wrapper
67
+ # Probed ONCE here so an invalid callback_scheduling declaration fails
68
+ # at construction, not mid-session inside a flush.
69
+ @store_scheduling = SessionStores.store_callback_scheduling(store)
53
70
  @max_pending_entries = max_pending_entries
54
71
  @max_pending_bytes = max_pending_bytes
55
72
  @pending = []
@@ -203,9 +220,18 @@ module ClaudeAgentSDK
203
220
  succeeded = true
204
221
  break
205
222
  when :timeout
206
- # Don't retry on timeout: the in-flight call may still land, so a
207
- # retry would launch a concurrent duplicate. Also bounds worst-case
208
- # lock hold at ~send_timeout rather than ~3x.
223
+ # Don't retry on timeout in EITHER mode. Thread mode: the
224
+ # abandoned in-flight call may still land, so a retry would launch
225
+ # a concurrent duplicate. Inline mode: cooperative cancellation
226
+ # interrupts only the adapter's own fiber — work the adapter
227
+ # itself offloaded (descendant tasks, an already-issued remote
228
+ # write) may still land after the cancellation, so a retry races
229
+ # it exactly like the thread case (adversarially demonstrated in
230
+ # review: a dedupe-conforming adapter still persisted duplicates).
231
+ # Uniform no-retry also bounds worst-case lock hold at
232
+ # ~send_timeout. The dropped batch is surfaced (MirrorErrorMessage,
233
+ # batches_dropped?) and the local transcript remains the source of
234
+ # truth.
209
235
  last_err = err
210
236
  break
211
237
  else # :error — retryable
@@ -222,11 +248,20 @@ module ClaudeAgentSDK
222
248
 
223
249
  # Run SessionStore#append (user code) on a plain thread via FiberBoundary,
224
250
  # bounded by send_timeout (enforced with or without an active reactor).
225
- # Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout the
226
- # worker thread is left running (cancellation is best-effort; the in-flight
227
- # call may still land) and not retried.
251
+ # Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout in
252
+ # thread mode the worker thread is left running (cancellation is
253
+ # best-effort; the in-flight call may still land) and not retried. An
254
+ # adapter declaring `callback_scheduling -> :inline` instead runs in
255
+ # place on the reactor under a cooperative timeout (interrupted at its
256
+ # next suspension point, ensure blocks run) — same JoinTimeout contract
257
+ # and same no-retry semantics (see append_with_retry: offloaded work
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.
228
261
  def invoke_append(key, entries)
229
- FiberBoundary.invoke(timeout: @send_timeout) { @store.append(key, entries) }
262
+ FiberBoundary.invoke(timeout: @send_timeout, scheduling: @store_scheduling, wrapper: @callback_wrapper) do
263
+ @store.append(key, entries)
264
+ end
230
265
  [:ok, nil]
231
266
  rescue FiberBoundary::JoinTimeout
232
267
  [:timeout, "append timed out after #{@send_timeout}s"]
@@ -290,9 +290,11 @@ module ClaudeAgentSDK
290
290
  attr_accessor :uuid, :session_id, :content
291
291
  end
292
292
 
293
- # Emitted when a session_store mirror batch exhausts its retries and is
294
- # dropped. The local-disk transcript is still durable; this is the consumer's
295
- # only signal that the external store missed a batch (at-most-once delivery).
293
+ # Emitted when a session_store mirror batch fails terminally and is
294
+ # dropped timeouts immediately (never retried), other failures after up
295
+ # to three attempts. The local-disk transcript is still durable; this is
296
+ # the consumer's only signal that the external store missed a batch
297
+ # (at-most-once delivery).
296
298
  class MirrorErrorMessage < SystemMessage
297
299
  attr_accessor :uuid, :session_id, :error, :key
298
300
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.26.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.26.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors