claude-agent-sdk 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4e4fcd2fa1c18916f41409482c8edfe3367ea5b06db9a90b80ed168f339a45e
4
- data.tar.gz: 3726daa4aa431b4dba5cd181433066bc8f541efbe13984799c8642bfc671e75e
3
+ metadata.gz: 760ec1fb0cf80f6e56d7b5d3adb20ca2956b209d0961020ae36fa52a13e28144
4
+ data.tar.gz: 6ba365de4438e4081f9bfa47afadc4859d9a001bb6238c0b66f00437cb032626
5
5
  SHA512:
6
- metadata.gz: 00cb37c301d1c1eae778bd55b0d19330e21b6069a0943a6cd7a7931da4d32d8da847c8362f102d530aed8cdcf66f28eb9ca57ac248ed5ce2cbcd5609b3435ab0
7
- data.tar.gz: 0bcabed11ba7e49c78ab383bf1186d39cd65873e1190f13a7cbfc102f1c2c7f0226fc8604818f3fc4cf3706760334abb5bc82a7c9ab6e6c9d1e178fd3a1c3002
6
+ metadata.gz: 672d6640b3bdefc6c5d9cf599c41b89b6259107f780981da382b18bfa56180788225d5e278ab9a7ef22e79a09a000b48147a1a285b74182b5f4254d8d8ec474e
7
+ data.tar.gz: 0b6eff840de87f744de373bea259fd89a36ca1af33fbc2c678ac84296115b9a5fad841941feabaeb8ebda9c8333d6f4d72a380379c09c410e6358c571af1b26b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - **Fiber-native SessionStore adapters** (#47 phase 3): an adapter whose IO is entirely Fiber-scheduler-aware can declare it by defining an optional `callback_scheduling` method returning `:inline`. Every timeout-bounded store call the SDK makes (mirror-batcher appends; resume-materialization loads **and** the listing methods `list_sessions` / `list_session_summaries` / `list_subkeys`) then runs in place on the reactor fiber under a **cooperative** timeout — interrupted at the next suspension point with `ensure` blocks running — instead of on a throwaway thread with a hard `Thread#join` bound. The outward exception contract is unchanged (`FiberBoundary::JoinTimeout`); undeclared adapters are byte-for-byte untouched, and outside a reactor the hard bound still applies even for declared adapters. Cancellation reaches only the adapter's own fiber (offloaded work may still land afterwards), so timed-out appends are not retried in either mode and may remain permanently half-applied in the store — the drop is surfaced (`MirrorErrorMessage`, `batches_dropped?`) and the local transcript remains the source of truth. Invalid or raising declarations fail fast (`ArgumentError` at construction; conformance contract 17). Apps can opt a third-party fiber-native adapter in via `def store.callback_scheduling = :inline`.
14
+
10
15
  ## [0.26.0] - 2026-07-31
11
16
 
12
17
  ### Added
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.26.0'
71
+ gem 'claude-agent-sdk', '~> 0.27.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
data/docs/rails.md CHANGED
@@ -80,7 +80,7 @@ end
80
80
 
81
81
  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
82
 
83
- Note that `SessionStore` adapter calls (`#append` / `#load`) intentionally stay on threads even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor.
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.
84
84
 
85
85
  ## ActionCable Streaming
86
86
 
data/docs/sessions.md CHANGED
@@ -154,7 +154,8 @@ during resume materialization, default `60_000`).
154
154
  > Python and TypeScript SDKs.
155
155
  >
156
156
  > The temp dir is deleted at disconnect — **unless the mirror dropped batches**
157
- > (adapter failures that exhausted retries, surfaced as `MirrorErrorMessage`):
157
+ > (terminal append failures timeouts immediately, other failures after up to
158
+ > three attempts — surfaced as `MirrorErrorMessage`):
158
159
  > the store copy is then incomplete and the temp dir holds the only copy of the
159
160
  > dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
160
161
  > and warns with the preserved path so you can import them into the store.
@@ -175,6 +176,48 @@ 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
+
178
221
  ### Store-backed helpers
179
222
 
180
223
  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
  #
@@ -38,11 +40,19 @@ module ClaudeAgentSDK
38
40
  # it via a scoped, invalidatable fiber-storage entry instead — see
39
41
  # SCHEDULING_KEY / SchedulingScope below.
40
42
  #
41
- # A `timeout:` always forces the thread hop regardless of scheduling
42
- # `Thread#join(timeout)` is a hard bound that can abandon a wedged call,
43
- # which cooperative `with_timeout` cancellation cannot guarantee. The
44
- # store-adapter call sites (TranscriptMirrorBatcher, SessionResume) rely
45
- # on this to never stall the reactor on a wedged adapter.
43
+ # A `timeout:` forces the thread hop `Thread#join(timeout)` is a hard
44
+ # bound that can abandon a wedged call, which cooperative `with_timeout`
45
+ # cancellation cannot guarantee — unless the caller passes
46
+ # `scheduling: :inline` inside a reactor, where the bound becomes a
47
+ # cooperative `with_timeout` cancellation instead (issue #47 phase 3:
48
+ # fiber-native SessionStore adapters that declare
49
+ # `callback_scheduling -> :inline`). The store-adapter call sites
50
+ # (TranscriptMirrorBatcher, SessionResume) default to the thread hop so
51
+ # an undeclared (possibly scheduler-opaque) adapter can never stall the
52
+ # reactor; a declared-inline adapter accepts that a scheduler-opaque
53
+ # blocking call would stall it AND escape the cooperative deadline.
54
+ # Outside a reactor the hard bound applies even to inline-declared
55
+ # adapters — the timeout guarantee is never lost.
46
56
  #
47
57
  # The thread hop severs `break`/`return`/`next` from the surrounding method,
48
58
  # so SDK loops yielding user callbacks must keep loop control outside the
@@ -65,8 +75,9 @@ module ClaudeAgentSDK
65
75
  class JoinTimeout < StandardError; end
66
76
 
67
77
  # Cancellation injected into INLINE user callbacks by timeout
68
- # enforcement (hook timeouts under scheduling: :inline) — user code
69
- # should let it propagate. Deliberately
78
+ # enforcement (hook timeouts under scheduling: :inline; store-adapter
79
+ # timeouts for adapters declaring callback_scheduling :inline) — user
80
+ # code should let it propagate. Deliberately
70
81
  # NOT a StandardError: the exception is raised inside user code at a
71
82
  # suspension point, and a callback's ordinary `rescue StandardError`
72
83
  # must not be able to swallow the cancellation and convert an expired
@@ -133,10 +144,16 @@ module ClaudeAgentSDK
133
144
  # Run the given block on a plain thread when a Fiber scheduler is active.
134
145
  # Returns the block's value. Exceptions propagate to the caller.
135
146
  #
136
- # With +timeout+ (seconds) the thread hop happens unconditionally even
137
- # without a scheduler or with `scheduling: :inline` — so the bound is
138
- # enforced in plain synchronous code too; JoinTimeout is raised when it
139
- # expires.
147
+ # With +timeout+ (seconds) the thread hop happens regardless of a
148
+ # scheduler being active, so the bound is enforced in plain synchronous
149
+ # code too; JoinTimeout is raised when it expires. Exception: with
150
+ # `scheduling: :inline` INSIDE an Async task, the block instead runs in
151
+ # place under a cooperative `with_timeout` — the deadline is delivered
152
+ # as InlineCancellation at the block's next suspension point (its
153
+ # ensure blocks run; not swallowable by `rescue StandardError`) and
154
+ # surfaces as the same JoinTimeout, so callers need no changes. When
155
+ # inline is requested but no Async task is present, the hard
156
+ # thread-hop bound applies — the timeout guarantee is never lost.
140
157
  #
141
158
  # With `scheduling: :inline` (and no timeout) the block runs in place on
142
159
  # the current fiber, scheduler or not. The caller opts in via
@@ -165,6 +182,25 @@ module ClaudeAgentSDK
165
182
  body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
166
183
  return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
167
184
 
185
+ # Cooperative timeout for inline-declared store adapters: in place on
186
+ # the reactor fiber, cancelled at the next suspension point. The
187
+ # wrapper stays ignored on timeout paths (store adapters are never
188
+ # wrapped), so `body` is the bare block here. The cancellation class
189
+ # is a fresh per-invocation subclass: rescuing the shared base would
190
+ # also catch an OUTER timeout's cancellation delivered while this
191
+ # call is suspended (nested with_timeout — e.g. a store call inside a
192
+ # timed inline hook), mis-attributing the outer deadline to this call
193
+ # and letting execution continue past it. An outer cancellation is a
194
+ # different subclass, so it propagates through untouched.
195
+ if timeout && scheduling == :inline && (task = Async::Task.current?)
196
+ cancellation = Class.new(InlineCancellation)
197
+ begin
198
+ return task.with_timeout(timeout, cancellation, &block)
199
+ rescue cancellation
200
+ raise JoinTimeout, "timed out after #{timeout}s"
201
+ end
202
+ end
203
+
168
204
  thread = Thread.new(&body)
169
205
  thread.report_on_exception = false
170
206
  return thread.value if timeout.nil?
@@ -270,8 +270,9 @@ module ClaudeAgentSDK
270
270
  end
271
271
 
272
272
  # Synthesize a `mirror_error` system message and put it on the SDK message
273
- # stream so consumers learn a mirror batch was dropped after exhausting
274
- # retries. Non-blocking: the message queue is unbounded, so unlike the
273
+ # stream so consumers learn a mirror batch was dropped (timeouts
274
+ # immediately, other failures after up to three attempts).
275
+ # Non-blocking: the message queue is unbounded, so unlike the
275
276
  # Python SDK there is no buffer-full drop path.
276
277
  def report_mirror_error(key, error)
277
278
  session_id = key && (key['session_id'] || key[:session_id])
@@ -634,13 +635,18 @@ module ClaudeAgentSDK
634
635
  # wrapper composes INSIDE with_timeout, and the cancellation
635
636
  # passes through it un-swallowed (InlineCancellation is not a
636
637
  # StandardError, so a wrapper's ordinary rescue can't eat it).
638
+ # Fresh per-invocation subclass: rescuing the shared base would
639
+ # also catch an OUTER timeout's cancellation (e.g. an inline
640
+ # store call nested inside this hook already uses its own), so
641
+ # each timeout scope must only consume its own deadline.
637
642
  begin
638
- Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
643
+ cancellation = Class.new(FiberBoundary::InlineCancellation)
644
+ Async::Task.current.with_timeout(timeout, cancellation) do
639
645
  FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
640
646
  callback.call(hook_input, request_data[:tool_use_id], context)
641
647
  end
642
648
  end
643
- rescue FiberBoundary::InlineCancellation
649
+ rescue cancellation
644
650
  raise Async::TimeoutError, 'execution expired'
645
651
  end
646
652
  else
@@ -108,6 +108,10 @@ module ClaudeAgentSDK
108
108
  return nil if options.resume.nil? && !options.continue_conversation
109
109
 
110
110
  timeout_s = options.load_timeout_ms / 1000.0
111
+ # Probed ONCE at materialization entry (the resume path's construction
112
+ # point) so an invalid callback_scheduling declaration fails fast here,
113
+ # before any store IO or temp-dir work.
114
+ scheduling = SessionStores.store_callback_scheduling(store)
111
115
  project_key = Sessions.project_key_for_directory(options.cwd)
112
116
 
113
117
  resolved =
@@ -116,9 +120,9 @@ module ClaudeAgentSDK
116
120
  # prevent traversal and match every other resume path.
117
121
  return nil unless options.resume.match?(Sessions::UUID_RE)
118
122
 
119
- load_candidate(store, project_key, options.resume, timeout_s)
123
+ load_candidate(store, project_key, options.resume, timeout_s, scheduling)
120
124
  else
121
- resolve_continue_candidate(store, project_key, timeout_s)
125
+ resolve_continue_candidate(store, project_key, timeout_s, scheduling)
122
126
  end
123
127
  return nil if resolved.nil?
124
128
 
@@ -133,7 +137,7 @@ module ClaudeAgentSDK
133
137
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
134
138
  copy_auth_files(tmp_base, options.env)
135
139
 
136
- materialize_subkeys(store, project_dir, project_key, session_id, timeout_s) if SessionStore.implements?(store, :list_subkeys)
140
+ materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling) if SessionStore.implements?(store, :list_subkeys)
137
141
  rescue Exception # rubocop:disable Lint/RescueException
138
142
  # Any failure after mkdtemp leaves tmp_base (which may already hold a
139
143
  # .credentials.json copy) on disk with no path for the caller to clean
@@ -149,8 +153,8 @@ module ClaudeAgentSDK
149
153
  # -- Helpers --
150
154
 
151
155
  # 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
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
154
158
  store.load('project_key' => project_key, 'session_id' => session_id)
155
159
  end
156
160
  return nil if entries.nil? || entries.empty?
@@ -162,13 +166,13 @@ module ClaudeAgentSDK
162
166
  # transcripts are mirrored as ordinary top-level keys and often have the
163
167
  # highest mtime, so walk newest->oldest and skip them so --continue resumes
164
168
  # 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
169
+ def resolve_continue_candidate(store, project_key, timeout_s, scheduling)
170
+ sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling) do
167
171
  store.list_sessions(project_key)
168
172
  end
169
173
  return nil if sessions.nil? || sessions.empty?
170
174
 
171
- sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s)
175
+ sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
172
176
 
173
177
  sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
174
178
  sid = cand['session_id']
@@ -178,7 +182,7 @@ module ClaudeAgentSDK
178
182
  # --continue O(sum of transcript sizes) instead of O(candidates).
179
183
  next if sidechain_flags&.fetch(sid, false)
180
184
 
181
- loaded = load_candidate(store, project_key, sid, timeout_s)
185
+ loaded = load_candidate(store, project_key, sid, timeout_s, scheduling)
182
186
  next if loaded.nil?
183
187
 
184
188
  first = loaded[1][0]
@@ -194,10 +198,10 @@ module ClaudeAgentSDK
194
198
  # fails (callers then fall back to checking each full load). The per-load
195
199
  # isSidechain check above stays even on the summary path: a missing or
196
200
  # stale sidecar row costs one extra load, never a wrong resume.
197
- def sidechain_flags_from_summaries(store, project_key, timeout_s)
201
+ def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
198
202
  return nil unless SessionStore.implements?(store, :list_session_summaries)
199
203
 
200
- rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries') do
204
+ rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling) do
201
205
  store.list_session_summaries(project_key)
202
206
  end
203
207
  Array(rows).each_with_object({}) do |row, acc|
@@ -227,13 +231,17 @@ module ClaudeAgentSDK
227
231
 
228
232
  # Run a store call (user code) on a plain thread bounded by timeout_s,
229
233
  # re-raising failures/timeouts as RuntimeError with context. The thread hop
230
- # (FiberBoundary with a timeout always hops) both keeps the async scheduler
231
- # out of the user's store code AND enforces load_timeout_ms unconditionally
232
- # — including when materialization runs outside an Async reactor, where a
233
- # direct call would let a hung adapter block connect forever. A timed-out
234
- # worker is left running (not killed) since it may still complete.
235
- def with_timeout(timeout_s, what, &block)
236
- FiberBoundary.invoke(timeout: timeout_s, &block)
234
+ # (the default for FiberBoundary with a timeout) both keeps the async
235
+ # scheduler out of the user's store code AND enforces load_timeout_ms
236
+ # unconditionally — including when materialization runs outside an Async
237
+ # reactor, where a direct call would let a hung adapter block connect
238
+ # forever. A timed-out worker is left running (not killed) since it may
239
+ # still complete. An adapter declaring `callback_scheduling -> :inline`
240
+ # (probed once at materialization entry) instead runs in place under a
241
+ # cooperative timeout when a reactor is present — outside one, the hard
242
+ # thread-hop bound still applies.
243
+ def with_timeout(timeout_s, what, scheduling = :thread, &block)
244
+ FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, &block)
237
245
  rescue FiberBoundary::JoinTimeout
238
246
  raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
239
247
  rescue RuntimeError
@@ -363,9 +371,9 @@ module ClaudeAgentSDK
363
371
  end
364
372
 
365
373
  # Load and write all subagent transcripts/metadata under session_id.
366
- def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s)
374
+ def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling)
367
375
  session_dir = File.join(project_dir, session_id)
368
- subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}") do
376
+ subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling) do
369
377
  store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
370
378
  end
371
379
 
@@ -377,7 +385,7 @@ module ClaudeAgentSDK
377
385
  next
378
386
  end
379
387
 
380
- sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}") do
388
+ sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling) do
381
389
  store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
382
390
  end
383
391
  next if sub_entries.nil? || sub_entries.empty?
@@ -32,6 +32,32 @@ module ClaudeAgentSDK
32
32
  # - entries: raw JSONL transcript objects (opaque pass-through blobs)
33
33
  # - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
34
34
  # - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
35
+ #
36
+ # FIBER-NATIVE ADAPTERS (issue #47 phase 3): an adapter whose IO is
37
+ # entirely Fiber-scheduler-aware may additionally define an optional
38
+ # `callback_scheduling` method returning `:inline` (`:thread` = default
39
+ # behavior). The SDK then runs the adapter's timeout-bounded calls in place
40
+ # on the reactor fiber under a cooperative timeout instead of on a
41
+ # throwaway thread with a hard `Thread#join` bound. The declaration covers
42
+ # EVERY method the SDK invokes on the adapter — append, load,
43
+ # list_sessions, list_session_summaries, list_subkeys — not just
44
+ # append/load: resume materialization inlines the listing calls too. Only
45
+ # the adapter author can make this call — declare :inline ONLY if every
46
+ # blocking operation in every method yields to the scheduler;
47
+ # scheduler-opaque blocking stalls every job on the worker AND escapes the
48
+ # cooperative deadline. A timed-out inline call is interrupted at its next
49
+ # suspension point (ensure blocks run) rather than abandoned. The
50
+ # cancellation reaches only the adapter's own fiber — work the adapter
51
+ # offloaded (descendant tasks, an already-issued remote write) may still
52
+ # land afterwards — so timed-out appends are NOT retried (same as thread
53
+ # mode) and a cancelled append may remain permanently half-applied in the
54
+ # store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
55
+ # the local transcript remains the source of truth; the
56
+ # dedupe-by-entry-uuid recommendation above stays advisory. The method is deliberately NOT defined here: the SDK probes
57
+ # `respond_to?(:callback_scheduling)` (see
58
+ # SessionStores.store_callback_scheduling), so pure duck-typed adapters
59
+ # stay minimal, and an app can opt a third-party fiber-native adapter in
60
+ # via a singleton method (`def store.callback_scheduling = :inline`).
35
61
  class SessionStore
36
62
  # True if +store+ overrides +method+ rather than inheriting the base
37
63
  # implementation that raises NotImplementedError. Works for both subclasses
@@ -249,8 +275,31 @@ module ClaudeAgentSDK
249
275
 
250
276
  # Internal SessionStore support functions (path mapping, option validation).
251
277
  module SessionStores
278
+ STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
279
+
252
280
  module_function
253
281
 
282
+ # Where an adapter's timeout-bounded #append/#load calls run: :thread
283
+ # (default — hard-bounded thread hop) unless the adapter declares
284
+ # fiber-nativeness via an optional `callback_scheduling` method returning
285
+ # :inline (String form coerced, matching ClaudeAgentOptions). Probed
286
+ # respond_to?-style like the rest of the subsystem. Called once at
287
+ # construction time (batcher initialize, resume-materialization entry)
288
+ # so an invalid declaration fails fast there, not mid-session.
289
+ def store_callback_scheduling(store)
290
+ return :thread unless store.respond_to?(:callback_scheduling)
291
+
292
+ value = store.callback_scheduling
293
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
294
+ unless STORE_CALLBACK_SCHEDULING_MODES.include?(mode)
295
+ raise ArgumentError,
296
+ 'session_store#callback_scheduling must return one of ' \
297
+ "#{STORE_CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} (got #{value.inspect})"
298
+ end
299
+
300
+ mode
301
+ end
302
+
254
303
  # Derive a SessionKey from an absolute transcript file path.
255
304
  #
256
305
  # Main: <projects_dir>/<project_key>/<session_id>.jsonl
@@ -13,9 +13,11 @@ module ClaudeAgentSDK
13
13
 
14
14
  module_function
15
15
 
