Repository navigation
Extract the run-end decision into Query::RunLifecycle - #168
Merged
Merged
Conversation
When stdin may close was nine instance variables of Query, written from four unrelated places (the read loop and its ensure, stream_input, wait_for_result_and_end_input, the ceiling sleeper) and observable only by driving a whole Query over a fake transport. The three bugs fixed there (#1279's idle-after-result, ending the run before stopping the sleeper, re-arming under a control request in flight) were all orderings between those places. Query::RunLifecycle now owns the state and the orderings: RunEnd, the task ledger, the session state, the ceiling and its generation. Query hands it frames and five events (frame, message_will_write, stdin_closing, empty_input, reader_gone), waits on it, and supplies the timer (sleep_on_read_task, still a child of the read task) plus three live predicates. close_now is untouched and never calls into it: it can run off the reactor, so the lifecycle reads @closed through a predicate instead. No behaviour change. The code moved as it was; the read loop still flushes the mirror before a result reaches the lifecycle and still drops only session_state_changed frames marked sdk_host_only. Specs stop reaching into Query: an optional private `sleeper:` kwarg takes a FakeSleeper whose arms the examples wake by hand, and run_lifecycle.ceiling_armed? / inflight? replace instance_variable_get and send(:end_run_at_ceiling) / send(:track_task_lifecycle). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
The decision was pinned by examples that each drove a whole Query over a transport double and waited for the read loop, so every state and frame order cost a reactor, and the orderings between fibers were only reachable by reaching into the Query. spec/unit/query/run_lifecycle_spec.rb tables it through the lifecycle's own interface with a FakeSleeper: 82 examples, no Query, no transport, no clock (only the #wait rows run a reactor). Groups are named after the behaviours to keep — B1-B7, B9-B11, B14 and Q5; the legend is at the top of the file. Replaced, not layered: - ten "run-end ceiling" examples of query_run_end_spec.rb moved to the table (the arm/not-arm matrix, what stops and restarts the clock, the tracked agent, ceilings of 0 and 10**12); - the task-ledger block of query_spec.rb moved to the table's B2/B3 rows. Kept through Query, because only a Query shows the wiring: the real 300 ms sleeper, the parked hook under a passing ceiling (B6), the stream reopened for a main-thread turn, no ceiling once stdin is closed, no sleeper left behind at EOF (B11), the twelve "stdin stays open until idle" examples (B1-B3, B7, B9, B12) and query_end_run_order_spec.rb (B10). Three pieces of wiring between Query and its lifecycle had no example and get one each: - the mirror is flushed before a result ends the run or reaches the stream (B13): the flush parks on a gate, and neither happens until it is released; - a message is serialized before the run is reopened for it (B7): one that verbatim_prompts cannot mark leaves the ended run ended, so stdin still closes; - the lifecycle reads the Query closed state when it would arm (B16): a closed Query arms nothing for a frame it was already holding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
CLAUDE.md described the run-end rules inline under query(), as properties of Query: the per-run RunEnd, reopen_run, the ceiling sleeper. They are one module now, so the entry-point paragraph points at it and a section of its own says what the module owns, what Query feeds it, which orderings live inside it and how its specs reach it. formal/tla/README.md gains the sketch of the model that is still missing: the run-end orderings are the most-churned invariant in query.rb and the only one no model checks. With the decision behind one interface, one .tla can cover it; the sketch records per-run identity, the handoff split, the switches and the properties so the model starts from the settled design rather than from the code. CHANGELOG: an Internal entry under Unreleased. Nothing public moved. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Three follow-ups from the code review of the RunLifecycle extraction; no
library code changes.
FakeSleeper looked only at its latest arm, so a lifecycle that replaced a
pending sleeper without stopping it and then stopped the replacement read
as "nothing armed" while the first one still slept. Every arm now counts:
#pending_arms lists them, #fire refuses unless exactly one is pending, and
the table's invariant after each example is "one pending arm if the
lifecycle holds a sleeper, none otherwise".
The example for the lifecycle's `closed:` predicate fed frames to the
lifecycle of a Query that was never started. It proved the wiring, but not
the case the guard exists for: a result the read loop already holds when
the Query is closed. That case is reachable through the transport and
mirror seams and is now what the example stages, for a close from a task
of the reactor and from another thread: the read loop parks in the flush
for the result; the batcher's close (which Query#close calls after marking
the Query closed, before stopping the reader) releases the flush and parks
in turn; the reader finishes the result with the run neither ended nor
final, and no sleeper is armed. With `closed: -> { false }` both fail, on
async 2.10 as well.
The keyless real-CLI smoke ran on pull requests that touch
lib/claude_agent_sdk/query.rb. The run-end logic it exercises now lives in
lib/claude_agent_sdk/query/, so that directory joins the path filter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
…ight formal/tla/README.md said the lifecycle's table was the only check until the model is written. It is not: the same branch keeps the specs that drive a real Query through seams the table cannot reach — the end-before-clear ordering on the real scheduler (run on the async 2.10 floor too), 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. The sentence now lists them and says what is actually missing: a model, and any exhaustive check of the interleavings. CHANGELOG said Query hands the lifecycle "frames and five events"; frame is one of the five. It is five events, including frames, and one wait. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
…e-machine # Conflicts: # CHANGELOG.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
"When may stdin close?" had no module. It was nine instance variables of
Query(49 references) mutated from four unrelated places — the read loop,stream_input,wait_for_result_and_end_inputand the ceiling sleeper — and ten private methods (track_task_lifecycle…clear_run_end_ceiling). It could only be observed by driving a wholeQueryover a fake transport, soquery_run_end_spec.rbreached past the interface withinstance_variable_get/send(:end_run_at_ceiling). Every run-end bug of the last months landed in that cluster (#1279 idle-after-result, #1088 ledger, the end-before-clear ordering in 9eaa2ef, re-arm under an in-flight control request).This PR moves the decision into one
@api privateclass,Query::RunLifecycle(lib/claude_agent_sdk/query/run_lifecycle.rb), which ownsRunEnd, the ledger, the state table, the ceiling generation and the sleeper handle.Querytalks to it through five events (frame,message_will_write,stdin_closing,empty_input,reader_gone), onewait, and five readers; it hands in three non-suspending predicates (closed:,bidirectional_needs:,control_requests_in_flight:) and a sleeper adapter (sleep_on_read_task, a child of the read task as before; an optional privatesleeper:kwarg lets specs inject a fake).No behaviour change.
close_nowand everything fromdef closeto the end ofquery.rbis byte-identical tomain.lib/claude_agent_sdk.rbandsig/are untouched. The deliberate divergences stay: the ceiling re-arms while a control request is in flight;stream_inputserializes beforemessage_will_write;Clientarms ceilings nothing waits on; empty input is final-only (#1226).How it was verified
spec/unit/query/run_lifecycle_spec.rb(82 examples) tables every behaviour by name (B1–B7, B9–B11, B14, Q5) through the interface with aFakeSleeper; noinstance_variable_get/send(:targeting run-end state remains anywhere inspec/(the §8 grep returns nothing).rubocop:disable;rake rbs:validate rbs:testgreen; floor gemfile run green.end_inputsequences onmainand on this branch: stdin closes after the CLI'sidle, not at the result.c512dd4. Codex additionally alternatedmain's and this branch'sQuerybehind the unchangedquery()entry point: 56 frame sequences × thread/inline scheduling × bidirectional needs present/absent = 224 comparisons, repeated on async 2.36 and 2.10, all identical.Notes for review
formal/tla/README.mdnamesRunLifecycle.tlaas the next model (not written here; the sketch records per-run identity and the handoff split)..github/workflows/integration.ymlnow also triggers onlib/claude_agent_sdk/query/**.refactor/session-assembly): the Ruby changes do not overlap (that branch leavesquery.rbuntouched, this one leaveslib/claude_agent_sdk.rbuntouched), but both edit CLAUDE.md and CHANGELOG.md, so two documentation conflicts need reconciling when the branches are combined.🤖 Generated with Claude Code
https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm