Skip to content

Extract the run-end decision into Query::RunLifecycle - #168

Merged
ya-luotao merged 6 commits into
mainfrom
refactor/run-end-state-machine
Oct 6, 2026
Merged

ya-luotao merged 6 commits into
mainfrom
refactor/run-end-state-machine

Conversation

@ya-luotao

@ya-luotao ya-luotao commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

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_input and the ceiling sleeper — and ten private methods (track_task_lifecycle … clear_run_end_ceiling). It could only be observed by driving a whole Query over a fake transport, so query_run_end_spec.rb reached past the interface with instance_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 private class, Query::RunLifecycle (lib/claude_agent_sdk/query/run_lifecycle.rb), which owns RunEnd, the ledger, the state table, the ceiling generation and the sleeper handle. Query talks to it through five events (frame, message_will_write, stdin_closing, empty_input, reader_gone), one wait, 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 private sleeper: kwarg lets specs inject a fake).

No behaviour change. close_now and everything from def close to the end of query.rb is byte-identical to main. lib/claude_agent_sdk.rb and sig/ are untouched. The deliberate divergences stay: the ceiling re-arms while a control request is in flight; stream_input serializes before message_will_write; Client arms 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 a FakeSleeper; no instance_variable_get / send(: targeting run-end state remains anywhere in spec/ (the §8 grep returns nothing).
  • The integration examples that cross the real seams stay and grew: the parked-hook B6 case, the real-scheduler end-before-clear ordering (also green on the async 2.10.0 floor gemfile), the gated mirror-flush ordering (B13), and two new started-Query cases for a result that resumes after a close has begun (from a reactor task and from a foreign thread).
  • Full suite at head: 3643 examples / 0 failures (baseline 3574); RuboCop clean, no new rubocop:disable; rake rbs:validate rbs:test green; floor gemfile run green.
  • 27 hand mutations of the lifecycle (clear-before-end, no generation check, no re-arm under control, empty-input clearing, …) are each caught by the suite.
  • The implementer's recorded keyless real-CLI probe (CLI 2.1.289; hooks configured, String and Enumerator prompts) logs identical frame / end_input sequences on main and on this branch: stdin closes after the CLI's idle, not at the result.
  • Design settled over two adversarial rounds with Codex (gpt-6-astra, max reasoning); implementation reviewed by Codex: APPROVE, with three non-blocking test/CI improvements applied in c512dd4. Codex additionally alternated main's and this branch's Query behind the unchanged query() 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

  • CLAUDE.md gains a "Run Lifecycle" section; formal/tla/README.md names RunLifecycle.tla as the next model (not written here; the sketch records per-run identity and the handoff split).
  • .github/workflows/integration.yml now also triggers on lib/claude_agent_sdk/query/**.
  • Interaction with Share one session assembly between query() and Client #169 (refactor/session-assembly): the Ruby changes do not overlap (that branch leaves query.rb untouched, this one leaves lib/claude_agent_sdk.rb untouched), but both edit CLAUDE.md and CHANGELOG.md, so two documentation conflicts need reconciling when the branches are combined.
  • Provenance: the commits were written by a Claude Opus 5.5 implementer session working from a settled spec under this orchestrating session's direction; the commit trailers name the orchestrating session.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm

ya-luotao and others added 5 commits October 6, 2026 03:16
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
@ya-luotao
ya-luotao merged commit cb7e92f into main Oct 6, 2026
15 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant