Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ on:
- lib/claude_agent_sdk/cli_installer.rb
- lib/claude_agent_sdk/subprocess_cli_transport.rb
- lib/claude_agent_sdk/query.rb
- lib/claude_agent_sdk/query/**
- lib/claude_agent_sdk/message_parser.rb
- spec/integration/**
- .github/workflows/integration.yml
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Internal
- The decision of when `query()` may close stdin moved out of `Query` into one private class, `Query::RunLifecycle` (`lib/claude_agent_sdk/query/run_lifecycle.rb`): the per-run end, the task ledger, the session state and the ceiling timer, with the orderings between them. `Query` hands it five events, including frames, and one wait, and supplies the timer. No behavior change; nothing public moved. Its specs now go through that interface (`spec/unit/query/run_lifecycle_spec.rb`) instead of reaching into `Query`'s instance variables.
- Hash-form option interpretation moved into one private module (`OptionForms`); no behaviour change.

## [1.2.0] - 2026-10-03
Expand Down
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ User code
- Permission callback handling (can_use_tool)
- SDK MCP server request routing (tools/call, resources/read, prompts/get)
- Message queue (Async::Queue) separating control messages from SDK messages
- When stdin may close: Query::RunLifecycle (lib/claude_agent_sdk/query/run_lifecycle.rb)
│
▼
SubprocessCLITransport (lib/claude_agent_sdk/subprocess_cli_transport.rb)
Expand All @@ -40,10 +41,18 @@ User code

### Two API Entry Points

- **`query()`** — Simple function interface, run in its own `Async { }.wait`. Like `Client`, it always uses streaming mode with a `Query` handler and the control protocol, so hooks, `can_use_tool` and SDK MCP servers work (an optional `transport:` replaces the default `SubprocessCLITransport`). It writes the prompt (a String, or an Enumerator streamed in the background), then closes stdin. When hooks / `can_use_tool` / SDK MCP servers still need the channel it waits for the end of the run (Python #1279): the CLI's `session_state_changed` `idle` after a result (the transport sets `CLAUDE_CODE_SDK_READS_SESSION_STATE=1`, and `Query` drops the `sdk_host_only` frames that brings), bounded between turns by `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` (10 min; a main-thread turn, `requires_action` or a tracked agent stops the clock); from a CLI that sends no state, the first result with no tracked task in flight (the #1088 ledger). A run is a per-run `Query::RunEnd` that `reopen_run` swaps out, so a waiter the ended run woke still closes stdin. The ceiling sleeper is a child of the read task, never a `spawn_task` entry, and every exit path clears it. The difference from `Client` is lifecycle, not protocol: no follow-up queries and no outbound control calls (interrupt, set_model, ...). `ClaudeAgentSDK.ask` is a thin wrapper over `query()` that returns the last `ResultMessage` (raising `CLIConnectionError` if none arrives) and adds no error policy of its own.
- **`query()`** — Simple function interface, run in its own `Async { }.wait`. Like `Client`, it always uses streaming mode with a `Query` handler and the control protocol, so hooks, `can_use_tool` and SDK MCP servers work (an optional `transport:` replaces the default `SubprocessCLITransport`). It writes the prompt (a String, or an Enumerator streamed in the background), then closes stdin. When hooks / `can_use_tool` / SDK MCP servers still need the channel it waits for the end of the run (Python #1279), which `Query::RunLifecycle` decides (see Run Lifecycle below). The difference from `Client` is lifecycle, not protocol: no follow-up queries and no outbound control calls (interrupt, set_model, ...). `ClaudeAgentSDK.ask` is a thin wrapper over `query()` that returns the last `ResultMessage` (raising `CLIConnectionError` if none arrives) and adds no error policy of its own.

- **`Client`** — Full-featured bidirectional sessions. Accepts optional `transport_class` (defaults to `SubprocessCLITransport`) and `transport_args` for custom transports. Creates transport, instantiates a `Query` handler that runs `read_messages` in an async task, routes control messages internally, and exposes SDK messages via `Async::Queue`. Supports hooks, permission callbacks, SDK MCP servers, interrupt, model switching, and file rewind.

### Run Lifecycle (when stdin may close)

`Query::RunLifecycle` (`lib/claude_agent_sdk/query/run_lifecycle.rb`, `@api private`) owns the one decision *when may stdin close?*: the CLI's `session_state_changed` `idle` after a result (the transport sets `CLAUDE_CODE_SDK_READS_SESSION_STATE=1`, and `Query` drops the `sdk_host_only` frames that brings), bounded between turns by `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` (10 min; a main-thread turn, `requires_action` or a tracked agent stops the clock); from a CLI that sends no state, the first result with no tracked task in flight (the #1088 ledger).

`Query` only feeds it: `frame` for every frame the read loop does not route elsewhere (after the mirror flush for a result, before the `sdk_host_only` drop), `message_will_write` (after serialization, before the write), `wait`, `stdin_closing`, `empty_input` (final only — the #1226 trade-off), `reader_gone`. It supplies the timer (`sleeper:`, by default `Query#sleep_on_read_task`: a child of the read task, never a `spawn_task` entry) and three live, non-suspending predicates (`bidirectional_needs:`, `closed:`, `control_requests_in_flight:`). `close_now` never calls into it — it can run off the reactor — and the lifecycle is reactor-only, with no mutex.

The orderings live inside the module, and its class comment states them: a run is a per-run `RunEnd`, marked ended **before** its waiters are signalled and before the sleeper is stopped (each of those can hand the reactor to an input writer, whose message must then get a run of its own); a ceiling that wakes checks its generation, detaches, then acts, and re-arms instead of ending while a control request is in flight; every exit path clears the sleeper. `spec/unit/query/run_lifecycle_spec.rb` tables them through the interface with a `FakeSleeper` (`spec/support/fake_sleeper.rb`; `Query.new(sleeper:)` takes one too); specs read `query.run_lifecycle` (`ended?`, `final?`, `ceiling_armed?`, `inflight?`, `session_state`), never its instance variables. It is the next TLA+ target (`formal/tla/README.md`).

### CLI Discovery / Installer

`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `claude` on the process `PATH`, searched by the SDK itself (`#executable_on_path`; no `which` — it would be found through `PATH` too, relative entries included, and its answer would have to be believed; with no `PATH` at all, `DEFAULT_DISCOVERY_SEARCH_PATH`, Ruby's default without its `.`) → common install locations.
Expand Down
11 changes: 11 additions & 0 deletions formal/tla/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ There are three properties. `EverySenderFinishes` says every control method retu
| `bug_nocheck` | `CHECK_SLOT_FIRST = FALSE` | `EverySenderFinishes` violated: the fiber's write suspended after delivery, and the broadcast signal fired before it waited, so the signal was lost | `await_control_response`: "a signal arriving before the sender reaches wait would otherwise be dropped" |
| `bug_edge_thread` | `LEVEL_TRIGGERED = FALSE` | `EverySenderFinishes` violated: the sender off the owning reactor checked the slot, the signal fired before it parked, and the signal was lost. A fiber on another thread's reactor was in exactly this position while the waiter was picked by "has an Async task" | `ThreadWaiter`: "closing the check-then-wait gap that an edge-triggered Condition would lose across threads" |

## Next: `RunLifecycle.tla` (not written yet)

`Query::RunLifecycle` (`lib/claude_agent_sdk/query/run_lifecycle.rb`) decides when `query()` may close stdin: at the CLI's `idle` after a result, at a ceiling between turns, or at the first result with no tracked task in flight. Its fixes so far were all orderings between fibers — ending a run before stopping the ceiling sleeper, re-arming while a control request is in flight, the task ledger — and none of them is model-checked. The decision now sits in one Ruby file behind five events and one wait, so one model can cover it. Until it exists there is no model and no exhaustive check of the interleavings. What runs is RSpec: the lifecycle's table (`spec/unit/query/run_lifecycle_spec.rb`), and through a real `Query`, the end-before-clear ordering on the real scheduler (`spec/unit/query_end_run_order_spec.rb`, which CI also runs on the async 2.10 floor) and, in `spec/unit/query_run_end_spec.rb`, a hook parked under a passing ceiling, the mirror flush gated ahead of a result, and a close that begins while the read loop holds a result (from a reactor task and from another thread). Each covers only the interleavings its examples spell out.

What the model has to hold:

- **Per-run identity, not one `ended` flag.** A waiter keeps the run it started waiting on; a run that ended and was reopened still releases it. So: a sequence of runs, the current one, the set of ended ones, and the run each waiter captured.
- **Actions split at the handoffs.** Ending a run is three steps another fiber can run between: mark the run ended, signal its waiters, stop the sleeper. The input writer (`MessageWillWrite`) must be able to interleave with each. The other actions: a result, a session state, a main-thread turn, a task starting or settling, a control request starting or ending, the ceiling waking (armed, and its generation still current), stdin closing, empty input, the reader going away.
- **Switches**, one per rejected alternative: `END_BEFORE_CLEAR` (`FALSE`: the sleeper is stopped before the run is marked ended, and a message written in that gap joins the run that is about to end), `REARM_ON_CONTROL` (`FALSE`: a ceiling that passes under a control request ends the run), `TRACK_LEDGER` (`FALSE`: a result ends the run with a background agent still in flight).
- **Properties.** `NoCloseMidTurn`: stdin never closes during a turn or with a tracked task in flight, unless the reader is gone or the closing waiter's own run had ended. `MessageOwnsRun`: after a message is written, the current run is not ended until a later result, `idle` or the reader's exit — for single-message prompts; from a CLI that reports no state, a queued later message is a documented limitation, not a property. `EventuallyEnds`, under weak fairness and a ceiling above 0, given that timer-resetting work eventually stops (no endless `running` frames, tracked tasks or control requests after a result) and that an interval between turns eventually occurs.

## What the models assume and leave out

- File contents are abstracted to "the version they are the bytes of", so a checksum match is equality. Platform detection is not modelled.
Expand Down
Loading
Loading