Repository navigation
Share one session assembly between query() and Client - #169
Merged
Merged
Conversation
query() and Client run one lifecycle whose implementation is written twice. Before any of it moves, pin what both do today, through the public API only, over an in-memory CLI and a recording observer: - the lifecycle differences L1-L8 (own reactor, String and Enumerable prompts, one run, ask, injected transports, the block-less Enumerator, what observers are told when the session cannot be opened); - which error wins when the cleanup after a failed connect fails too; - the session ids on the wire; - the two moments verbatim_prompts is read, including that a materialized store-backed resume reads the repointed copy of the options; - what is disposed of after a setup that fails part-way; - which transports get a materialized resume, and what happens to the temp dir on disconnect; - reconnecting, disconnecting from inside a callback, the double on_error of a query nested in a receive block; - a caller's deadline expiring while on_error runs, per scheduling mode. The deadline is delivered once the observer signals that it is running with the handshake rejection, the way an expired task.with_timeout delivers it (the scheduler raising into the waiting fiber), so no private method has to be wrapped to arm it and slow setup cannot use it up. None of these examples reads an instance variable or calls a private method, so they stay as they are while the implementation is extracted. No lib change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Every notification and every user-block invocation in query() and Client passed (observers, scheduling:, wrapper:) by hand: eleven notify_observers calls, two observing_prompt_stream calls and three invoke_iteration calls, with Client holding the triple as three instance variables and query() as three locals. A call site that forgot one of the two keywords would run a callback on the wrong execution context without failing anything. Dispatch holds the triple and offers the three calls. It is a context holder: each method is the module function it names, called with the triple, so notify_observers' containment and the Break bridging are untouched. Client builds an empty one in its constructor (scheduling and wrapper are captured there, as before) and swaps in the observers it resolves on each connect. Client#notify_error stays for now as a one-line delegate; a spec still wraps it, and both go together with the ownership change. No behaviour change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Client acquired its transport, built its Query, installed the transcript mirror, wrote prompts, ran the receive loop (twice) and tore everything down through six private helpers and four instance variables. query() does the same things with its own copy of each, and every lifecycle fix so far had to be made twice or was made once. SessionAssembly now owns the transport, the Query and the materialized resume dir of one session, and what is done with them: - #connect acquires in the documented order, recording each resource before the next step that can fail, with no rollback of its own; - #write_prompt / #write_message serialize and write user messages, stamped with the verbatim_prompts value #connect captured (after the transport connected, before the Query is built: Client's timing); - #stream_prompt_in_background spawns Query#stream_input; - #deliver is the receive loop, with until_result: for receive_response; - #close_resources(always_close_transport:) closes the Query, the transport and decides the temp dir's fate under nested ensures. It is shared implementation, not a lifecycle: it notifies nothing and rescues nothing. Client keeps every rescue, every notification and its connect-error precedence, and delegates inside them. Client#query_handler becomes a delegating method with the same nil-until-connect meaning. Everything moves in one commit so that there is no state in which disconnect cannot find a resource or a prompt has no writer: connect_inner, install_transcript_mirror, materialize_resume, notify_error, writeln and write are deleted here, together with the last caller of each. The specs that reached the old state move with it, through the public API: - client_teardown_under_deadline_spec delivers the deadline once on_error is running, instead of wrapping notify_error; - close_from_callback_spec takes the handler from Client#query_handler before disconnecting and asserts the outcome through the recording transport and the public API; - session_resume_spec, session_resume_nil_options_spec and client_spec drive materialization and its disposal through #connect / #disconnect with a recording transport, instead of calling materialize_resume or planting instance variables; - client_spec reads the options a Client runs on from the transport it constructs. Client is now under the class-length limit, so its Metrics/ClassLength exemption goes (RuboCop reports it as redundant). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
query() still carried its own copy of everything Client now gets from
SessionAssembly: resume materialization and repointing, transport
construction, the fifteen-keyword Query.new, the transcript mirror
install, the receive loop and the preserve-or-clean-up decision. A new
Query keyword or a teardown fix had to be written in both places.
query() is now the one-shot adapter over the same object. What makes it
one-shot stays in it, inline and in the same order:
- prompt validation, option configuration and observer resolution before
the reactor task, and the Async(finished: false) task itself;
- on_user_prompt, then write_prompt(..., session_id: '', verbatim:
:current): the option is read after the notification, from the options
the session runs on (the repointed copy once a store-backed resume was
materialized), exactly where the old code read it;
- the background wait_for_result_and_end_input for a String prompt;
- rescue StandardError -> on_error, and an ensure that fires on_close for
connect-phase failures too, then close_resources(always_close_transport:
false): the transport is closed on its own only when no Query was built,
and a raising close still replaces the original error. Giving query()
Client's stronger close is a behaviour change for a follow-up.
An injected transport is passed as { instance: } and gets no
materialization; without one the session constructs SubprocessCLITransport
after materializing, as before.
The characterization spec passes unchanged. There is now one Query.new,
one receive loop, one mirror install and one temp-dir decision in lib/.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
…e by side The mapping from options to the Query, the order of acquisition and the disposal rules were each tested twice, once through query() and once through Client, by capturing Query.new keywords behind doubles: twenty-six examples in two files that had to be kept in step by hand, and that said nothing about whether the two entry points agree. session_assembly_spec.rb is now the one place where - every one of the fifteen Query keywords is derived from options (hook normalization, SDK MCP instances, exclude_dynamic_sections and snapshot with false told from nil, the run-end ceiling, the dispatch pair); - each acquisition step is failed in turn and what was acquired before it is shown to be disposed of; - the eligibility rule for a materialized resume is spelled out per transport source; - the three verbatim modes of write_prompt, the receive loop and both shapes of close_resources are pinned. It calls only SessionAssembly's own methods. entry_point_parity_spec.rb runs one scenario through query() and through Client.open + query + receive_messages, against the same in-memory CLI and store-backed resume, under both scheduling modes with a wrapper, and compares what the caller got, what the block was given, what the observer was told and what became of the temp dir: a completed run, several results, a stream that dies, a block that raises or breaks, a mirror that dropped batches, a deadline during on_message. The differences that are meant to exist (session_id, stdin close, on_close after a failed connect, which error wins on a raising close) are listed as such, and nowhere else. It also passes against the lib of the commit before the extraction. The duplicated keyword examples go from query_api_spec and client_spec; each keeps one example that runs a real Query and reads the initialize request and the prompt off the wire. query_api_spec's inline streaming transport is replaced by the shared FakeCLI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
CLAUDE.md's "Two API Entry Points" said the difference between query() and Client is lifecycle, not protocol, but nothing said where the part they share lives. One sentence now names SessionAssembly (and Dispatch) as the single implementation of acquisition, prompt writes, delivery and disposal, states that it notifies and rescues nothing, and points at the two specs that pin the differences and the sameness. The FiberBoundary section's pointer to the loop that keeps `break` on the dequeue fiber follows the loop to SessionAssembly#deliver. CHANGELOG: an Unreleased "Changed" entry marked internal. No public API, signature or behaviour changed; sig/ is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Client#disconnect used to reset its connected state after the transport close and before it dealt with the materialized resume dir. When the teardown moved into SessionAssembly#close_resources, the reset moved to after the whole of it. Removing the directory can take a while (it retries, sleeping, when the directory is busy) and the reactor runs other tasks meanwhile, so in that window the client still claimed to be connected while its handler and transport were already gone: - query, receive_messages, receive_response and the control methods got past the "Not connected" guard and raised NoMethodError on nil, where they raised CLIConnectionError before, and the first three notified on_user_prompt / on_error for a session that was closed; - a second disconnect notified on_close a second time. close_resources now takes a block and calls it once both closes are behind, before the directory is dealt with, also when a close raised. Client marks itself disconnected there: the old boundary. query() passes no block and is unchanged. The reproduction from review round 1 is pinned through the public API (client_teardown_interleavings_spec): a real Query and a really materialized resume, the directory's removal parked, the calls made from another task, under both scheduling modes with a wrapper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Client#disconnect dropped its SessionAssembly in an unconditional ensure. The assembly clears its materialized resume dir only after disposing of it, precisely so that a disposal that was cut short can be retried; but with the owner gone, nothing could reach it any more. When the removal of the directory was interrupted (the directory busy, and the caller's deadline expiring or its task being stopped during the wait before the retry), the next disconnect returned normally and left the directory, and the copy of the credentials and settings in it, behind. Before the extraction the client kept @materialized in that case and the second disconnect removed it. The session is now let go of only when close_resources returned. When it raised, the session stays: what was closed is not closed again (the handler and the transport are nil-ed inside), and what was left is disposed of by the next disconnect. The disconnected state is still reset inside the teardown, whatever it raises, and the error that propagates is the same. A session that a reconnect has replaced in the meantime is not cleared. Pinned through the public API with the reproduction from review round 1: a really materialized directory whose first removal fails with EBUSY, an interruption delivered during the retry sleep (an expired deadline, and a cancellation that is not a StandardError), then a second disconnect that removes the directory; both scheduling modes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Client resolves its observers on each connect, and its receive loop used to ask the client for them message by message. SessionAssembly was handed a Dispatch per connection instead (Client replaced its own on connect), so a receive that was still running when the client was disconnected and reconnected kept notifying the observers it had started with. With observer factories that is visible: the first connection's observer got an on_message after its on_close, and the new connection's observer never heard of the message. Client now keeps one Dispatch for its lifetime and puts the observers it resolves in on each connect; scheduling and wrapper stay as captured by the constructor. Each notification reads the observers there are at that moment, as an Array that is replaced and never changed in place, so a notification under way keeps iterating the one it began with. The session holds that same Dispatch. query() builds its own and never replaces its observers. Dispatch#with_observers, which had no other caller, is gone. Pinned through the public API with the reproduction from review round 1: a message block parked across disconnect and connect, observers from a factory, both scheduling modes with a wrapper. CLAUDE.md's sentence on SessionAssembly now says what stays with Client across a teardown, and names the spec. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
The helper rescued Exception under a Lint/RescueException exemption "in case a deadline is a bare Exception", but every scenario of the parity spec fails with a StandardError, the deadline's Async::TimeoutError included. Rescuing what the scenarios raise needs no exemption, and one of the five RuboCop directives the branch added to spec/ goes with it. The other four are in entry_point_lifecycle_spec.rb, which rescues Async::Stop and builds a cancellation that is not a StandardError; that file is the characterization that has to stay byte-identical, so they stay. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
This was referenced Oct 5, 2026
Client is disconnected from the moment the removal of its materialized resume dir starts, so it can be connected again while that removal is still going. The disconnect that finishes afterwards owns the old session only: Client#disconnect clears @session only if it is still the session it tore down. Without that check the late disconnect let go of the new session — the client then claimed to be connected with nothing behind it, and the new session's own resume dir, with its credentials copy, was out of reach of the disconnect that ended it. Nothing pinned that check: review round 2 found the interleaving with a probe, not in the committed examples. It is now a public-API example next to the other teardown interleavings, under both scheduling modes: park the old directory's removal, reconnect, let the old disconnect finish, use the new session, disconnect it; the messages, the observer's notifications and both transport closes are as expected and the new resume dir is removed. It fails when the check is taken out. Test only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
The Unreleased entry said "No public API, signature or behavior change". The reviews established one difference from main, and an absolute claim hides it: when a Client is reconnected while its previous disconnect is still removing the materialized directory of a store-backed resume, the old disconnect used to clear the new connection's hold on its own resume directory when it finished, and that directory was then left behind. The old disconnect now leaves the new session alone, so the new session's directory is removed by its own disconnect (client_teardown_interleavings_spec pins it). The entry now says what is preserved — public signatures and the documented differences between the two entry points — and names that one cleanup difference. It makes no claim about retrying an interrupted removal: a later disconnect finishing it is what main already did. Two wordings the PR-level review flagged as imprecise are tightened with it. The entry said the observers are "bound once"; scheduling and wrapper are captured by Client's constructor, the observers are resolved on each connect. CLAUDE.md said SessionAssembly "notifies nothing"; it never notifies on_error or on_close, and it does dispatch on_message. CLAUDE.md also no longer says the parity spec pins "everything else" as identical: it pins the scenarios it runs. Docs only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
…mbly # Conflicts: # CHANGELOG.md # lib/claude_agent_sdk.rb
…mbly # Conflicts: # CLAUDE.md
ya-luotao
added a commit
that referenced
this pull request
Oct 6, 2026
Internal release: OptionForms (#167), Query::RunLifecycle (#168) and SessionAssembly (#169) each move duplicated or scattered logic into one private module; no public API or signature change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
query()andClientare two lifecycles over one protocol, but the session lifecycle was implemented twice inlib/claude_agent_sdk.rb: twoQuery.newsites fed by the same six extractors, three copies of the receive loop (parse → observers → user block →Break), two copies of the store-backed-resume wiring and of the preserve-or-cleanup decision, and the(observers, scheduling:, wrapper:)triple threaded by hand through every notification call. The duplication itself is the motivation: a lifecycle fix has to be written twice (4d650d2 changed Client's connect/disconnect teardown only; 7d8cb81 changed the shared notification helper; AUDIT-2026-06-12 H4 was an omission shared by both copies).This PR introduces two
@api privateclasses inlib/claude_agent_sdk/session_assembly.rb:SessionAssembly— shared implementation and resource ownership for both entry points: a resource-free constructor;#connectacquires in the existing order (materialize → repoint → transport →Query.new→ mirror → start → handshake), recording each resource before the next fallible step and never rolling back on its own;#write_prompt/#deliver/#close_resources(always_close_transport:)with an explicit switch that preserves today's two disposal shapes. The adapters keep their own rescue/notification boundaries and exception precedence.Dispatch— binds the observer triple once; Client keeps one stable instance whose observer set is replaced on connect, so a receive already running keeps notifying the current connection's observers as onmain.query()becomes the one-shot adapter over the same object.Client#connect_inner,#install_transcript_mirror,#materialize_resume,#notify_error,#writeln,#writeare gone.lib/claude_agent_sdk/query.rb,sig/anddocs/are untouched; the root file shrinks from 1567 to about 1340 lines.No change to the public lifecycle. Every documented difference between the entry points is preserved and now named in a spec (L1–L8):
query()'s own reactor, String prompt then stdin closed at run end,session_id: ''vs'default', no follow-ups,ask, injected-transport close, Enumerator return, and both failure-path observer sequences (query()→on_errorthenon_close;Client#connect→on_erroralone).verbatim_promptskeeps both of its read timings and reads the effective (materialized) options. Retrying an interrupted materialized-directory removal behaves as onmain. One disclosed cleanup difference: if a reconnect happens while the old disconnect is still removing its resume directory, finishing the old disconnect no longer loses ownership of the new session's resume directory, so the new session's disconnect removes it (mainleft it behind).How it was verified
e5b2e73) characterizes the lifecycle through the public API only (65 examples,spec/unit/entry_point_lifecycle_spec.rb+ harness): both failure-path observer sequences, exception precedence on both paths, wire session ids, partial-setup cleanup, reconnect, disconnect from a callback, the nested double-on_errorcase, and theverbatim_promptscapture timings including the store-backed-resume variant. Byte-identical and green at every later commit.spec/unit/entry_point_parity_spec.rb(28 examples):query()vsClient#receive_messagesover the same fake streaming transport in both scheduling modes with a wrapper; identical observer sequences and teardown decisions except the explicit difference matrix. It also passes againstmain's implementation.spec/unit/session_assembly_spec.rbat the new interface;spec/unit/client_teardown_interleavings_spec.rb(10 examples) pins the four interleavings the code review reproduced or probed (racing calls during a parked removal, interrupted cleanup then a second disconnect, parked receive across reconnect, reconnect during a parked removal).rake rbs:validate rbs:testgreen; floor gemfile (async 2.10.0) and Rails 8 suites green; keyless real-CLI smoke green..fleet/arch/): four adversarial design rounds with Codex before implementation; two Codex code-review rounds (three blocking findings on round 1, each reproduced againstmainand fixed in84d8f82,34d409f,616450a; round 2 APPROVE with 96 public traces identical tomain).Notes for review
refactor/options-hash-normalization): that branch edits the bodies of the three root extractors; this branch deletes their call sites in favour ofSessionAssembly#connect. Whichever lands second rebases. Interaction with Extract the run-end decision into Query::RunLifecycle #168 (refactor/run-end-state-machine): Ruby changes do not overlap, but both edit CLAUDE.md and CHANGELOG.md.query()'s close path is weaker thanClient's (no independent transport-close ensure); adopting Client's shape would be a behaviour fix.f75684b,1e84553) saySessionAssembly"notifies nothing"; precisely, it dispatcheson_messageinside#deliverwhile theon_error/on_closepolicy stays in the adapters (CLAUDE.md states it correctly). Commit messages are left as history.🤖 Generated with Claude Code
https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm