claude-agent-sdk 1.2.0 → 1.2.1

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.
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async/condition'
4
+
5
+ module ClaudeAgentSDK
6
+ class Query
7
+ # When stdin may close: one run's end, decided from the CLI's frames and one
8
+ # timer (the ceiling). Reactor-only, like every caller: no mutex, and never
9
+ # reached from a FiberBoundary thread or from Query#close_now. An event may
10
+ # hand execution to another fiber mid-call, at RunEnd#end!'s signal, the
11
+ # sleeper's async, a handle's #stop and #wait. So the order is the contract:
12
+ # #end_run marks the run ended BEFORE it signals or stops the sleeper (a
13
+ # message written in that gap gets a run of its own); a ceiling that wakes
14
+ # checks its generation, detaches, then acts, and re-arms instead of ending
15
+ # while the SDK still answers a control request. The sleeper is a child of
16
+ # the read task and every exit path clears it (#reader_gone, #stdin_closing).
17
+ #
18
+ # @api private
19
+ class RunLifecycle
20
+ # Task types whose completion runs a follow-up turn, and which therefore
21
+ # may still need the control channel after the turn's result frame.
22
+ #
23
+ # Mirrors the set the CLI itself holds a result back for, which is
24
+ # narrower than its notion of "delegated agent work". The types left out
25
+ # are left out on purpose:
26
+ # - background shells and monitors run indefinitely by design, so
27
+ # deferring the close on one withholds it forever rather than briefly;
28
+ # - teammates are long-lived too — their status stays running for their
29
+ # whole lifetime, so they never settle the ledger;
30
+ # - remote agents can be long-running monitors the CLI likewise refuses
31
+ # to wait on.
32
+ # Anything added here must be a type that reliably reaches a terminal
33
+ # status, or it will hang the query (see #track_task_lifecycle).
34
+ DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
35
+
36
+ # Frame types that mark a main-thread turn under way (when they carry no
37
+ # parent_tool_use_id), and the states that do not re-arm the ceiling.
38
+ TURN_FRAME_TYPES = %w[assistant stream_event].freeze
39
+ NON_RUNNING_SESSION_STATES = %w[idle requires_action].freeze
40
+
41
+ # One run's end, set at most once (the Python SDK's per-run anyio.Event).
42
+ # A waiter holds the object it started waiting on, so a run that ends and
43
+ # is then reopened (#reopen_run swaps in a fresh RunEnd) still releases the
44
+ # waiters the ended run woke, while later waits wait for the new run.
45
+ # Reactor-only, like every caller.
46
+ class RunEnd
47
+ def initialize
48
+ @ended = false
49
+ @condition = Async::Condition.new
50
+ end
51
+
52
+ def ended?
53
+ @ended
54
+ end
55
+
56
+ def end!
57
+ return if @ended
58
+
59
+ @ended = true
60
+ @condition.signal
61
+ end
62
+
63
+ def wait
64
+ @condition.wait until @ended
65
+ end
66
+ end
67
+
68
+ # The CLI's latest session_state_changed state, or nil while it sends
69
+ # none (a CLI too old to honor CLAUDE_CODE_SDK_READS_SESSION_STATE). A
70
+ # CLI that reports state stays "running" while a background agent is
71
+ # live or its completion is still to be handled, and reports "idle" once
72
+ # no further turn is owed.
73
+ attr_reader :session_state
74
+
75
+ # +ceiling_ms+ bounds the wait between turns (0 = never armed; see #arm).
76
+ # +sleeper+ is called as `sleeper.call(seconds) { ... }`: it runs the block
77
+ # once +seconds+ have passed and returns a handle that responds to #stop
78
+ # (Query#sleep_on_read_task in production). The three predicates read
79
+ # Query's facts when they are called and must not suspend.
80
+ def initialize(ceiling_ms:, sleeper:, bidirectional_needs:, closed:, control_requests_in_flight:)
81
+ @ceiling_ms = ceiling_ms
82
+ @sleeper = sleeper
83
+ @bidirectional_needs = bidirectional_needs
84
+ @closed = closed
85
+ @control_requests_in_flight = control_requests_in_flight
86
+ # Ends when the run is over, so the stdin-closing waiter can wake (Python
87
+ # #1088, #1190/#1279). Work the CLI takes up after the run ended swaps in
88
+ # a fresh one (#reopen_run).
89
+ @run_end = RunEnd.new
90
+ @result_received = false
91
+ @session_state = nil
92
+ # Ends the run if no new turn starts within the ceiling after a result
93
+ # (#arm). The generation tells a sleeper that woke after it was cleared
94
+ # or re-armed to stand down; it only ever grows.
95
+ @ceiling_handle = nil
96
+ @ceiling_generation = 0
97
+ # A main-thread turn is under way (its assistant/stream_event frames
98
+ # have started and its result has not arrived): the ceiling counts only
99
+ # the wait between turns, so it is not armed meanwhile.
100
+ @turn_in_progress = false
101
+ # Set once stdin is closed or the reader is gone: the run then stays
102
+ # ended, since nothing can wait on a reopened one.
103
+ @final = false
104
+ # Task IDs of started-but-not-finished deferring tasks. A result frame
105
+ # only ends one turn, not the run: a background task keeps running past
106
+ # it and still needs stdin for hook/SDK-MCP control responses (Python
107
+ # #1088/#1103), so a result that arrives while this set is non-empty
108
+ # must not close stdin.
109
+ @inflight_tasks = Set.new
110
+ end
111
+
112
+ # One frame the read loop did not route elsewhere (anything but control
113
+ # and transcript_mirror frames), frames marked sdk_host_only included;
114
+ # for a result, after the mirror flush.
115
+ def frame(message)
116
+ type = message[:type]
117
+ if type == 'system'
118
+ on_system(message)
119
+ elsif type == 'result'
120
+ on_result
121
+ elsif TURN_FRAME_TYPES.include?(type) && message[:parent_tool_use_id].nil?
122
+ on_main_thread_turn
123
+ end
124
+ end
125
+
126
+ # A user message was serialized and is about to be written. It owes a run
127
+ # of its own, result included: an earlier one having ended does not end it.
128
+ def message_will_write
129
+ reopen_run
130
+ @result_received = false
131
+ clear
132
+ end
133
+
134
+ # Wait for the end of the run that is current now, when the CLI may still
135
+ # send control requests that need a reply; return at once otherwise.
136
+ def wait
137
+ @run_end.wait if @bidirectional_needs.call
138
+ end
139
+
140
+ # Stdin is about to close after a wait: no run can be reopened any more.
141
+ def stdin_closing
142
+ @final = true
143
+ clear
144
+ end
145
+
146
+ # Stdin is about to close with nothing written: final, but an armed
147
+ # sleeper is left for the read task's stop and the run is not ended
148
+ # (the #1226 trade-off; see Query#stream_input).
149
+ def empty_input
150
+ @final = true
151
+ end
152
+
153
+ # The read loop is over: wake the stdin-closing waiter so it does not
154
+ # stall on early exit; with the reader gone the run stays ended. Also
155
+ # stops a pending sleeper, which would otherwise keep the reactor alive.
156
+ def reader_gone
157
+ @final = true
158
+ end_run
159
+ end
160
+
161
+ def ended?
162
+ @run_end.ended?
163
+ end
164
+
165
+ def final?
166
+ @final
167
+ end
168
+
169
+ def ceiling_armed?
170
+ !@ceiling_handle.nil?
171
+ end
172
+
173
+ def inflight?(task_id)
174
+ @inflight_tasks.include?(task_id)
175
+ end
176
+
177
+ private
178
+
179
+ # Track task lifecycle frames so results can tell "one turn ended" apart
180
+ # from "the run is done" (Python #1088/#1103), then the session state.
181
+ def on_system(message)
182
+ had_tasks_in_flight = !@inflight_tasks.empty?
183
+ track_task_lifecycle(message)
184
+ # The ceiling left the last tracked agent alone; the wait between
185
+ # turns starts over now that it settled.
186
+ rearm_between_turns if had_tasks_in_flight && @inflight_tasks.empty?
187
+ on_session_state(message[:state]) if message[:subtype] == 'session_state_changed'
188
+ end
189
+
190
+ # A main-thread turn is under way, so the ceiling stops (it counts only
191
+ # the wait between turns, as the CLI's does) and the run reopens even if
192
+ # the ceiling ended it while no state changed.
193
+ def on_main_thread_turn
194
+ @turn_in_progress = true
195
+ reopen_run
196
+ clear
197
+ end
198
+
199
+ # Track in-flight tasks from `system` task lifecycle frames.
200
+ #
201
+ # `task_started` marks a task in flight; `task_notification` or a
202
+ # `task_updated` patch with a terminal status clears it. Terminal
203
+ # completion can arrive as either frame (not every terminal task emits a
204
+ # notification), so both are handled; Set deletion keeps the pair
205
+ # idempotent.
206
+ #
207
+ # This is a mitigation, not a complete answer to Python #1088. An empty
208
+ # set means "nothing we know of is running", which is not the same as
209
+ # "the run is over": a task that settles *before* the turn's result frame
210
+ # leaves the set empty at that result, so stdin closes even though the
211
+ # completion may still wake the parent for a continuation turn. What this
212
+ # does fix is the common ordering, where the task outlives the turn that
213
+ # spawned it.
214
+ #
215
+ # Only delegated agent work is tracked (DEFERRING_TASK_TYPES). A
216
+ # background *shell* is also reported through these frames, but it may
217
+ # never reach a terminal status, and the CLI in stream-json mode only
218
+ # exits on stdin EOF — tracking one would withhold the close forever.
219
+ #
220
+ # `background_tasks_changed` is deliberately not consumed, in either
221
+ # direction: its payload is the live *background* set, while a subagent
222
+ # is registered in the foreground and only flips to backgrounded later
223
+ # without a second task_started, so narrowing against the snapshot would
224
+ # drop an agent that goes on to outlive its turn, and widening from it
225
+ # could admit an id no later frame ever clears (observer agents suppress
226
+ # both their start and terminal frames).
227
+ def track_task_lifecycle(message)
228
+ task_id = message[:task_id]
229
+ return if task_id.nil? || task_id.to_s.empty?
230
+
231
+ case message[:subtype]
232
+ when 'task_started'
233
+ @inflight_tasks.add(task_id) if DEFERRING_TASK_TYPES.include?(message[:task_type])
234
+ when 'task_notification'
235
+ @inflight_tasks.delete(task_id)
236
+ when 'task_updated'
237
+ patch = message[:patch]
238
+ status = patch.is_a?(Hash) ? patch[:status] : nil
239
+ @inflight_tasks.delete(task_id) if TERMINAL_TASK_STATUSES.include?(status)
240
+ end
241
+ end
242
+
243
+ # A result ends a turn, not necessarily the run: a background agent that
244
+ # finished just before it still wakes the session for another turn, whose
245
+ # hook, permission and SDK MCP requests need stdin (Python #1190/#1279). A
246
+ # CLI that reports session state stays "running" while such a turn is
247
+ # owed, so wait for "idle" (some hosts send it just before the result).
248
+ # Without state events the result is all there is to go on.
249
+ def on_result
250
+ @result_received = true
251
+ @turn_in_progress = false
252
+ if @session_state.nil? || @session_state == 'idle' || !@bidirectional_needs.call
253
+ maybe_end_run
254
+ elsif @session_state != 'requires_action'
255
+ # While the SDK is still answering a request the ceiling waits for
256
+ # the "running" that follows.
257
+ arm
258
+ end
259
+ end
260
+
261
+ # Track the CLI's session_state_changed state (Python #1279).
262
+ def on_session_state(state)
263
+ @session_state = state
264
+ if state == 'idle'
265
+ maybe_end_run if @result_received
266
+ return
267
+ end
268
+ # Work the CLI took up after the run ended (a finished background task
269
+ # woke it) reopens the run until the next "idle".
270
+ reopen_run
271
+ if state == 'requires_action'
272
+ # The host is answering a request; stdin must outlast it.
273
+ clear
274
+ else
275
+ rearm_between_turns
276
+ end
277
+ end
278
+
279
+ # End the run unless a tracked background task is still in flight: such
280
+ # a task may still need hook/SDK-MCP control responses over stdin
281
+ # (Python #1088), and its completion wakes the parent for a follow-up
282
+ # turn whose result (or "idle") ends the run then. A CLI that reports
283
+ # session state never reports "idle" with an agent still live, so this
284
+ # matters for CLIs that report "idle" at every turn end or not at all.
285
+ def maybe_end_run
286
+ end_run if @inflight_tasks.empty?
287
+ end
288
+
289
+ # The run is over: wake the stdin-closing waiter. Idempotent.
290
+ #
291
+ # The run is marked ended BEFORE the ceiling is cleared: stopping the
292
+ # sleeper yields to whatever else is ready, and a Query#stream_input task
293
+ # that writes its next message in that gap must find the run already
294
+ # ended, so that #reopen_run gives the message a run of its own. Cleared
295
+ # first, the message joined the run that was about to end, and stdin
296
+ # closed before the message's own run had produced a frame.
297
+ def end_run
298
+ @run_end.end!
299
+ clear
300
+ end
301
+
302
+ # Reopen an ended run for work that started after it ended. A waiter the
303
+ # ended run already woke still closes stdin; this makes a later wait
304
+ # (Query#stream_input's, once its prompts are all written) wait for the
305
+ # new work too. Once stdin is closed, or the reader is gone, the run
306
+ # stays ended.
307
+ def reopen_run
308
+ @run_end = RunEnd.new if @run_end.ended? && !@final
309
+ end
310
+
311
+ # End the run anyway once the ceiling passes with no new turn. The CLI's
312
+ # own background-wait ceiling only counts once stdin is closed, so without
313
+ # this, work that never finishes would hold "running", and stdin, open
314
+ # forever. It counts only the wait between turns: restarted at each result
315
+ # and whenever the CLI reports "running" again, cleared by main-thread
316
+ # turn activity and by "requires_action", never armed mid-turn.
317
+ #
318
+ # Every exit path clears the sleeper (#end_run from #reader_gone,
319
+ # #stdin_closing), so a pending one can never keep the enclosing reactor
320
+ # alive; the production sleeper is a child of the read task besides
321
+ # (Query#sleep_on_read_task).
322
+ def arm
323
+ clear
324
+ # A frame read while close is under way (the read loop flushes the
325
+ # mirror before a result gets here) must not leave a sleeper behind
326
+ # either.
327
+ return if @ceiling_ms <= 0 || @run_end.ended? || @final || @closed.call ||
328
+ @turn_in_progress || !@bidirectional_needs.call
329
+
330
+ generation = @ceiling_generation
331
+ seconds = [@ceiling_ms, MAX_RUN_END_CEILING_MS].min / 1000.0
332
+ @ceiling_handle = @sleeper.call(seconds) { ceiling_passed(generation) }
333
+ end
334
+
335
+ # Restart the ceiling if the run is between turns, past a result, with
336
+ # the CLI still reporting work ("running").
337
+ def rearm_between_turns
338
+ return unless @result_received
339
+ return if @session_state.nil? || NON_RUNNING_SESSION_STATES.include?(@session_state)
340
+
341
+ arm
342
+ end
343
+
344
+ def ceiling_passed(generation)
345
+ # Cleared or re-armed while this sleeper was already waking up.
346
+ return unless generation == @ceiling_generation
347
+
348
+ # Detach before ending the run: #end_run clears the ceiling, and
349
+ # clearing it must not stop the task running this very method.
350
+ @ceiling_handle = nil
351
+ # A tracked background agent still running may still need stdin for its
352
+ # hook, permission and SDK MCP requests (Python #1088), so it is not cut
353
+ # off; the ceiling starts over once it settles (#on_system).
354
+ return unless @inflight_tasks.empty?
355
+
356
+ # A control request the SDK is still answering (a slow hook or SDK MCP
357
+ # tool) must be able to write its reply, so the clock starts over rather
358
+ # than closing stdin under it. Ruby-only guard: Python relies on the CLI
359
+ # reporting requires_action for every such request.
360
+ return arm if @control_requests_in_flight.call
361
+
362
+ end_run
363
+ end
364
+
365
+ def clear
366
+ @ceiling_generation += 1
367
+ handle = @ceiling_handle
368
+ @ceiling_handle = nil
369
+ handle&.stop
370
+ end
371
+ end
372
+ end
373
+ end