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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +1 -1
- data/docs/rails.md +1 -1
- data/docs/sessions.md +44 -1
- data/lib/claude_agent_sdk/fiber_boundary.rb +47 -11
- data/lib/claude_agent_sdk/query.rb +10 -4
- data/lib/claude_agent_sdk/session_resume.rb +29 -21
- 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 +32 -8
- data/lib/claude_agent_sdk/types.rb +5 -3
- data/lib/claude_agent_sdk/version.rb +1 -1
- 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: 760ec1fb0cf80f6e56d7b5d3adb20ca2956b209d0961020ae36fa52a13e28144
|
|
4
|
+
data.tar.gz: 6ba365de4438e4081f9bfa47afadc4859d9a001bb6238c0b66f00437cb032626
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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`)
|
|
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
|
-
> (
|
|
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:`
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
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])
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
# 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
|
|
@@ -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
|
|
207
|
-
#
|
|
208
|
-
#
|
|
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
|
|
226
|
-
# worker thread is left running (cancellation is
|
|
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
|
|
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
|