16
- # Assert the 16 SessionStore behavioral contracts against an adapter.
16
+ # Assert the 17 SessionStore behavioral contracts against an adapter.
17
17
  #
18
18
  # Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
19
+ # Contract 17 (Ruby extension) validates the optional
20
+ # `callback_scheduling` fiber-nativeness declaration (:thread/:inline).
19
21
  # Contract 16 (Ruby extension) locks one-row-per-session `list_sessions`
20
22
  # under multiple appends — the naive one-row-per-append implementation
21
23
  # passed every other contract and then showed N duplicate sessions in
@@ -62,6 +64,7 @@ module ClaudeAgentSDK
62
64
  has_delete = optional?(probe, 'delete', skip_optional)
63
65
  has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional)
64
66
 
67
+ check_callback_scheduling_declaration(fresh)
65
68
  check_append_and_load(fresh, has_list_sessions)
66
69
  check_list_sessions(fresh) if has_list_sessions
67
70
  check_list_session_summaries(fresh, has_list_sessions, has_delete) if has_list_summaries
@@ -71,6 +74,30 @@ module ClaudeAgentSDK
71
74
  nil
72
75
  end
73
76
 
77
+ # -- Optional: callback_scheduling declaration ---------------------------
78
+
79
+ # 17. an adapter declaring the optional callback_scheduling method (issue
80
+ # #47 phase 3: fiber-native adapters) must return :thread or :inline —
81
+ # the SDK probes it at construction and raises ArgumentError otherwise,
82
+ # so a bad declaration would fail every session using the store. Runs
83
+ # first because the SDK probes before any store IO; skipped entirely for
84
+ # non-declaring adapters (the probe defaults to :thread).
85
+ def check_callback_scheduling_declaration(fresh)
86
+ store = fresh.call
87
+ return unless store.respond_to?(:callback_scheduling)
88
+
89
+ begin
90
+ SessionStores.store_callback_scheduling(store)
91
+ rescue ArgumentError => e
92
+ assert(false, "callback_scheduling declaration must be :thread or :inline (#{e.message})")
93
+ rescue StandardError, NotImplementedError => e
94
+ # A raising declaration is as fatal as a bad value — the SDK probes
95
+ # it at construction. Report through the harness's documented
96
+ # ConformanceError instead of leaking the raw exception.
97
+ assert(false, "callback_scheduling declaration raised #{e.class}: #{e.message}")
98
+ end
99
+ end
100
+
74
101
  # -- Required: append + load -------------------------------------------
75
102
 
76
103
  def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/MethodLength
@@ -347,8 +374,9 @@ module ClaudeAgentSDK
347
374
  "SessionStore conformance failed: #{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}"
348
375
  end
349
376
 
350
- private_class_method :check_append_and_load, :check_list_sessions, :check_list_session_summaries,
351
- :check_delete, :check_list_subkeys, :check_uuid_dedupe_contract, :key, :entry,
377
+ private_class_method :check_callback_scheduling_declaration, :check_append_and_load, :check_list_sessions,
378
+ :check_list_session_summaries, :check_delete, :check_list_subkeys,
379
+ :check_uuid_dedupe_contract, :key, :entry,
352
380
  :epoch_ms?, :optional?, :summaries_by_id, :assert, :assert_eq
353
381
  end
354
382
  end
@@ -28,7 +28,14 @@ module ClaudeAgentSDK
28
28
  # The semaphore serializes appends, but a #send that exceeds send_timeout is
29
29
  # abandoned (its worker thread keeps running) and the next drain proceeds, so
30
30
  # two #append calls for the SAME key can briefly overlap. SessionStore#append
31
- # must be thread-safe per key (see that method's contract).
31
+ # must be thread-safe per key (see that method's contract). For adapters
32
+ # declaring `callback_scheduling -> :inline` the timed-out call is instead
33
+ # cancelled cooperatively (interrupted at its next suspension point, ensure
34
+ # runs) — but only on the adapter's own fiber; work the adapter offloaded
35
+ # may still land, so timeouts are not retried in either mode and the
36
+ # cancelled append may remain permanently HALF-applied in the store. The
37
+ # drop is surfaced (MirrorErrorMessage, batches_dropped?); the local
38
+ # transcript remains the source of truth.
32
39
  class TranscriptMirrorBatcher
