claude-agent-sdk 0.20.0 → 0.21.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 +13 -0
- data/README.md +1 -1
- data/docs/sessions.md +6 -0
- data/lib/claude_agent_sdk/command_builder.rb +8 -1
- data/lib/claude_agent_sdk/query.rb +109 -4
- data/lib/claude_agent_sdk/session_resume.rb +17 -0
- data/lib/claude_agent_sdk/sessions.rb +69 -6
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +59 -23
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +27 -19
- 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: 408fe2ed3f2f1f00866e3cef0b1795e67dc50dafd3292fc016985f62c10f1b6e
|
|
4
|
+
data.tar.gz: 045b04fee0681072fe7e99c81ab7a99d51d2eec362baf282a45c8d825cc38135
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 91b04e46d137103527e3af91d089acc01be96b69a8b0e6902eecb603654567ee826c8284ac0f9b797f180af64220ecfe0d71c3ab6f5e755e03525d596fcff85e
|
|
7
|
+
data.tar.gz: fb7e3d0a535522e98ed3c7a0180f1ac35ca5057a29dfdb3429b07cd896375672c0c94e114d92809325680f599ca39d8dee2f7fc3a658a73fabd744cf4a987946
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.21.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
Design-decision fix batch (Batch C) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #43), plus three teardown-race hardenings from its adversarial review. Minor (not patch) because three fixes change observable behavior for previously-broken flows: a missing settings file with sandbox no longer raises, a raising initial prompt stream no longer notifies observers, and teardown can now preserve (instead of delete) the materialized resume dir.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- `Query#close` (and therefore `Client#disconnect`) is now safe to call from any thread. Calling it from a FiberBoundary worker — e.g. a tool handler, hook, or permission callback ending the session — crashed with `NoMethodError` (nil `Fiber.scheduler`) and left the read/child tasks running, hanging the enclosing reactor. A transient reactor-side watcher now serves closes marshaled from foreign threads with identical semantics; once the reactor is gone, close falls back to a direct (fiber-dead-safe) teardown. `close` is also idempotent now — concurrent or repeated closes no longer re-run teardown against half-torn-down state.
|
|
16
|
+
- Eager-mode mirror failures during the last flush of a stream are reported before the `'end'` sentinel: the `MirrorErrorMessage` for an in-flight dropped batch could previously be enqueued after `'end'` and never delivered, violating the documented "failures surface as MirrorErrorMessage" guarantee.
|
|
17
|
+
- Disk session listings no longer report tool-argument text as the session summary/title: the head/tail byte-scans for `summary`/`customTitle`/`aiTitle`/`lastPrompt` now verify each match against the top level of its containing JSONL line (the same keys the SessionStore fold reads), so keys nested inside tool_use inputs (subagent/teammate tool arguments) are ignored. Lines truncated at the 64KB window edge keep the raw-scan value.
|
|
18
|
+
- Resume-from-store teardown no longer deletes the only copy of turns the mirror failed to persist: when the batcher dropped batches (tracked via `TranscriptMirrorBatcher#batches_dropped?` / `Query#mirror_batches_dropped?`, including drains cancelled mid-flight), the materialized temp `CLAUDE_CONFIG_DIR` is preserved — credential copies scrubbed, transcripts kept, warning with the path — instead of removed.
|
|
19
|
+
- A missing settings file with `sandbox` set now warns and continues with sandbox-only settings (Python parity) instead of raising `CLIConnectionError`.
|
|
20
|
+
- `Client#connect` no longer fires observer `on_error` for a raising initial Enumerator prompt: input-stream errors are swallowed-with-warn (documented `Observer#on_error` contract, `query()` and Python parity). Notifying them also marked still-live OTel traces as failed.
|
|
21
|
+
- Postgres reference adapter (`examples/session_stores/postgres_session_store.rb`): all DB round-trips now serialize through an internal mutex — mirror appends run on fresh worker threads and can overlap after a send-timeout abandon, which pg's single-connection thread rules forbid. The "a single PG::Connection suffices" concurrency guidance was wrong and has been rewritten.
|
|
22
|
+
|
|
10
23
|
## [0.20.0] - 2026-07-03
|
|
11
24
|
|
|
12
25
|
Behavioral fix batch (Batch B) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #42). Every fix aligns code with a documented contract or Python SDK behavior and changes behavior only for inputs that previously produced wrong results, hangs, or crashes. Minor (not patch) because two fixes raise where the SDK previously reported success: a signal-killed CLI now raises `ProcessError`, and assistant messages without `message.model` now raise `MessageParseError`.
|
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.21.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
data/docs/sessions.md
CHANGED
|
@@ -152,6 +152,12 @@ during resume materialization, default `60_000`).
|
|
|
152
152
|
> `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
|
|
153
153
|
> programmatically via `ClaudeAgentOptions` are unaffected. This matches the
|
|
154
154
|
> Python and TypeScript SDKs.
|
|
155
|
+
>
|
|
156
|
+
> The temp dir is deleted at disconnect — **unless the mirror dropped batches**
|
|
157
|
+
> (adapter failures that exhausted retries, surfaced as `MirrorErrorMessage`):
|
|
158
|
+
> the store copy is then incomplete and the temp dir holds the only copy of the
|
|
159
|
+
> dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
|
|
160
|
+
> and warns with the preserved path so you can import them into the store.
|
|
155
161
|
|
|
156
162
|
### Implementing an adapter
|
|
157
163
|
|
|
@@ -376,7 +376,14 @@ module ClaudeAgentSDK
|
|
|
376
376
|
end
|
|
377
377
|
|
|
378
378
|
def load_settings_file(path)
|
|
379
|
-
|
|
379
|
+
# Missing file: warn and continue with sandbox-only settings (Python
|
|
380
|
+
# parity: logger.warning("Settings file not found: ...") and an empty
|
|
381
|
+
# settings object). Raising here turned a misconfiguration the CLI
|
|
382
|
+
# tolerates into a hard connect failure.
|
|
383
|
+
unless File.file?(path)
|
|
384
|
+
warn "Claude SDK: Settings file not found: #{path}"
|
|
385
|
+
return {}
|
|
386
|
+
end
|
|
380
387
|
|
|
381
388
|
JSON.parse(File.read(path))
|
|
382
389
|
end
|
|
@@ -77,6 +77,16 @@ module ClaudeAgentSDK
|
|
|
77
77
|
@closed = false
|
|
78
78
|
@initialization_result = nil
|
|
79
79
|
@transcript_mirror_batcher = nil
|
|
80
|
+
|
|
81
|
+
# Cross-thread close marshaling (see #close). Thread::Queue is the one
|
|
82
|
+
# primitive that is both fiber-scheduler-aware on the reactor side and
|
|
83
|
+
# thread-safe on the caller side (push -> scheduler#unblock).
|
|
84
|
+
@close_requests = ::Thread::Queue.new
|
|
85
|
+
@close_watcher = nil
|
|
86
|
+
@owning_scheduler = nil
|
|
87
|
+
# First-caller-wins guard for close_now (see there).
|
|
88
|
+
@close_mutex = Mutex.new
|
|
89
|
+
@close_started = false
|
|
80
90
|
end
|
|
81
91
|
|
|
82
92
|
# Initialize control protocol if in streaming mode
|
|
@@ -178,7 +188,24 @@ module ClaudeAgentSDK
|
|
|
178
188
|
parent = Async::Task.current?
|
|
179
189
|
raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
|
|
180
190
|
|
|
191
|
+
@owning_scheduler = Fiber.scheduler
|
|
181
192
|
@task = parent.async { read_messages }
|
|
193
|
+
# Reactor-side agent for #close calls arriving from foreign threads
|
|
194
|
+
# (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
|
|
195
|
+
# the owning thread's Fiber.scheduler, so the off-thread caller hands the
|
|
196
|
+
# whole close over and waits. Transient: must never keep the reactor
|
|
197
|
+
# alive, and is stopped automatically when the parent task finishes.
|
|
198
|
+
# One-shot: after serving a close it is done; a reactor-side close wakes
|
|
199
|
+
# it via @close_requests.close (pop -> nil) so it exits without serving.
|
|
200
|
+
@close_watcher = parent.async(transient: true) do
|
|
201
|
+
if (reply = @close_requests.pop)
|
|
202
|
+
begin
|
|
203
|
+
close
|
|
204
|
+
ensure
|
|
205
|
+
reply << true
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
182
209
|
end
|
|
183
210
|
|
|
184
211
|
# Spawn a child task that is stopped by #close (mirrors the Python SDK's
|
|
@@ -206,6 +233,14 @@ module ClaudeAgentSDK
|
|
|
206
233
|
@transcript_mirror_batcher = batcher
|
|
207
234
|
end
|
|
208
235
|
|
|
236
|
+
# True when the mirror dropped at least one batch (store copy incomplete).
|
|
237
|
+
# Meaningful after #close, which runs the final flush. Consulted by the
|
|
238
|
+
# resume-from-store teardown to decide whether the materialized temp dir
|
|
239
|
+
# holds the only copy of some turns and must be preserved.
|
|
240
|
+
def mirror_batches_dropped?
|
|
241
|
+
!!@transcript_mirror_batcher&.batches_dropped?
|
|
242
|
+
end
|
|
243
|
+
|
|
209
244
|
# Synthesize a `mirror_error` system message and put it on the SDK message
|
|
210
245
|
# stream so consumers learn a mirror batch was dropped after exhausting
|
|
211
246
|
# retries. Non-blocking: the message queue is unbounded, so unlike the
|
|
@@ -1152,8 +1187,45 @@ module ClaudeAgentSDK
|
|
|
1152
1187
|
end
|
|
1153
1188
|
end
|
|
1154
1189
|
|
|
1155
|
-
# Close the query and transport
|
|
1190
|
+
# Close the query and transport.
|
|
1191
|
+
#
|
|
1192
|
+
# Callable from any thread. Async::Task#stop needs the reactor's
|
|
1193
|
+
# Fiber.scheduler (per-thread), which foreign callers — FiberBoundary
|
|
1194
|
+
# workers running a tool handler / hook that calls client.disconnect, or
|
|
1195
|
+
# plain user threads — don't have; stopping from one raised NoMethodError
|
|
1196
|
+
# and left the read/child tasks running. Such callers hand the close to
|
|
1197
|
+
# the reactor-side watcher (spawned in #start) and wait for it to finish,
|
|
1198
|
+
# so close semantics are identical regardless of the calling thread.
|
|
1156
1199
|
def close
|
|
1200
|
+
if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
|
|
1201
|
+
marshal_close_to_reactor
|
|
1202
|
+
else
|
|
1203
|
+
# Same scheduler (reactor-side caller, including the watcher itself),
|
|
1204
|
+
# or no live watcher: when the reactor is gone its task fibers are
|
|
1205
|
+
# dead, so stopping them no longer touches Fiber.scheduler.
|
|
1206
|
+
close_now
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
private
|
|
1211
|
+
|
|
1212
|
+
def close_now
|
|
1213
|
+
# First caller wins: a reactor-side close racing a watcher-served
|
|
1214
|
+
# foreign close (or a repeated disconnect) must not re-run teardown
|
|
1215
|
+
# against half-torn-down state — transport.close can suspend mid-reap,
|
|
1216
|
+
# and a second pass would race it. Later callers return immediately;
|
|
1217
|
+
# the SDK's outer teardown ensures (query() / Client#disconnect) close
|
|
1218
|
+
# the transport independently, so nothing is left dangling even if the
|
|
1219
|
+
# first pass failed partway.
|
|
1220
|
+
first_caller = @close_mutex.synchronize do
|
|
1221
|
+
if @close_started
|
|
1222
|
+
false
|
|
1223
|
+
else
|
|
1224
|
+
@close_started = true
|
|
1225
|
+
end
|
|
1226
|
+
end
|
|
1227
|
+
return unless first_caller
|
|
1228
|
+
|
|
1157
1229
|
@closed = true
|
|
1158
1230
|
# Wake pending control-request waiters (same shape as the read-loop
|
|
1159
1231
|
# rescue broadcast): close stops the read task with Async::Stop, which
|
|
@@ -1171,10 +1243,43 @@ module ClaudeAgentSDK
|
|
|
1171
1243
|
# Stop tracked child tasks (e.g. stream_input) before the read task and
|
|
1172
1244
|
# transport so a parked input stream can never keep the reactor alive
|
|
1173
1245
|
# (mirrors Python close() cancelling _child_tasks).
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1246
|
+
begin
|
|
1247
|
+
@child_tasks.each(&:stop)
|
|
1248
|
+
@child_tasks.clear
|
|
1249
|
+
@task&.stop
|
|
1250
|
+
rescue NoMethodError, FiberError => e
|
|
1251
|
+
# Schedulerless fallback close (the watcher died unserved) racing
|
|
1252
|
+
# reactor teardown: a task fiber can still be unwinding, and stopping
|
|
1253
|
+
# it needs the owning thread's Fiber.scheduler. The dying reactor
|
|
1254
|
+
# stops its own tasks; the transport close below (plain IO + kill)
|
|
1255
|
+
# still runs. On the reactor itself this is a real bug — re-raise.
|
|
1256
|
+
raise unless Fiber.scheduler.nil?
|
|
1257
|
+
|
|
1258
|
+
warn "Claude SDK: skipped stopping tasks during off-reactor close: #{e.message}"
|
|
1259
|
+
end
|
|
1177
1260
|
@transport.close
|
|
1261
|
+
# Release a still-parked close watcher: pop returns nil and it exits
|
|
1262
|
+
# without serving. Any foreign-thread close arriving after this point
|
|
1263
|
+
# falls back to a direct close (safe — the fibers are now dead).
|
|
1264
|
+
@close_requests.close
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# Hand the close to the reactor and wait for completion. Polls watcher
|
|
1268
|
+
# liveness instead of waiting forever: if the reactor shuts down
|
|
1269
|
+
# concurrently (the transient watcher is stopped without serving the
|
|
1270
|
+
# request), no reply will ever arrive — fall back to a direct close,
|
|
1271
|
+
# which is safe once the reactor's fibers are dead.
|
|
1272
|
+
def marshal_close_to_reactor
|
|
1273
|
+
reply = ::Thread::Queue.new
|
|
1274
|
+
@close_requests << reply
|
|
1275
|
+
loop do
|
|
1276
|
+
return if reply.pop(timeout: 0.1)
|
|
1277
|
+
break unless @close_watcher&.alive?
|
|
1278
|
+
end
|
|
1279
|
+
close_now
|
|
1280
|
+
rescue ClosedQueueError
|
|
1281
|
+
# The push raced a reactor-side close_now that closed @close_requests.
|
|
1282
|
+
close_now
|
|
1178
1283
|
end
|
|
1179
1284
|
end
|
|
1180
1285
|
end
|
|
@@ -28,6 +28,23 @@ module ClaudeAgentSDK
|
|
|
28
28
|
def cleanup
|
|
29
29
|
SessionResume.rmtree_with_retry(@config_dir)
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
# Teardown when the transcript mirror dropped batches: the CLI's
|
|
33
|
+
# authoritative transcript lives in this temp dir, and the store copy is
|
|
34
|
+
# missing the dropped turns — deleting the dir would permanently lose
|
|
35
|
+
# them. Keep the transcripts (projects/), remove the redacted credential
|
|
36
|
+
# copies, and tell the user where the data is so they can import it into
|
|
37
|
+
# the store manually. Never raises.
|
|
38
|
+
def preserve_transcripts
|
|
39
|
+
['.credentials.json', '.claude.json'].each do |name|
|
|
40
|
+
FileUtils.rm_f(File.join(@config_dir, name))
|
|
41
|
+
end
|
|
42
|
+
warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
|
|
43
|
+
"Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
|
|
44
|
+
'deleting it — import it into your session store, then remove the directory.'
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}"
|
|
47
|
+
end
|
|
31
48
|
end
|
|
32
49
|
|
|
33
50
|
# Materialize a SessionStore-backed resume into a temp CLAUDE_CONFIG_DIR.
|
|
@@ -213,6 +213,65 @@ module ClaudeAgentSDK
|
|
|
213
213
|
result
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
+
# Byte-scan for `"key":"` like extract_json_string_field, but verify each
|
|
217
|
+
# match by JSON-parsing its containing line and reading the key at the TOP
|
|
218
|
+
# LEVEL of the entry. The raw scan also matches keys nested inside
|
|
219
|
+
# tool_use inputs (real transcripts carry unescaped `"summary":"..."` in
|
|
220
|
+
# subagent/teammate tool arguments), which made the disk path report
|
|
221
|
+
# tool-argument text as the session summary/title while the store fold —
|
|
222
|
+
# which reads only top-level keys — disagreed. A line that doesn't parse
|
|
223
|
+
# (truncated at the head/tail window edge) keeps the raw-scan value: its
|
|
224
|
+
# top-level shape can't be checked, and dropping it would regress the
|
|
225
|
+
# common case of a true entry cut by the 64KB window.
|
|
226
|
+
def extract_top_level_string_field(text, key, last: false)
|
|
227
|
+
positions = field_match_positions(text, key)
|
|
228
|
+
positions.reverse! if last
|
|
229
|
+
parsed_lines = {}
|
|
230
|
+
positions.each do |idx, value_start|
|
|
231
|
+
line_start = (text.rindex("\n", idx) || -1) + 1
|
|
232
|
+
entry = parsed_lines.fetch(line_start) do
|
|
233
|
+
parsed_lines[line_start] = parse_containing_line(text, line_start, idx)
|
|
234
|
+
end
|
|
235
|
+
if entry
|
|
236
|
+
value = entry[key]
|
|
237
|
+
return value if value.is_a?(String)
|
|
238
|
+
|
|
239
|
+
next # parseable line without a top-level string value: nested/false match
|
|
240
|
+
end
|
|
241
|
+
value = extract_json_string_value(text, value_start)
|
|
242
|
+
return unescape_json_string(value) if value
|
|
243
|
+
end
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Match positions of both compact and spaced `"key":` patterns, sorted by
|
|
248
|
+
# position so first/last selection is by document order (the pattern-major
|
|
249
|
+
# order of extract_json_string_field is wrong when spacings mix).
|
|
250
|
+
def field_match_positions(text, key)
|
|
251
|
+
positions = []
|
|
252
|
+
["\"#{key}\":\"", "\"#{key}\": \""].each do |pattern|
|
|
253
|
+
pos = 0
|
|
254
|
+
while (idx = text.index(pattern, pos))
|
|
255
|
+
value_start = idx + pattern.length
|
|
256
|
+
positions << [idx, value_start]
|
|
257
|
+
pos = value_start
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
positions.sort_by!(&:first)
|
|
261
|
+
positions
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Parse the JSONL line containing byte offset +idx+. Returns the entry
|
|
265
|
+
# Hash, {} for parseable non-Hash lines (a match inside one is nested by
|
|
266
|
+
# definition), or nil when the line doesn't parse (window truncation).
|
|
267
|
+
def parse_containing_line(text, line_start, idx)
|
|
268
|
+
line_end = text.index("\n", idx) || text.length
|
|
269
|
+
entry = JSON.parse(text[line_start...line_end])
|
|
270
|
+
entry.is_a?(Hash) ? entry : {}
|
|
271
|
+
rescue StandardError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
216
275
|
# Extract string value starting at pos (handles escapes)
|
|
217
276
|
def extract_json_string_value(text, start)
|
|
218
277
|
pos = start
|
|
@@ -341,15 +400,19 @@ module ClaudeAgentSDK
|
|
|
341
400
|
# Head fallback covers short sessions where the title entry may not be in tail.
|
|
342
401
|
# Each candidate passes through presence so a blank value (e.g. a
|
|
343
402
|
# trailing title-clearing entry) falls through instead of short-circuiting.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
403
|
+
# Summary-chain fields use the top-level-verified scan: a raw byte scan
|
|
404
|
+
# also matches these keys nested inside tool_use inputs, reporting tool
|
|
405
|
+
# arguments as the session title/summary (and diverging from the store
|
|
406
|
+
# fold, which reads top-level keys only).
|
|
407
|
+
custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true)) ||
|
|
408
|
+
presence(extract_top_level_string_field(head, 'customTitle', last: true)) ||
|
|
409
|
+
presence(extract_top_level_string_field(tail, 'aiTitle', last: true)) ||
|
|
410
|
+
presence(extract_top_level_string_field(head, 'aiTitle', last: true))
|
|
348
411
|
first_prompt = extract_first_prompt_from_head(head)
|
|
349
412
|
# lastPrompt tail entry shows what the user was most recently doing.
|
|
350
413
|
summary = custom_title ||
|
|
351
|
-
presence(
|
|
352
|
-
presence(
|
|
414
|
+
presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) ||
|
|
415
|
+
presence(extract_top_level_string_field(tail, 'summary', last: true)) ||
|
|
353
416
|
first_prompt
|
|
354
417
|
return nil if summary.nil? || summary.strip.empty?
|
|
355
418
|
|
|
@@ -55,12 +55,24 @@ module ClaudeAgentSDK
|
|
|
55
55
|
@pending = []
|
|
56
56
|
@pending_entries = 0
|
|
57
57
|
@pending_bytes = 0
|
|
58
|
+
# Batches that exhausted retries (or could not be keyed) and never
|
|
59
|
+
# reached the store. Written only under @lock; read cross-thread by
|
|
60
|
+
# #batches_dropped? (a plain Integer read is safe under the GVL).
|
|
61
|
+
@dropped_batches = 0
|
|
58
62
|
# Fiber-aware lock: the critical section blocks on SessionStore#append
|
|
59
63
|
# (a thread hop), so a Thread::Mutex would deadlock the reactor. The
|
|
60
64
|
# semaphore serializes drains so append ordering matches enqueue order.
|
|
61
65
|
@lock = Async::Semaphore.new(1)
|
|
62
66
|
end
|
|
63
67
|
|
|
68
|
+
# True when at least one batch of entries never reached the store — the
|
|
69
|
+
# mirror copy is incomplete. Consulted at teardown by the resume-from-store
|
|
70
|
+
# cleanup so the materialized temp dir (which then holds the only copy of
|
|
71
|
+
# the dropped turns) is preserved instead of deleted.
|
|
72
|
+
def batches_dropped?
|
|
73
|
+
@dropped_batches.positive?
|
|
74
|
+
end
|
|
75
|
+
|
|
64
76
|
# Buffer a frame; schedule an eager background flush if thresholds are
|
|
65
77
|
# exceeded. Synchronous and fire-and-forget.
|
|
66
78
|
#
|
|
@@ -112,27 +124,47 @@ module ClaudeAgentSDK
|
|
|
112
124
|
@pending_bytes = 0
|
|
113
125
|
|
|
114
126
|
errors = []
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
127
|
+
# Cancellation (Async::Stop — not a StandardError) delivered while
|
|
128
|
+
# waiting on the lock or inside the append's thread join loses the
|
|
129
|
+
# detached items without either rescue firing. Teardown would then read
|
|
130
|
+
# batches_dropped? as false and delete a materialized resume dir holding
|
|
131
|
+
# the only copy of these entries — count the batch as dropped unless the
|
|
132
|
+
# flush path ran to completion (do_flush counts its own failures).
|
|
133
|
+
accounted = items.empty?
|
|
134
|
+
begin
|
|
135
|
+
@lock.acquire do
|
|
136
|
+
# Emptiness is checked INSIDE the lock (matching the Python batcher):
|
|
137
|
+
# an empty #flush/#close still serializes behind any in-flight or
|
|
138
|
+
# queued drain, so they are true barriers — at result-yield and at
|
|
139
|
+
# teardown the store really is up to date, and Query#close can't stop
|
|
140
|
+
# the read task while a detached batch is still being appended.
|
|
141
|
+
next if items.empty?
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
do_flush(items, errors)
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
# do_flush already guards each append; this guards any remaining path
|
|
147
|
+
# so the "never raises" contract holds against future regressions.
|
|
148
|
+
@dropped_batches += 1
|
|
149
|
+
warn "Claude SDK: TranscriptMirrorBatcher drain failed: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
accounted = true
|
|
152
|
+
|
|
153
|
+
# Report errors BEFORE releasing the lock: flush/close are barriers, so
|
|
154
|
+
# a caller observing flush completion must also observe the error
|
|
155
|
+
# report. Reporting after release let an in-flight eager drain enqueue
|
|
156
|
+
# its MirrorErrorMessage after the read loop's 'end' sentinel — past
|
|
157
|
+
# the point where consumers stop dequeuing, i.e. never delivered. The
|
|
158
|
+
# production on_error (Query#report_mirror_error) is a non-blocking
|
|
159
|
+
# queue push, so holding the lock across it costs nothing.
|
|
160
|
+
errors.each do |key, message|
|
|
161
|
+
@on_error.call(key, message)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
warn "Claude SDK: TranscriptMirrorBatcher on_error callback raised: #{e.message}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
ensure
|
|
167
|
+
@dropped_batches += 1 unless accounted
|
|
136
168
|
end
|
|
137
169
|
end
|
|
138
170
|
|
|
@@ -149,6 +181,7 @@ module ClaudeAgentSDK
|
|
|
149
181
|
|
|
150
182
|
key = SessionStores.file_path_to_session_key(file_path, @projects_dir)
|
|
151
183
|
if key.nil?
|
|
184
|
+
@dropped_batches += 1
|
|
152
185
|
warn "Claude SDK: [SessionStore] dropping mirror frame: filePath #{file_path} is not under " \
|
|
153
186
|
"#{@projects_dir} -- subprocess CLAUDE_CONFIG_DIR likely differs from parent (custom env / container?)"
|
|
154
187
|
next
|
|
@@ -180,8 +213,11 @@ module ClaudeAgentSDK
|
|
|
180
213
|
end
|
|
181
214
|
end
|
|
182
215
|
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
return if succeeded
|
|
217
|
+
|
|
218
|
+
@dropped_batches += 1
|
|
219
|
+
errors << [key, last_err.to_s]
|
|
220
|
+
warn "Claude SDK: TranscriptMirrorBatcher flush failed for #{file_path}: #{last_err}"
|
|
185
221
|
end
|
|
186
222
|
|
|
187
223
|
# Run SessionStore#append (user code) on a plain thread via FiberBoundary,
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -555,8 +555,13 @@ module ClaudeAgentSDK
|
|
|
555
555
|
ensure
|
|
556
556
|
# Remove the materialized resume temp dir (which holds a redacted
|
|
557
557
|
# .credentials.json copy) AFTER the subprocess has exited, even when
|
|
558
|
-
# close itself raises
|
|
559
|
-
|
|
558
|
+
# close itself raises — unless the mirror dropped batches: the store
|
|
559
|
+
# copy is then incomplete and the temp dir holds the only copy of
|
|
560
|
+
# the dropped turns, so it is preserved (scrubbed of credentials)
|
|
561
|
+
# with a warning instead of deleted.
|
|
562
|
+
if materialized
|
|
563
|
+
query_handler&.mirror_batches_dropped? ? materialized.preserve_transcripts : materialized.cleanup
|
|
564
|
+
end
|
|
560
565
|
end
|
|
561
566
|
end
|
|
562
567
|
end.wait
|
|
@@ -909,6 +914,10 @@ module ClaudeAgentSDK
|
|
|
909
914
|
# state, and removes the materialized temp dir (which holds a redacted
|
|
910
915
|
# .credentials.json copy) — so disconnect can never leave the client
|
|
911
916
|
# half-open or leak the temp dir. The original error still propagates.
|
|
917
|
+
# Keep a handle on the query handler past the nil-out below: whether the
|
|
918
|
+
# mirror dropped batches is only final AFTER #close ran its last flush,
|
|
919
|
+
# and the materialized-dir decision at the bottom needs to ask it.
|
|
920
|
+
query_handler = @query_handler
|
|
912
921
|
begin
|
|
913
922
|
@query_handler&.close
|
|
914
923
|
ensure
|
|
@@ -918,9 +927,17 @@ module ClaudeAgentSDK
|
|
|
918
927
|
ensure
|
|
919
928
|
@transport = nil
|
|
920
929
|
@connected = false
|
|
921
|
-
# Remove the materialized resume temp dir AFTER the subprocess
|
|
930
|
+
# Remove the materialized resume temp dir AFTER the subprocess
|
|
931
|
+
# exited — unless the mirror dropped batches: the store copy is then
|
|
932
|
+
# incomplete and the temp dir holds the only copy of the dropped
|
|
933
|
+
# turns, so it is preserved (scrubbed of credentials) with a warning
|
|
934
|
+
# instead of deleted.
|
|
922
935
|
if @materialized
|
|
923
|
-
|
|
936
|
+
if query_handler&.mirror_batches_dropped?
|
|
937
|
+
@materialized.preserve_transcripts
|
|
938
|
+
else
|
|
939
|
+
@materialized.cleanup
|
|
940
|
+
end
|
|
924
941
|
@materialized = nil
|
|
925
942
|
end
|
|
926
943
|
end
|
|
@@ -997,22 +1014,13 @@ module ClaudeAgentSDK
|
|
|
997
1014
|
# yielding deadlocked connect — and serialized Hash messages with
|
|
998
1015
|
# to_s (Ruby inspect, not JSON). stream_input JSON-generates Hashes
|
|
999
1016
|
# and is tracked on the Query so close() stops it. Stream errors are
|
|
1000
|
-
#
|
|
1001
|
-
#
|
|
1017
|
+
# swallowed-with-warn by stream_input (Python parity) — they don't
|
|
1018
|
+
# abort connect, and observers are NOT notified (the documented
|
|
1019
|
+
# Observer#on_error contract; notifying a swallowed error would mark
|
|
1020
|
+
# a still-live OTel trace as failed). Same behavior as query()'s
|
|
1021
|
+
# streaming path.
|
|
1002
1022
|
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
|
|
1003
|
-
|
|
1004
|
-
@query_handler.spawn_task { @query_handler.stream_input(notifying) }
|
|
1005
|
-
end
|
|
1006
|
-
end
|
|
1007
|
-
|
|
1008
|
-
# Wrap a stream so a raising user enumerator fires on_error exactly once
|
|
1009
|
-
# before stream_input's swallow-with-warn handling takes over.
|
|
1010
|
-
def error_notifying_stream(stream)
|
|
1011
|
-
Enumerator.new do |yielder|
|
|
1012
|
-
stream.each { |message| yielder << message }
|
|
1013
|
-
rescue StandardError => e
|
|
1014
|
-
notify_error(e)
|
|
1015
|
-
raise
|
|
1023
|
+
@query_handler.spawn_task { @query_handler.stream_input(observed) }
|
|
1016
1024
|
end
|
|
1017
1025
|
end
|
|
1018
1026
|
|