claude-agent-sdk 0.20.0 → 0.22.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 +33 -0
- data/README.md +26 -26
- data/docs/client.md +1 -1
- data/docs/configuration.md +5 -5
- data/docs/errors.md +2 -2
- data/docs/hooks-and-permissions.md +3 -3
- data/docs/mcp-servers.md +1 -1
- data/docs/observability.md +1 -1
- data/docs/rails.md +4 -4
- data/docs/sessions.md +7 -1
- data/docs/types.md +1 -1
- data/lib/claude_agent_sdk/command_builder.rb +8 -1
- data/lib/claude_agent_sdk/instrumentation/otel.rb +31 -6
- data/lib/claude_agent_sdk/query.rb +109 -4
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +12 -5
- data/lib/claude_agent_sdk/session_resume.rb +43 -0
- data/lib/claude_agent_sdk/sessions.rb +97 -10
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +51 -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: b739ecaa08f1ddc9bc97ffdf639deca97a69da0251ec7b4ec975c7994c76c4b7
|
|
4
|
+
data.tar.gz: c85fe94b25b9b18c7de7d842cdeef7d0059c40cf829f33a963a4c6a215b4f999
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c1e37466bb844a9bcb4be7206881051e097e45181b64554f1e1299602a00f0ad88f1ffcefd73796e118715595bcef83cfd8e63138e2f537e2b6312428767e2c
|
|
7
|
+
data.tar.gz: daa2ba1268fdf355b426fec710b28254eaa4db61ff45017fe444f05b1499986f13aa2b1273997a52f64c061ce84a6cd7efda897d977c5d753323511c05184399
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.22.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
Final batch (Batch D) from the 2026-07-03 full-codebase audit, closing it out: tests/docs/examples plus the three recorded split-verdict findings (P1-P3). Minor because the conformance suite gained a contract (an adapter that passed 0.21.0 can now fail contract 16) and new API surface (`check_uuid_dedupe:`).
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- OTel observer: the next turn's prompt is no longer dropped from the new trace after an interrupted turn or `/clear` (a superseding init with no ResultMessage) — prompts arriving while the current trace already has its input are buffered for the next trace instead of being latched out; a prompt queued mid-turn now labels its own trace too.
|
|
16
|
+
- Disk session listings no longer misclassify a session as a sidechain from a nested `"isSidechain":true` inside a structured field: the first line is parsed and the top-level key checked (same as the store fold), with the substring heuristic kept only for window-truncated first lines.
|
|
17
|
+
- SDK MCP servers: propertyless object schemas (`{ type: 'object', additionalProperties: ... }`, `oneOf`, accept-anything forms) now pass through intact instead of being mangled into nonsense parameter lists ("additionalProperties" advertised as a required string param).
|
|
18
|
+
- `import_session_to_store` skips an unparseable trailing line with a warning (an ordinary interrupted-CLI artifact every read path already tolerates) instead of aborting mid-import with a raw `JSON::ParserError` and leaving a partial store import behind.
|
|
19
|
+
- Redis reference adapter: the delete cascade now WATCHes the subkey set, so a concurrent eager-mode append can no longer orphan a freshly-created subagent list forever; both the Redis and Postgres reference adapters stamp mtimes through a monotonic guard (like S3) so a backward clock step can't misdirect `--continue`.
|
|
20
|
+
- Redis example spec no longer FLUSHDBs whatever `SESSION_STORE_REDIS_URL` points at — it uses a random key prefix per run with prefix-scoped cleanup, like the Postgres spec.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Conformance suite contract 16: `list_sessions` must return exactly one row per session under multiple appends (the naive one-row-per-append implementation previously passed every contract, then showed N duplicate sessions in pickers). Contract 4 now also asserts `append([])` cannot create a phantom key. New opt-in `check_uuid_dedupe:` flag asserts the advisory retried-batch uuid-dedupe recommendation.
|
|
24
|
+
- `--continue` with a store that implements `list_session_summaries` skips sidechain candidates via the summary sidecar instead of downloading every candidate's full transcript.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- `RUN_INTEGRATION=1` now actually runs the real-CLI integration suite (the old tautological placeholder suite is gone; `RUN_REAL_INTEGRATION` remains as an alias). The real suite still self-skips without a `claude` CLI or `ANTHROPIC_API_KEY`.
|
|
28
|
+
- Packaged docs/README now link to examples and repo files via absolute GitHub URLs — the relative links were dead in installed gems and on rubydoc.info (examples/ and assets/ are not packaged).
|
|
29
|
+
|
|
30
|
+
## [0.21.0] - 2026-07-03
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- `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.
|
|
36
|
+
- 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.
|
|
37
|
+
- 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.
|
|
38
|
+
- 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.
|
|
39
|
+
- A missing settings file with `sandbox` set now warns and continues with sandbox-only settings (Python parity) instead of raising `CLIConnectionError`.
|
|
40
|
+
- `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.
|
|
41
|
+
- 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.
|
|
42
|
+
|
|
10
43
|
## [0.20.0] - 2026-07-03
|
|
11
44
|
|
|
12
45
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Claude Agent SDK for Ruby
|
|
2
2
|
|
|
3
|
-

|
|
3
|
+

|
|
4
4
|
|
|
5
5
|
[](https://badge.fury.io/rb/claude-agent-sdk)
|
|
6
6
|
|
|
@@ -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.22.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
|
@@ -144,7 +144,7 @@ ClaudeAgentSDK.query(prompt: stream) do |message|
|
|
|
144
144
|
end
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
See [examples/streaming_input_example.rb](examples/streaming_input_example.rb) and [examples/quick_start.rb](examples/quick_start.rb).
|
|
147
|
+
See [examples/streaming_input_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/streaming_input_example.rb) and [examples/quick_start.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/quick_start.rb).
|
|
148
148
|
|
|
149
149
|
## `Client` — Bidirectional Sessions
|
|
150
150
|
|
|
@@ -220,49 +220,49 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
220
220
|
|
|
221
221
|
| Example | Description |
|
|
222
222
|
|---------|-------------|
|
|
223
|
-
| [quick_start.rb](examples/quick_start.rb) | Basic `query()` usage with options |
|
|
224
|
-
| [client_example.rb](examples/client_example.rb) | Interactive Client usage |
|
|
225
|
-
| [message_types_example.rb](examples/message_types_example.rb) | Handling all 24 SDK message types |
|
|
226
|
-
| [streaming_input_example.rb](examples/streaming_input_example.rb) | Streaming input for multi-turn conversations |
|
|
227
|
-
| [session_resumption_example.rb](examples/session_resumption_example.rb) | Multi-turn conversations with session persistence |
|
|
228
|
-
| [structured_output_example.rb](examples/structured_output_example.rb) | JSON schema structured output |
|
|
229
|
-
| [error_handling_example.rb](examples/error_handling_example.rb) | Error handling with `AssistantMessage.error` |
|
|
230
|
-
| [bare_mode_example.rb](examples/bare_mode_example.rb) | Minimal startup with `bare: true` |
|
|
231
|
-
| [sandbox_example.rb](examples/sandbox_example.rb) | Full sandbox settings (network, filesystem, violations) |
|
|
223
|
+
| [quick_start.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/quick_start.rb) | Basic `query()` usage with options |
|
|
224
|
+
| [client_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/client_example.rb) | Interactive Client usage |
|
|
225
|
+
| [message_types_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/message_types_example.rb) | Handling all 24 SDK message types |
|
|
226
|
+
| [streaming_input_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/streaming_input_example.rb) | Streaming input for multi-turn conversations |
|
|
227
|
+
| [session_resumption_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_resumption_example.rb) | Multi-turn conversations with session persistence |
|
|
228
|
+
| [structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb) | JSON schema structured output |
|
|
229
|
+
| [error_handling_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/error_handling_example.rb) | Error handling with `AssistantMessage.error` |
|
|
230
|
+
| [bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb) | Minimal startup with `bare: true` |
|
|
231
|
+
| [sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb) | Full sandbox settings (network, filesystem, violations) |
|
|
232
232
|
|
|
233
233
|
### MCP Servers
|
|
234
234
|
|
|
235
235
|
| Example | Description |
|
|
236
236
|
|---------|-------------|
|
|
237
|
-
| [mcp_calculator.rb](examples/mcp_calculator.rb) | Custom tools with SDK MCP servers |
|
|
238
|
-
| [mcp_resources_prompts_example.rb](examples/mcp_resources_prompts_example.rb) | MCP resources and prompts |
|
|
239
|
-
| [http_mcp_server_example.rb](examples/http_mcp_server_example.rb) | HTTP/SSE MCP server configuration |
|
|
237
|
+
| [mcp_calculator.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_calculator.rb) | Custom tools with SDK MCP servers |
|
|
238
|
+
| [mcp_resources_prompts_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_resources_prompts_example.rb) | MCP resources and prompts |
|
|
239
|
+
| [http_mcp_server_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/http_mcp_server_example.rb) | HTTP/SSE MCP server configuration |
|
|
240
240
|
|
|
241
241
|
### Hooks & Permissions
|
|
242
242
|
|
|
243
243
|
| Example | Description |
|
|
244
244
|
|---------|-------------|
|
|
245
|
-
| [hooks_example.rb](examples/hooks_example.rb) | Using hooks to control tool execution |
|
|
246
|
-
| [advanced_hooks_example.rb](examples/advanced_hooks_example.rb) | Typed hook inputs/outputs |
|
|
247
|
-
| [lifecycle_hooks_example.rb](examples/lifecycle_hooks_example.rb) | All 27 hook events |
|
|
248
|
-
| [permission_callback_example.rb](examples/permission_callback_example.rb) | Dynamic tool permission control |
|
|
245
|
+
| [hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/hooks_example.rb) | Using hooks to control tool execution |
|
|
246
|
+
| [advanced_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/advanced_hooks_example.rb) | Typed hook inputs/outputs |
|
|
247
|
+
| [lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb) | All 27 hook events |
|
|
248
|
+
| [permission_callback_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/permission_callback_example.rb) | Dynamic tool permission control |
|
|
249
249
|
|
|
250
250
|
### Advanced
|
|
251
251
|
|
|
252
252
|
| Example | Description |
|
|
253
253
|
|---------|-------------|
|
|
254
|
-
| [budget_control_example.rb](examples/budget_control_example.rb) | Budget control with `max_budget_usd` |
|
|
255
|
-
| [fallback_model_example.rb](examples/fallback_model_example.rb) | Fallback model configuration |
|
|
256
|
-
| [extended_thinking_example.rb](examples/extended_thinking_example.rb) | Extended thinking |
|
|
257
|
-
| [e2b_transport_example.rb](examples/e2b_transport_example.rb) | Custom transport running CLI in an E2B microVM |
|
|
254
|
+
| [budget_control_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/budget_control_example.rb) | Budget control with `max_budget_usd` |
|
|
255
|
+
| [fallback_model_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/fallback_model_example.rb) | Fallback model configuration |
|
|
256
|
+
| [extended_thinking_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/extended_thinking_example.rb) | Extended thinking |
|
|
257
|
+
| [e2b_transport_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/e2b_transport_example.rb) | Custom transport running CLI in an E2B microVM |
|
|
258
258
|
|
|
259
259
|
### Observability & Rails
|
|
260
260
|
|
|
261
261
|
| Example | Description |
|
|
262
262
|
|---------|-------------|
|
|
263
|
-
| [otel_langfuse_example.rb](examples/otel_langfuse_example.rb) | OpenTelemetry tracing with Langfuse backend |
|
|
264
|
-
| [rails_actioncable_example.rb](examples/rails_actioncable_example.rb) | ActionCable streaming to frontend |
|
|
265
|
-
| [rails_background_job_example.rb](examples/rails_background_job_example.rb) | Background jobs with session resumption |
|
|
263
|
+
| [otel_langfuse_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/otel_langfuse_example.rb) | OpenTelemetry tracing with Langfuse backend |
|
|
264
|
+
| [rails_actioncable_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_actioncable_example.rb) | ActionCable streaming to frontend |
|
|
265
|
+
| [rails_background_job_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_background_job_example.rb) | Background jobs with session resumption |
|
|
266
266
|
|
|
267
267
|
## Available Tools
|
|
268
268
|
|
data/docs/client.md
CHANGED
|
@@ -76,7 +76,7 @@ client = ClaudeAgentSDK::Client.new(
|
|
|
76
76
|
|
|
77
77
|
### Reference: running `claude` inside an E2B sandbox
|
|
78
78
|
|
|
79
|
-
[`examples/e2b_transport_example.rb`](
|
|
79
|
+
[`examples/e2b_transport_example.rb`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/e2b_transport_example.rb) is a working transport that runs the Claude Code CLI inside an [E2B](https://e2b.dev) Firecracker microVM instead of on your host. The wire protocol stays identical — only the I/O layer changes:
|
|
80
80
|
|
|
81
81
|
```
|
|
82
82
|
ClaudeAgentSDK::Client (host)
|
data/docs/configuration.md
CHANGED
|
@@ -35,7 +35,7 @@ ClaudeAgentSDK.query(prompt: "Create a profile for a software engineer", options
|
|
|
35
35
|
end
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
See [examples/structured_output_example.rb](
|
|
38
|
+
See [examples/structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb).
|
|
39
39
|
|
|
40
40
|
## Thinking Configuration
|
|
41
41
|
|
|
@@ -91,7 +91,7 @@ ClaudeAgentSDK.query(prompt: "Explain recursion", options: options) do |message|
|
|
|
91
91
|
end
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
See [examples/budget_control_example.rb](
|
|
94
|
+
See [examples/budget_control_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/budget_control_example.rb).
|
|
95
95
|
|
|
96
96
|
## Fallback Model
|
|
97
97
|
|
|
@@ -102,7 +102,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
102
102
|
)
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
See [examples/fallback_model_example.rb](
|
|
105
|
+
See [examples/fallback_model_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/fallback_model_example.rb).
|
|
106
106
|
|
|
107
107
|
## Beta Features
|
|
108
108
|
|
|
@@ -155,7 +155,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
155
155
|
)
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
See [examples/sandbox_example.rb](
|
|
158
|
+
See [examples/sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb).
|
|
159
159
|
|
|
160
160
|
## Bare Mode
|
|
161
161
|
|
|
@@ -186,7 +186,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
186
186
|
|
|
187
187
|
**What still works:** skills (via `/skill-name`), explicit `--add-dir` CLAUDE.md, `--settings`, `--mcp-config`, `--agents`, `--plugin-dir`, API key from `ANTHROPIC_API_KEY` env var.
|
|
188
188
|
|
|
189
|
-
See [examples/bare_mode_example.rb](
|
|
189
|
+
See [examples/bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb).
|
|
190
190
|
|
|
191
191
|
## File Checkpointing & Rewind
|
|
192
192
|
|
data/docs/errors.md
CHANGED
|
@@ -18,7 +18,7 @@ ClaudeAgentSDK.query(prompt: "Hello") do |message|
|
|
|
18
18
|
end
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
See [examples/error_handling_example.rb](
|
|
21
|
+
See [examples/error_handling_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/error_handling_example.rb).
|
|
22
22
|
|
|
23
23
|
## Exception Handling
|
|
24
24
|
|
|
@@ -92,4 +92,4 @@ end
|
|
|
92
92
|
| `CLIJSONDecodeError` | JSON parsing issues |
|
|
93
93
|
| `MessageParseError` | Message parsing issues |
|
|
94
94
|
|
|
95
|
-
See [lib/claude_agent_sdk/errors.rb](
|
|
95
|
+
See [lib/claude_agent_sdk/errors.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/errors.rb) for all error types.
|
|
@@ -19,7 +19,7 @@ All hook input objects include common fields like `session_id`, `transcript_path
|
|
|
19
19
|
- `SubagentStart` → `SubagentStartHookInput` (`agent_id`, `agent_type`)
|
|
20
20
|
- `PermissionRequest` → `PermissionRequestHookInput` (`tool_name`, `tool_input`, `permission_suggestions`)
|
|
21
21
|
|
|
22
|
-
All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](
|
|
22
|
+
All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types.rb) and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb).
|
|
23
23
|
|
|
24
24
|
### Example: Blocking Dangerous Commands
|
|
25
25
|
|
|
@@ -67,7 +67,7 @@ Async do
|
|
|
67
67
|
end.wait
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
See [examples/hooks_example.rb](
|
|
70
|
+
See [examples/hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/hooks_example.rb), [examples/advanced_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/advanced_hooks_example.rb), and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb).
|
|
71
71
|
|
|
72
72
|
## Permission Callbacks
|
|
73
73
|
|
|
@@ -107,4 +107,4 @@ Async do
|
|
|
107
107
|
end.wait
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
See [examples/permission_callback_example.rb](
|
|
110
|
+
See [examples/permission_callback_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/permission_callback_example.rb).
|
data/docs/mcp-servers.md
CHANGED
|
@@ -150,4 +150,4 @@ server = ClaudeAgentSDK.create_sdk_mcp_server(
|
|
|
150
150
|
)
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
See [examples/mcp_calculator.rb](
|
|
153
|
+
See [examples/mcp_calculator.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_calculator.rb) and [examples/mcp_resources_prompts_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_resources_prompts_example.rb) for complete examples.
|
data/docs/observability.md
CHANGED
|
@@ -147,4 +147,4 @@ end
|
|
|
147
147
|
options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [MyObserver.new])
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
See [examples/otel_langfuse_example.rb](
|
|
150
|
+
See [examples/otel_langfuse_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/otel_langfuse_example.rb) for a complete multi-tool example.
|
data/docs/rails.md
CHANGED
|
@@ -193,7 +193,7 @@ end
|
|
|
193
193
|
Then every `ClaudeAgentSDK.query` and `Client` session automatically gets traced — no per-call wiring needed. The lambda factory ensures each request gets its own observer with isolated span state, safe for concurrent Puma/Sidekiq workers.
|
|
194
194
|
|
|
195
195
|
See:
|
|
196
|
-
- [examples/rails_actioncable_example.rb](
|
|
197
|
-
- [examples/rails_background_job_example.rb](
|
|
198
|
-
- [examples/session_resumption_example.rb](
|
|
199
|
-
- [examples/http_mcp_server_example.rb](
|
|
196
|
+
- [examples/rails_actioncable_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_actioncable_example.rb)
|
|
197
|
+
- [examples/rails_background_job_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_background_job_example.rb)
|
|
198
|
+
- [examples/session_resumption_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_resumption_example.rb)
|
|
199
|
+
- [examples/http_mcp_server_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/http_mcp_server_example.rb)
|
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
|
|
|
@@ -166,7 +172,7 @@ ClaudeAgentSDK::Testing.run_session_store_conformance(-> { MyStore.new(...) })
|
|
|
166
172
|
```
|
|
167
173
|
|
|
168
174
|
Copy-in reference adapters for **S3, Redis, and Postgres** live in
|
|
169
|
-
[`examples/session_stores/`](
|
|
175
|
+
[`examples/session_stores/`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md), each with a
|
|
170
176
|
production checklist.
|
|
171
177
|
|
|
172
178
|
### Store-backed helpers
|
data/docs/types.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Types Reference
|
|
2
2
|
|
|
3
|
-
See [lib/claude_agent_sdk/types.rb](
|
|
3
|
+
See [lib/claude_agent_sdk/types.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types.rb) for complete type definitions.
|
|
4
4
|
|
|
5
5
|
## Message Types
|
|
6
6
|
|
|
@@ -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
|
|
@@ -50,16 +50,32 @@ module ClaudeAgentSDK
|
|
|
50
50
|
@root_span = nil
|
|
51
51
|
@root_context = nil
|
|
52
52
|
@tool_spans = {} # tool_use_id => span
|
|
53
|
-
@first_user_input = nil #
|
|
53
|
+
@first_user_input = nil # first user prompt of the current trace
|
|
54
|
+
@pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt)
|
|
54
55
|
@last_assistant_text = nil # capture last assistant text for trace output
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def on_user_prompt(prompt)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
text = prompt.to_s
|
|
60
|
+
return if text.empty?
|
|
61
|
+
|
|
62
|
+
if @root_span.nil?
|
|
63
|
+
# Pre-init: buffered for the trace start_trace is about to open.
|
|
64
|
+
# First prompt wins as the trace input.
|
|
65
|
+
@first_user_input ||= text
|
|
66
|
+
elsif @first_user_input.nil?
|
|
67
|
+
# Open trace with no input yet (init arrived before the prompt).
|
|
68
|
+
@first_user_input = text
|
|
69
|
+
@root_span.set_attribute('input.value', truncate(text))
|
|
70
|
+
else
|
|
71
|
+
# The open trace already has its input, so this prompt belongs to
|
|
72
|
+
# the NEXT trace: either the current turn was interrupted (/clear —
|
|
73
|
+
# a superseding init follows with no ResultMessage) or the user
|
|
74
|
+
# queued the next turn mid-stream (its init follows the current
|
|
75
|
+
# ResultMessage). A first-prompt-forever latch here dropped these
|
|
76
|
+
# prompts from the new trace entirely.
|
|
77
|
+
@pending_prompt ||= text
|
|
78
|
+
end
|
|
63
79
|
end
|
|
64
80
|
|
|
65
81
|
def on_message(message)
|
|
@@ -96,6 +112,10 @@ module ClaudeAgentSDK
|
|
|
96
112
|
def on_close
|
|
97
113
|
finish_open_spans
|
|
98
114
|
reset_session_buffers
|
|
115
|
+
# Unlike the per-trace buffers, the pending next-trace prompt survives
|
|
116
|
+
# end_trace/supersede resets by design — but the session is over now,
|
|
117
|
+
# and a reused observer must not leak it into the next session.
|
|
118
|
+
@pending_prompt = nil
|
|
99
119
|
end
|
|
100
120
|
|
|
101
121
|
private
|
|
@@ -112,6 +132,11 @@ module ClaudeAgentSDK
|
|
|
112
132
|
superseding = !@root_span.nil?
|
|
113
133
|
finish_open_spans
|
|
114
134
|
reset_session_buffers if superseding
|
|
135
|
+
# A prompt that arrived while the previous trace already had its input
|
|
136
|
+
# was buffered for THIS trace; it wins over a younger post-end prompt
|
|
137
|
+
# because it came first chronologically.
|
|
138
|
+
@first_user_input = @pending_prompt if @pending_prompt
|
|
139
|
+
@pending_prompt = nil
|
|
115
140
|
|
|
116
141
|
attrs = {
|
|
117
142
|
# gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.)
|
|
@@ -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
|
|
@@ -25,16 +25,23 @@ module ClaudeAgentSDK
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# A prebuilt JSON Schema is detected by type == 'object' (String or Symbol)
|
|
28
|
-
#
|
|
29
|
-
#
|
|
28
|
+
# with properties either ABSENT or a Hash. Properties-present-but-not-Hash
|
|
29
|
+
# falls through to the simple idiom, which uses Symbols as type VALUES —
|
|
30
30
|
# { type: :string, properties: :string } is a legal simple schema with
|
|
31
|
-
# params literally named type/properties.
|
|
31
|
+
# params literally named type/properties. Propertyless object schemas
|
|
32
|
+
# ({ type: 'object', additionalProperties: ... } / oneOf / empty-object
|
|
33
|
+
# accept-anything forms) previously fell into the simple branch and were
|
|
34
|
+
# mangled into nonsense parameter lists ("additionalProperties" as a
|
|
35
|
+
# required string param). A $ref-only schema without type: 'object' remains
|
|
36
|
+
# indistinguishable from a params hash — declare the type alongside $ref.
|
|
32
37
|
def self.prebuilt_json_schema?(schema)
|
|
33
38
|
return false unless schema.is_a?(Hash)
|
|
34
39
|
|
|
35
40
|
type_val = schema[:type] || schema['type']
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
return false unless (type_val.is_a?(String) || type_val.is_a?(Symbol)) && type_val.to_s == 'object'
|
|
42
|
+
return true unless schema.key?(:properties) || schema.key?('properties')
|
|
43
|
+
|
|
44
|
+
(schema[:properties] || schema['properties']).is_a?(Hash)
|
|
38
45
|
end
|
|
39
46
|
|
|
40
47
|
# Single source of truth for tool input schemas: prebuilt schemas are
|
|
@@ -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.
|
|
@@ -151,9 +168,15 @@ module ClaudeAgentSDK
|
|
|
151
168
|
end
|
|
152
169
|
return nil if sessions.nil? || sessions.empty?
|
|
153
170
|
|
|
171
|
+
sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s)
|
|
172
|
+
|
|
154
173
|
sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
|
|
155
174
|
sid = cand['session_id']
|
|
156
175
|
next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
|
|
176
|
+
# Skip known sidechains without downloading their transcript: the
|
|
177
|
+
# newest keys are often sidechains, and full-loading each one made
|
|
178
|
+
# --continue O(sum of transcript sizes) instead of O(candidates).
|
|
179
|
+
next if sidechain_flags&.fetch(sid, false)
|
|
157
180
|
|
|
158
181
|
loaded = load_candidate(store, project_key, sid, timeout_s)
|
|
159
182
|
next if loaded.nil?
|
|
@@ -166,6 +189,26 @@ module ClaudeAgentSDK
|
|
|
166
189
|
nil
|
|
167
190
|
end
|
|
168
191
|
|
|
192
|
+
# session_id => true for sessions the summary sidecar marks as sidechains;
|
|
193
|
+
# nil when the store doesn't implement list_session_summaries or the call
|
|
194
|
+
# fails (callers then fall back to checking each full load). The per-load
|
|
195
|
+
# isSidechain check above stays even on the summary path: a missing or
|
|
196
|
+
# stale sidecar row costs one extra load, never a wrong resume.
|
|
197
|
+
def sidechain_flags_from_summaries(store, project_key, timeout_s)
|
|
198
|
+
return nil unless SessionStore.implements?(store, :list_session_summaries)
|
|
199
|
+
|
|
200
|
+
rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries') do
|
|
201
|
+
store.list_session_summaries(project_key)
|
|
202
|
+
end
|
|
203
|
+
Array(rows).each_with_object({}) do |row, acc|
|
|
204
|
+
sid = row.is_a?(Hash) ? row['session_id'] : nil
|
|
205
|
+
acc[sid] = row.dig('data', 'is_sidechain') == true if sid
|
|
206
|
+
end
|
|
207
|
+
# NotImplementedError is a ScriptError that with_timeout does not wrap.
|
|
208
|
+
rescue StandardError, NotImplementedError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
169
212
|
# Adapters contractually report mtime as an epoch-ms Numeric (the
|
|
170
213
|
# conformance suite asserts it), but SQL timestamps naturally arrive as
|
|
171
214
|
# ISO-8601 Strings through JSON. Unary minus on a String is String#-@
|
|
@@ -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
|
|
@@ -314,14 +373,26 @@ module ClaudeAgentSDK
|
|
|
314
373
|
head, tail = read_head_tail(file_path, stat.size)
|
|
315
374
|
|
|
316
375
|
# Check first line for sidechain
|
|
317
|
-
|
|
318
|
-
return nil if first_line.include?('"isSidechain":true') || first_line.include?('"isSidechain": true')
|
|
376
|
+
return nil if sidechain_first_line?(head.lines.first || '')
|
|
319
377
|
|
|
320
378
|
build_session_info(file_path, head, tail, stat, project_path)
|
|
321
379
|
rescue StandardError
|
|
322
380
|
nil
|
|
323
381
|
end
|
|
324
382
|
|
|
383
|
+
# Sidechain classification parses the first line and reads the top-level
|
|
384
|
+
# key — the same key the store fold reads (`entry['isSidechain'] == true`).
|
|
385
|
+
# The old raw substring scan also matched "isSidechain":true nested inside
|
|
386
|
+
# a structured field, hiding the session from disk listings only. A first
|
|
387
|
+
# line truncated by the read window can't be shape-checked and keeps the
|
|
388
|
+
# substring heuristic.
|
|
389
|
+
def sidechain_first_line?(line)
|
|
390
|
+
entry = JSON.parse(line)
|
|
391
|
+
entry.is_a?(Hash) && entry['isSidechain'] == true
|
|
392
|
+
rescue StandardError
|
|
393
|
+
line.include?('"isSidechain":true') || line.include?('"isSidechain": true')
|
|
394
|
+
end
|
|
395
|
+
|
|
325
396
|
def read_head_tail(file_path, size)
|
|
326
397
|
head = tail = nil
|
|
327
398
|
File.open(file_path, 'rb') do |f|
|
|
@@ -341,15 +412,19 @@ module ClaudeAgentSDK
|
|
|
341
412
|
# Head fallback covers short sessions where the title entry may not be in tail.
|
|
342
413
|
# Each candidate passes through presence so a blank value (e.g. a
|
|
343
414
|
# trailing title-clearing entry) falls through instead of short-circuiting.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
415
|
+
# Summary-chain fields use the top-level-verified scan: a raw byte scan
|
|
416
|
+
# also matches these keys nested inside tool_use inputs, reporting tool
|
|
417
|
+
# arguments as the session title/summary (and diverging from the store
|
|
418
|
+
# fold, which reads top-level keys only).
|
|
419
|
+
custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true)) ||
|
|
420
|
+
presence(extract_top_level_string_field(head, 'customTitle', last: true)) ||
|
|
421
|
+
presence(extract_top_level_string_field(tail, 'aiTitle', last: true)) ||
|
|
422
|
+
presence(extract_top_level_string_field(head, 'aiTitle', last: true))
|
|
348
423
|
first_prompt = extract_first_prompt_from_head(head)
|
|
349
424
|
# lastPrompt tail entry shows what the user was most recently doing.
|
|
350
425
|
summary = custom_title ||
|
|
351
|
-
presence(
|
|
352
|
-
presence(
|
|
426
|
+
presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) ||
|
|
427
|
+
presence(extract_top_level_string_field(tail, 'summary', last: true)) ||
|
|
353
428
|
first_prompt
|
|
354
429
|
return nil if summary.nil? || summary.strip.empty?
|
|
355
430
|
|
|
@@ -911,11 +986,23 @@ module ClaudeAgentSDK
|
|
|
911
986
|
# encoding: transcripts are UTF-8 regardless of locale; without it a
|
|
912
987
|
# LANG=C process raises Encoding::InvalidByteSequenceError on the first
|
|
913
988
|
# multibyte line, aborting the import mid-way (Python pins utf-8 here).
|
|
914
|
-
File.foreach(file_path, encoding: 'UTF-8') do |line|
|
|
989
|
+
File.foreach(file_path, encoding: 'UTF-8').with_index(1) do |line, lineno|
|
|
915
990
|
line = line.chomp
|
|
916
991
|
next if line.empty?
|
|
917
992
|
|
|
918
|
-
|
|
993
|
+
begin
|
|
994
|
+
entry = JSON.parse(line)
|
|
995
|
+
rescue JSON::ParserError
|
|
996
|
+
# A truncated trailing line is an ordinary interrupted-CLI artifact;
|
|
997
|
+
# every read path tolerates it (parse_jsonl_entries skips bad
|
|
998
|
+
# lines), and raising here aborted mid-import, leaving a partial
|
|
999
|
+
# store import behind. Skip the line, but say so — import is an
|
|
1000
|
+
# explicit user operation.
|
|
1001
|
+
warn "Claude SDK: import_session_to_store skipped unparseable line #{lineno} of #{file_path}"
|
|
1002
|
+
next
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
batch << entry
|
|
919
1006
|
nbytes += line.bytesize
|
|
920
1007
|
next unless batch.length >= batch_size || nbytes >= TranscriptMirrorBatcher::MAX_PENDING_BYTES
|
|
921
1008
|
|
|
@@ -13,10 +13,13 @@ module ClaudeAgentSDK
|
|
|
13
13
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
|
-
# Assert the
|
|
16
|
+
# Assert the 16 SessionStore behavioral contracts against an adapter.
|
|
17
17
|
#
|
|
18
18
|
# Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
|
|
19
|
-
# Contract
|
|
19
|
+
# Contract 16 (Ruby extension) locks one-row-per-session `list_sessions`
|
|
20
|
+
# under multiple appends — the naive one-row-per-append implementation
|
|
21
|
+
# passed every other contract and then showed N duplicate sessions in
|
|
22
|
+
# pickers. Contract 15 is a Ruby SDK extension locking empty-subpath delete
|
|
20
23
|
# coherence ('' == no subpath, the same addressing append/load already
|
|
21
24
|
# use in every implementation in both SDKs); it runs only for stores
|
|
22
25
|
# implementing #delete, and skip_optional: %w[delete] excludes the
|
|
@@ -38,7 +41,15 @@ module ClaudeAgentSDK
|
|
|
38
41
|
# @param skip_optional [Array<String>] optional method names to skip.
|
|
39
42
|
# Contracts for an optional method are also skipped automatically when the
|
|
40
43
|
# store does not override it.
|
|
41
|
-
|
|
44
|
+
# @param check_uuid_dedupe [Boolean] additionally assert the ADVISORY
|
|
45
|
+
# uuid-dedupe recommendation: re-appending a batch that overlaps a prior
|
|
46
|
+
# write (exactly what the mirror batcher's retry can produce) must not
|
|
47
|
+
# duplicate entries sharing a uuid. The SessionStore contract phrases
|
|
48
|
+
# this as a "should" and the shipped reference adapters deliberately
|
|
49
|
+
# skip it (duplicates largely self-heal through last-wins summary folds,
|
|
50
|
+
# though resumed transcripts can still show repeated turns), so this
|
|
51
|
+
# defaults to off. Turn it on if your adapter dedupes.
|
|
52
|
+
def run_session_store_conformance(make_store, skip_optional: [], check_uuid_dedupe: false)
|
|
42
53
|
skip_optional = skip_optional.map(&:to_s)
|
|
43
54
|
invalid = skip_optional - OPTIONAL_METHODS
|
|
44
55
|
raise ConformanceError, "unknown optional methods in skip_optional: #{invalid}" unless invalid.empty?
|
|
@@ -56,6 +67,7 @@ module ClaudeAgentSDK
|
|
|
56
67
|
check_list_session_summaries(fresh, has_list_sessions, has_delete) if has_list_summaries
|
|
57
68
|
check_delete(fresh, has_list_subkeys, has_list_sessions) if has_delete
|
|
58
69
|
check_list_subkeys(fresh) if has_list_subkeys
|
|
70
|
+
check_uuid_dedupe_contract(fresh) if check_uuid_dedupe
|
|
59
71
|
nil
|
|
60
72
|
end
|
|
61
73
|
|
|
@@ -86,11 +98,21 @@ module ClaudeAgentSDK
|
|
|
86
98
|
entry('uuid' => 'm', 'n' => 3), entry('uuid' => 'b', 'n' => 4)],
|
|
87
99
|
'multiple appends must preserve call order')
|
|
88
100
|
|
|
89
|
-
# 4. append([]) is a no-op
|
|
101
|
+
# 4. append([]) is a no-op — including on a never-written key, which
|
|
102
|
+
# must NOT come into existence (a phantom key surfaces as an empty
|
|
103
|
+
# session in listings; documented on InMemorySessionStore, previously
|
|
104
|
+
# never asserted).
|
|
90
105
|
store = fresh.call
|
|
91
106
|
store.append(key, [entry('uuid' => 'a', 'n' => 1)])
|
|
92
107
|
store.append(key, [])
|
|
93
108
|
assert_eq(store.load(key), [entry('uuid' => 'a', 'n' => 1)], 'append([]) must be a no-op')
|
|
109
|
+
phantom = { 'project_key' => key['project_key'], 'session_id' => 'phantom' }
|
|
110
|
+
store.append(phantom, [])
|
|
111
|
+
assert(store.load(phantom).nil?, 'append([]) to an unwritten key must not create it')
|
|
112
|
+
if has_list_sessions
|
|
113
|
+
listed = store.list_sessions(key['project_key']).map { |s| s['session_id'] }
|
|
114
|
+
assert(!listed.include?('phantom'), 'append([]) must not surface a phantom session in list_sessions')
|
|
115
|
+
end
|
|
94
116
|
|
|
95
117
|
# 5. subpath keys are stored independently of main.
|
|
96
118
|
store = fresh.call
|
|
@@ -134,6 +156,16 @@ module ClaudeAgentSDK
|
|
|
134
156
|
[entry('n' => 1)])
|
|
135
157
|
assert_eq(store.list_sessions('proj').map { |s| s['session_id'] }, ['main'],
|
|
136
158
|
'list_sessions must exclude subagent subpaths')
|
|
159
|
+
|
|
160
|
+
# 16. one row per session regardless of how many appends built it. A
|
|
161
|
+
# one-row-per-append implementation passed every other contract (the
|
|
162
|
+
# multi-append guard existed only for list_session_summaries) and then
|
|
163
|
+
# showed N duplicate sessions in pickers.
|
|
164
|
+
store = fresh.call
|
|
165
|
+
multi = { 'project_key' => 'proj', 'session_id' => 'multi' }
|
|
166
|
+
3.times { |i| store.append(multi, [entry('uuid' => "u#{i}", 'n' => i)]) }
|
|
167
|
+
assert_eq(store.list_sessions('proj').map { |s| s['session_id'] }, ['multi'],
|
|
168
|
+
'list_sessions must return exactly one row per session after multiple appends')
|
|
137
169
|
end
|
|
138
170
|
|
|
139
171
|
# -- Optional: list_session_summaries ----------------------------------
|
|
@@ -259,6 +291,19 @@ module ClaudeAgentSDK
|
|
|
259
291
|
'list_subkeys of an unknown session must be empty')
|
|
260
292
|
end
|
|
261
293
|
|
|
294
|
+
# -- Opt-in: uuid dedupe (advisory) --------------------------------------
|
|
295
|
+
|
|
296
|
+
# A retried mirror batch can overlap a prior write (timeout abandons the
|
|
297
|
+
# in-flight append, which may still land). Adapters that opt in promise
|
|
298
|
+
# load reflects each uuid once, in first-seen order.
|
|
299
|
+
def check_uuid_dedupe_contract(fresh)
|
|
300
|
+
store = fresh.call
|
|
301
|
+
store.append(key, [entry('uuid' => 'r1', 'n' => 1), entry('uuid' => 'r2', 'n' => 2)])
|
|
302
|
+
store.append(key, [entry('uuid' => 'r2', 'n' => 2), entry('uuid' => 'r3', 'n' => 3)]) # retry overlap
|
|
303
|
+
assert_eq(store.load(key).map { |e| e['uuid'] }, %w[r1 r2 r3],
|
|
304
|
+
'a retried batch overlapping a prior write must dedupe by entry uuid')
|
|
305
|
+
end
|
|
306
|
+
|
|
262
307
|
# -- helpers -----------------------------------------------------------
|
|
263
308
|
|
|
264
309
|
def key
|
|
@@ -303,7 +348,7 @@ module ClaudeAgentSDK
|
|
|
303
348
|
end
|
|
304
349
|
|
|
305
350
|
private_class_method :check_append_and_load, :check_list_sessions, :check_list_session_summaries,
|
|
306
|
-
:check_delete, :check_list_subkeys, :key, :entry,
|
|
307
|
-
:summaries_by_id, :assert, :assert_eq
|
|
351
|
+
:check_delete, :check_list_subkeys, :check_uuid_dedupe_contract, :key, :entry,
|
|
352
|
+
:epoch_ms?, :optional?, :summaries_by_id, :assert, :assert_eq
|
|
308
353
|
end
|
|
309
354
|
end
|
|
@@ -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
|
|