33
40
  # Eager-flush thresholds (exposed for tests).
34
41
  MAX_PENDING_ENTRIES = 500
@@ -50,6 +57,9 @@ module ClaudeAgentSDK
50
57
  @projects_dir = projects_dir
51
58
  @on_error = on_error
52
59
  @send_timeout = send_timeout
60
+ # Probed ONCE here so an invalid callback_scheduling declaration fails
61
+ # at construction, not mid-session inside a flush.
62
+ @store_scheduling = SessionStores.store_callback_scheduling(store)
53
63
  @max_pending_entries = max_pending_entries
54
64
  @max_pending_bytes = max_pending_bytes
55
65
  @pending = []
@@ -203,9 +213,18 @@ module ClaudeAgentSDK
203
213
  succeeded = true
204
214
  break
205
215
  when :timeout
206
- # Don't retry on timeout: the in-flight call may still land, so a
207
- # retry would launch a concurrent duplicate. Also bounds worst-case
208
- # lock hold at ~send_timeout rather than ~3x.
216
+ # Don't retry on timeout in EITHER mode. Thread mode: the
217
+ # abandoned in-flight call may still land, so a retry would launch
218
+ # a concurrent duplicate. Inline mode: cooperative cancellation
219
+ # interrupts only the adapter's own fiber — work the adapter
220
+ # itself offloaded (descendant tasks, an already-issued remote
221
+ # write) may still land after the cancellation, so a retry races
222
+ # it exactly like the thread case (adversarially demonstrated in
223
+ # review: a dedupe-conforming adapter still persisted duplicates).
224
+ # Uniform no-retry also bounds worst-case lock hold at
225
+ # ~send_timeout. The dropped batch is surfaced (MirrorErrorMessage,
226
+ # batches_dropped?) and the local transcript remains the source of
227
+ # truth.
209
228
  last_err = err
210
229
  break
211
230
  else # :error — retryable
@@ -222,11 +241,16 @@ module ClaudeAgentSDK
222
241
 
223
242
  # Run SessionStore#append (user code) on a plain thread via FiberBoundary,
224
243
  # bounded by send_timeout (enforced with or without an active reactor).
225
- # Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout the
226
- # worker thread is left running (cancellation is best-effort; the in-flight
227
- # call may still land) and not retried.
244
+ # Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout in
245
+ # thread mode the worker thread is left running (cancellation is
246
+ # best-effort; the in-flight call may still land) and not retried. An
247
+ # adapter declaring `callback_scheduling -> :inline` instead runs in
248
+ # place on the reactor under a cooperative timeout (interrupted at its
249
+ # next suspension point, ensure blocks run) — same JoinTimeout contract
250
+ # and same no-retry semantics (see append_with_retry: offloaded work
251
+ # may outlive the cancellation).
228
252
  def invoke_append(key, entries)
229
- FiberBoundary.invoke(timeout: @send_timeout) { @store.append(key, entries) }
253
+ FiberBoundary.invoke(timeout: @send_timeout, scheduling: @store_scheduling) { @store.append(key, entries) }
230
254
  [:ok, nil]
231
255
  rescue FiberBoundary::JoinTimeout
232
256
  [:timeout, "append timed out after #{@send_timeout}s"]
@@ -290,9 +290,11 @@ module ClaudeAgentSDK
290
290
  attr_accessor :uuid, :session_id, :content
291
291
  end
292
292
 
293
- # Emitted when a session_store mirror batch exhausts its retries and is
294
- # dropped. The local-disk transcript is still durable; this is the consumer's
295
- # only signal that the external store missed a batch (at-most-once delivery).
293
+ # Emitted when a session_store mirror batch fails terminally and is
294
+ # dropped timeouts immediately (never retried), other failures after up
295
+ # to three attempts. The local-disk transcript is still durable; this is
296
+ # the consumer's only signal that the external store missed a batch
297
+ # (at-most-once delivery).
296
298
  class MirrorErrorMessage < SystemMessage
297
299
  attr_accessor :uuid, :session_id, :error, :key
298
300
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.26.0'
4
+ VERSION = '0.27.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors