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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +1 -1
- data/docs/rails.md +3 -1
- data/docs/sessions.md +50 -1
- data/lib/claude_agent_sdk/fiber_boundary.rb +85 -21
- data/lib/claude_agent_sdk/query.rb +20 -18
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +31 -4
- data/lib/claude_agent_sdk/session_resume.rb +38 -23
- data/lib/claude_agent_sdk/session_store.rb +49 -0
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +31 -3
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +44 -9
- data/lib/claude_agent_sdk/types.rb +5 -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,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.
|
|
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`)
|
|
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
|
-
> (
|
|
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
|
-
#
|
|
41
|
+
# DISPATCH_KEY / CallbackDispatchScope below.
|
|
40
42
|
#
|
|
41
|
-
# A `timeout:`
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
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
|
|
69
|
-
#
|
|
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
|
|
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
|
-
|
|
98
|
+
DISPATCH_KEY = :claude_agent_sdk_callback_dispatch
|
|
88
99
|
|
|
89
|
-
# The value stored under
|
|
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
|
|
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
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
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
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
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
|
|
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
|
|
274
|
-
#
|
|
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).
|
|
632
|
-
#
|
|
633
|
-
#
|
|
634
|
-
#
|
|
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
|
-
|
|
638
|
-
Async::Task.current
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
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
|
-
|
|
1016
|
-
dispatch_scope = FiberBoundary::
|
|
1017
|
-
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
|
|
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::
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
231
|
-
# out of the user's store code AND enforces load_timeout_ms
|
|
232
|
-
# — including when materialization runs outside an Async
|
|
233
|
-
# direct call would let a hung adapter block connect
|
|
234
|
-
# worker is left running (not killed) since it may
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
# 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 :
|
|
351
|
-
:
|
|
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
|
|
207
|
-
#
|
|
208
|
-
#
|
|
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
|
|
226
|
-
# worker thread is left running (cancellation is
|
|
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
|
|
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
|
|
294
|
-
# dropped
|
|
295
|
-
#
|
|
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
|
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
|