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 +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +1 -1
- data/docs/rails.md +3 -1
- data/docs/sessions.md +6 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +53 -25
- data/lib/claude_agent_sdk/query.rb +17 -21
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +31 -4
- data/lib/claude_agent_sdk/session_resume.rb +26 -19
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +14 -3
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +14 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a12b68451568e5428820499fb4bf42401596477ea74d6b25a8fad3637b70178
|
|
4
|
+
data.tar.gz: d0465a73a3cbe17d3775d65d83978c16527548f9f752f8786b606f2c8c1c3713
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
98
|
+
DISPATCH_KEY = :claude_agent_sdk_callback_dispatch
|
|
99
99
|
|
|
100
|
-
# The value stored under
|
|
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
|
|
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
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
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
|
|
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
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
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
|
-
|
|
197
|
-
|
|
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).
|
|
633
|
-
#
|
|
634
|
-
#
|
|
635
|
-
#
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
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
|
-
|
|
1022
|
-
dispatch_scope = FiberBoundary::
|
|
1023
|
-
Fiber[FiberBoundary::
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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::
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
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"]
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -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
|
-
|
|
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
|