pi-agent-rb 0.2.2 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +50 -0
- data/lib/pi_agent/client.rb +138 -17
- data/lib/pi_agent/errors.rb +15 -0
- data/lib/pi_agent/session.rb +8 -0
- data/lib/pi_agent/transport/subprocess.rb +52 -8
- data/lib/pi_agent/transport.rb +19 -1
- data/lib/pi_agent/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec081bcb7d1b2bac67301f37239e002e9b32d0eeb065d80a36b4d217896e1485
|
|
4
|
+
data.tar.gz: f19a72e0adb62fb42a49e3a21f2bcdffe3d46b3b546752c66e36427469c04c6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1493e49939b44a45ff13fa7652b855ff48e25c611ad59bb6ac00c725bec549e76297156a455b20668bc5641a7fb0d8bcadd3eb535227e4e988c06c004a34dd92
|
|
7
|
+
data.tar.gz: 9cbd030aa9ae76199aa5730aab067bfd447a15416e9845d17fa3632f8baffd341586d0ca11b423eb4b66da6baa837c92511ec2ad7a6703a85c40e8d5996396d4
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Transport death notification. When pi dies unexpectedly (OOM kill,
|
|
14
|
+
missing binary after spawn, sandbox teardown), the client now learns
|
|
15
|
+
immediately instead of waiting out the 30s ack / 300s event timeouts
|
|
16
|
+
with a generic `TimeoutError`:
|
|
17
|
+
- Transports may accept an `on_close:` callable alongside
|
|
18
|
+
`on_message:`/`on_stderr:` and invoke it exactly once, with a short
|
|
19
|
+
human-readable reason, when they reach a terminal state on their
|
|
20
|
+
own — never for a caller-initiated `#close`. `Transport::Subprocess`
|
|
21
|
+
implements this (reporting exit status or signal, and watching the
|
|
22
|
+
child process itself so a descendant holding the stdout pipe open
|
|
23
|
+
can't defer the notification). The contract is documented in
|
|
24
|
+
`Transport` and the README's "Custom transports" section.
|
|
25
|
+
- On notification, `Client` rejects in-flight request futures with the
|
|
26
|
+
new `PiAgent::TransportClosedError` (a `ProtocolError` subclass with
|
|
27
|
+
the reason on `#reason`) and wakes subscribers with a synthetic
|
|
28
|
+
`Client::TRANSPORT_CLOSED_TYPE` message, which `Session` event
|
|
29
|
+
streams re-raise as `TransportClosedError`. Later `request`/`notify`
|
|
30
|
+
calls fail fast with the same error, and subscribers registered
|
|
31
|
+
after the death get the notification replayed once.
|
|
32
|
+
- Backward compatible: `Client#start` passes `on_close:` only when the
|
|
33
|
+
factory accepts the keyword (or `**kwargs`); existing
|
|
34
|
+
`(on_message:, on_stderr:)` factories keep their previous
|
|
35
|
+
timeout-backstop behavior unchanged.
|
|
36
|
+
- Subscriber callbacks are isolated during fanout: one raising
|
|
37
|
+
subscriber no longer prevents the rest from receiving a message.
|
|
38
|
+
|
|
10
39
|
## [0.2.2] - 2026-07-30
|
|
11
40
|
|
|
12
41
|
### Added
|
data/README.md
CHANGED
|
@@ -220,10 +220,60 @@ end
|
|
|
220
220
|
a pi extension vetoes the operation — that is an expected outcome, not
|
|
221
221
|
an error.
|
|
222
222
|
|
|
223
|
+
## Custom transports
|
|
224
|
+
|
|
225
|
+
By default the client spawns `pi --mode rpc` as a local subprocess. Pass
|
|
226
|
+
`transport_factory:` to run pi somewhere else — a container, a remote
|
|
227
|
+
sandbox:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
factory = lambda do |on_message:, on_stderr:, on_close:|
|
|
231
|
+
MySandboxTransport.new(
|
|
232
|
+
sandbox: sandbox,
|
|
233
|
+
on_message: on_message, on_stderr: on_stderr, on_close: on_close
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
PiAgent.session(transport_factory: factory) do |session|
|
|
238
|
+
# pi runs inside the sandbox; the protocol flows through your transport
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The factory receives the client's handlers and returns an object
|
|
243
|
+
implementing `#start`, `#write(Hash)`, `#close(timeout:)`, and `#alive?`.
|
|
244
|
+
[`Transport`](lib/pi_agent/transport.rb) documents the full contract;
|
|
245
|
+
[`Transport::Subprocess`](lib/pi_agent/transport/subprocess.rb) is the
|
|
246
|
+
reference implementation.
|
|
247
|
+
|
|
248
|
+
`on_close:` is the transport's death notification. Invoke it exactly
|
|
249
|
+
once, with a short human-readable reason (e.g.
|
|
250
|
+
`"process terminated by signal 9"`), when the transport reaches a
|
|
251
|
+
terminal state *on its own* — process exit, read-stream EOF, fatal stream
|
|
252
|
+
error — and only after delivering any stdout messages already read. Do
|
|
253
|
+
not invoke it for a shutdown initiated through `#close`. When it fires,
|
|
254
|
+
the client fails in-flight requests and live event streams promptly with
|
|
255
|
+
`PiAgent::TransportClosedError` (reason on `#reason`) instead of letting
|
|
256
|
+
them wait out the 30s ack / 300s event timeouts, and later
|
|
257
|
+
`request`/`notify` calls fail fast with the same error. Subscribers that
|
|
258
|
+
register after the death get the notification replayed once, so an event
|
|
259
|
+
stream started late still ends promptly.
|
|
260
|
+
|
|
261
|
+
`on_close:` is optional and backward compatible: the client inspects the
|
|
262
|
+
factory's parameters and passes the keyword only when the factory accepts
|
|
263
|
+
it (explicitly or via `**kwargs`). An existing
|
|
264
|
+
`(on_message:, on_stderr:)` factory keeps working unchanged — the
|
|
265
|
+
timeouts then remain the only backstop when pi dies.
|
|
266
|
+
|
|
223
267
|
## Errors
|
|
224
268
|
|
|
225
269
|
- A failed RPC command (`success: false`) raises `PiAgent::CommandError`,
|
|
226
270
|
which carries the failing `#command` name.
|
|
271
|
+
- If the transport dies out from under the client (pi OOM-killed, sandbox
|
|
272
|
+
torn down), in-flight requests and event streams raise
|
|
273
|
+
`PiAgent::TransportClosedError` promptly, with the death reason on
|
|
274
|
+
`#reason`. A caller-initiated `close` never raises it. This requires
|
|
275
|
+
the transport to report death — the bundled subprocess transport does;
|
|
276
|
+
for custom transports see [Custom transports](#custom-transports).
|
|
227
277
|
- Agent-side errors arrive *in* the event stream, not as exceptions —
|
|
228
278
|
inspect them with `Event#error?`, `#error_message`, and `#error_reason`
|
|
229
279
|
(`"aborted"` vs `"error"`). This covers `extension_error` events and
|
data/lib/pi_agent/client.rb
CHANGED
|
@@ -14,7 +14,10 @@ module PiAgent
|
|
|
14
14
|
# By default the client spawns `pi --mode rpc` as a local subprocess.
|
|
15
15
|
# Pass `transport_factory:` — a callable `(on_message:, on_stderr:) ->
|
|
16
16
|
# transport` — to run pi somewhere else (e.g. inside a remote sandbox).
|
|
17
|
-
#
|
|
17
|
+
# A factory may additionally accept `on_close:` to report transport
|
|
18
|
+
# death; the keyword is passed only when the factory accepts it, so
|
|
19
|
+
# older two-keyword factories keep working. See Transport for the
|
|
20
|
+
# transport contract.
|
|
18
21
|
#
|
|
19
22
|
# Since pi 0.79.0 project-local inputs (.pi/settings.json, project
|
|
20
23
|
# extensions, resources, packages) are trust-gated, and in RPC mode pi
|
|
@@ -25,6 +28,12 @@ module PiAgent
|
|
|
25
28
|
DEFAULT_BIN = "pi"
|
|
26
29
|
DEFAULT_ARGS = ["--mode", "rpc"].freeze
|
|
27
30
|
|
|
31
|
+
# Synthetic message fanned out to subscribers when the transport dies,
|
|
32
|
+
# so event streams wake promptly instead of waiting out their timeouts.
|
|
33
|
+
# Never sent by pi (the "pi_agent/" prefix keeps it out of upstream's
|
|
34
|
+
# event namespace); carries the death reason under "reason".
|
|
35
|
+
TRANSPORT_CLOSED_TYPE = "pi_agent/transport_closed"
|
|
36
|
+
|
|
28
37
|
attr_reader :bin
|
|
29
38
|
|
|
30
39
|
def self.resolve_bin(override = nil)
|
|
@@ -64,13 +73,15 @@ module PiAgent
|
|
|
64
73
|
@subscribers_mutex = Mutex.new
|
|
65
74
|
@transport = nil
|
|
66
75
|
@extension_ui = nil
|
|
76
|
+
# Close/death bookkeeping, guarded by @pending_mutex except
|
|
77
|
+
# @close_broadcast (@subscribers_mutex).
|
|
78
|
+
@caller_closed = false
|
|
79
|
+
@close_reason = nil
|
|
80
|
+
@close_broadcast = nil
|
|
67
81
|
end
|
|
68
82
|
|
|
69
83
|
def start
|
|
70
|
-
@transport = @transport_factory.call(
|
|
71
|
-
on_message: method(:handle_message),
|
|
72
|
-
on_stderr: method(:handle_stderr)
|
|
73
|
-
)
|
|
84
|
+
@transport = @transport_factory.call(**transport_callbacks)
|
|
74
85
|
@extension_ui = ExtensionUI.new(
|
|
75
86
|
writer: @transport,
|
|
76
87
|
handler: @extension_ui_handler,
|
|
@@ -83,21 +94,44 @@ module PiAgent
|
|
|
83
94
|
def request(type, params = {})
|
|
84
95
|
id = next_id
|
|
85
96
|
future = Future.new
|
|
86
|
-
@pending_mutex.synchronize
|
|
97
|
+
@pending_mutex.synchronize do
|
|
98
|
+
raise TransportClosedError, @close_reason if @close_reason
|
|
99
|
+
|
|
100
|
+
@pending[id] = future
|
|
101
|
+
end
|
|
87
102
|
payload = { id: id, type: type }.merge(params)
|
|
88
|
-
|
|
103
|
+
begin
|
|
104
|
+
@transport.write(payload)
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
raise abandon_request(id, e)
|
|
107
|
+
end
|
|
89
108
|
future
|
|
90
109
|
end
|
|
91
110
|
|
|
92
111
|
def notify(type, params = {})
|
|
112
|
+
@pending_mutex.synchronize do
|
|
113
|
+
raise TransportClosedError, @close_reason if @close_reason
|
|
114
|
+
end
|
|
93
115
|
payload = { type: type }.merge(params)
|
|
94
|
-
|
|
116
|
+
begin
|
|
117
|
+
@transport.write(payload)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
raise close_error_or(e)
|
|
120
|
+
end
|
|
95
121
|
end
|
|
96
122
|
|
|
123
|
+
# Subscribers registered after a transport death still learn about it:
|
|
124
|
+
# the synthetic TRANSPORT_CLOSED_TYPE message is replayed to them once,
|
|
125
|
+
# outside any lock. (The death fanout snapshots subscribers atomically
|
|
126
|
+
# with recording the broadcast, so nobody sees it twice.)
|
|
97
127
|
def subscribe(&block)
|
|
98
128
|
raise ArgumentError, "subscribe requires a block" unless block
|
|
99
129
|
|
|
100
|
-
@subscribers_mutex.synchronize
|
|
130
|
+
replay = @subscribers_mutex.synchronize do
|
|
131
|
+
@subscribers << block
|
|
132
|
+
@close_broadcast
|
|
133
|
+
end
|
|
134
|
+
deliver([block], replay) if replay
|
|
101
135
|
block
|
|
102
136
|
end
|
|
103
137
|
|
|
@@ -106,6 +140,10 @@ module PiAgent
|
|
|
106
140
|
end
|
|
107
141
|
|
|
108
142
|
def close
|
|
143
|
+
# A caller-initiated close is a clean shutdown: mark it first so the
|
|
144
|
+
# transport teardown's own death notification is ignored and never
|
|
145
|
+
# surfaces as a TransportClosedError.
|
|
146
|
+
@pending_mutex.synchronize { @caller_closed = true }
|
|
109
147
|
# Drain extension UI handler threads while the transport is still
|
|
110
148
|
# open so their responses can still be written.
|
|
111
149
|
@extension_ui&.shutdown
|
|
@@ -125,14 +163,42 @@ module PiAgent
|
|
|
125
163
|
def build_subprocess_factory(bin, args, env, cwd)
|
|
126
164
|
@bin = self.class.resolve_bin(bin)
|
|
127
165
|
command = [@bin, *Array(args)]
|
|
128
|
-
lambda do |on_message:, on_stderr:|
|
|
166
|
+
lambda do |on_message:, on_stderr:, on_close:|
|
|
129
167
|
Transport::Subprocess.new(
|
|
130
168
|
command: command, env: env, cwd: cwd,
|
|
131
|
-
on_message: on_message, on_stderr: on_stderr
|
|
169
|
+
on_message: on_message, on_stderr: on_stderr, on_close: on_close
|
|
132
170
|
)
|
|
133
171
|
end
|
|
134
172
|
end
|
|
135
173
|
|
|
174
|
+
def transport_callbacks
|
|
175
|
+
callbacks = {
|
|
176
|
+
on_message: method(:handle_message),
|
|
177
|
+
on_stderr: method(:handle_stderr)
|
|
178
|
+
}
|
|
179
|
+
callbacks[:on_close] = method(:handle_transport_close) if factory_accepts_on_close?
|
|
180
|
+
callbacks
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# External factories predate on_close; pass it only to callables that
|
|
184
|
+
# declare the keyword (or **kwargs), so factories with the older
|
|
185
|
+
# `(on_message:, on_stderr:)` shape keep working unchanged.
|
|
186
|
+
def factory_accepts_on_close?
|
|
187
|
+
factory_parameters(@transport_factory).any? do |kind, name|
|
|
188
|
+
kind == :keyrest || (%i[keyreq key].include?(kind) && name == :on_close)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Proc#parameters / Method#parameters describe the callable itself.
|
|
193
|
+
# Any other callable object may define an unrelated #parameters method,
|
|
194
|
+
# so inspect its #call method instead of trusting that name.
|
|
195
|
+
def factory_parameters(factory)
|
|
196
|
+
case factory
|
|
197
|
+
when Proc, Method then factory.parameters
|
|
198
|
+
else factory.method(:call).parameters
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
136
202
|
def next_id
|
|
137
203
|
@pending_mutex.synchronize do
|
|
138
204
|
@next_id += 1
|
|
@@ -163,21 +229,76 @@ module PiAgent
|
|
|
163
229
|
end
|
|
164
230
|
|
|
165
231
|
def notify_subscribers(msg)
|
|
166
|
-
|
|
167
|
-
|
|
232
|
+
deliver(@subscribers_mutex.synchronize { @subscribers.dup }, msg)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Fan a message out, isolating each subscriber: one raising callback
|
|
236
|
+
# must not starve the rest (in particular, a raising logger must not
|
|
237
|
+
# keep a Session stream from seeing the death notification).
|
|
238
|
+
def deliver(callbacks, msg)
|
|
239
|
+
callbacks.each do |cb|
|
|
240
|
+
cb.call(msg)
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
handle_stderr("[pi-agent-rb] subscriber raised #{e.class}: #{e.message}")
|
|
243
|
+
end
|
|
168
244
|
end
|
|
169
245
|
|
|
170
246
|
def handle_stderr(line)
|
|
171
247
|
# no-op by default; future versions may wire a logger
|
|
172
248
|
end
|
|
173
249
|
|
|
174
|
-
|
|
250
|
+
# Transport-side death (process exit, stream EOF, fatal stream error).
|
|
251
|
+
# Idempotent, and a no-op after a caller-initiated #close — a clean
|
|
252
|
+
# shutdown is not an error. Fails all pending futures and wakes
|
|
253
|
+
# subscribers with a TRANSPORT_CLOSED_TYPE message so event streams
|
|
254
|
+
# end promptly instead of waiting out their timeouts. The broadcast is
|
|
255
|
+
# recorded atomically with the subscriber snapshot, so subscribers
|
|
256
|
+
# registered later get it replayed exactly once (see #subscribe).
|
|
257
|
+
def handle_transport_close(reason)
|
|
175
258
|
pending = @pending_mutex.synchronize do
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
259
|
+
return if @caller_closed || @close_reason
|
|
260
|
+
|
|
261
|
+
@close_reason = reason
|
|
262
|
+
take_pending
|
|
263
|
+
end
|
|
264
|
+
message = { "type" => TRANSPORT_CLOSED_TYPE, "reason" => reason }
|
|
265
|
+
callbacks = @subscribers_mutex.synchronize do
|
|
266
|
+
@close_broadcast = message
|
|
267
|
+
@subscribers.dup
|
|
179
268
|
end
|
|
269
|
+
pending.each { |f| f.reject(TransportClosedError.new(reason)) }
|
|
270
|
+
deliver(callbacks, message)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def reject_pending(error)
|
|
274
|
+
pending = @pending_mutex.synchronize { take_pending }
|
|
180
275
|
pending.each { |f| f.reject(error) }
|
|
181
276
|
end
|
|
277
|
+
|
|
278
|
+
# Must be called holding @pending_mutex.
|
|
279
|
+
def take_pending
|
|
280
|
+
snapshot = @pending.values
|
|
281
|
+
@pending.clear
|
|
282
|
+
snapshot
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# A write failed after the future was registered. Unregister it and,
|
|
286
|
+
# when the transport is dead, surface the death rather than the raw
|
|
287
|
+
# pipe error. Returns the error to raise; the future (if still ours)
|
|
288
|
+
# is rejected with the same error so it never dangles.
|
|
289
|
+
def abandon_request(id, error)
|
|
290
|
+
future = @pending_mutex.synchronize { @pending.delete(id) }
|
|
291
|
+
final = close_error_or(error)
|
|
292
|
+
future&.reject(final)
|
|
293
|
+
final
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Surface the death rather than the raw pipe error when the transport
|
|
297
|
+
# has already been reported dead. When the write error beats the
|
|
298
|
+
# notification, the raw error stands — it fails just as promptly.
|
|
299
|
+
def close_error_or(error)
|
|
300
|
+
reason = @pending_mutex.synchronize { @close_reason }
|
|
301
|
+
reason ? TransportClosedError.new(reason) : error
|
|
302
|
+
end
|
|
182
303
|
end
|
|
183
304
|
end
|
data/lib/pi_agent/errors.rb
CHANGED
|
@@ -9,6 +9,21 @@ module PiAgent
|
|
|
9
9
|
class SessionError < Error; end
|
|
10
10
|
class TimeoutError < Error; end
|
|
11
11
|
|
|
12
|
+
# Raised when the transport dies out from under the client — child
|
|
13
|
+
# process exit, read-stream EOF, fatal stream error — while requests or
|
|
14
|
+
# event streams are outstanding, and on any `request`/`notify` attempted
|
|
15
|
+
# after the death. A caller-initiated `Client#close` never raises it.
|
|
16
|
+
# `#reason` carries the transport's short description of what happened
|
|
17
|
+
# (e.g. "process terminated by signal 9").
|
|
18
|
+
class TransportClosedError < ProtocolError
|
|
19
|
+
attr_reader :reason
|
|
20
|
+
|
|
21
|
+
def initialize(reason)
|
|
22
|
+
@reason = reason
|
|
23
|
+
super("Transport closed: #{reason}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
12
27
|
# Raised when an RPC command returns `success: false`. Carries the
|
|
13
28
|
# failing command name so callers can branch on it.
|
|
14
29
|
class CommandError < Error
|
data/lib/pi_agent/session.rb
CHANGED
|
@@ -19,6 +19,10 @@ module PiAgent
|
|
|
19
19
|
# submit it as a streaming-aware prompt and drain it race-free. `events` is
|
|
20
20
|
# a prompt-less drain for when you have already subscribed before processing
|
|
21
21
|
# starts.
|
|
22
|
+
#
|
|
23
|
+
# If the transport dies mid-stream (pi killed, sandbox torn down), event
|
|
24
|
+
# streams raise TransportClosedError promptly instead of waiting out the
|
|
25
|
+
# event timeout — provided the transport reports death (see Transport).
|
|
22
26
|
class Session
|
|
23
27
|
# Max time to wait for the next event before assuming the agent stalled.
|
|
24
28
|
DEFAULT_EVENT_TIMEOUT = 300
|
|
@@ -322,6 +326,10 @@ module PiAgent
|
|
|
322
326
|
loop do
|
|
323
327
|
msg = queue.pop(timeout: event_timeout)
|
|
324
328
|
raise TimeoutError, "No event received within #{event_timeout}s" if msg.nil?
|
|
329
|
+
# Client's synthetic death notification: the transport is gone, no
|
|
330
|
+
# further events can arrive — end the stream now rather than
|
|
331
|
+
# timing out.
|
|
332
|
+
raise TransportClosedError, msg["reason"] if msg["type"] == Client::TRANSPORT_CLOSED_TYPE
|
|
325
333
|
|
|
326
334
|
event = Event.new(msg)
|
|
327
335
|
yielder << event
|
|
@@ -15,20 +15,29 @@ module PiAgent
|
|
|
15
15
|
class Subprocess
|
|
16
16
|
DEFAULT_CHUNK_SIZE = 4096
|
|
17
17
|
DEFAULT_CLOSE_TIMEOUT = 5
|
|
18
|
+
# How long to let the stdout reader drain buffered output after the
|
|
19
|
+
# child exits before reporting the death (or closing) anyway — a
|
|
20
|
+
# descendant that inherited the pipe can hold it open indefinitely.
|
|
21
|
+
EXIT_DRAIN_TIMEOUT = 1
|
|
18
22
|
|
|
19
23
|
attr_reader :pid
|
|
20
24
|
|
|
21
25
|
# `cwd` sets the child's working directory — pi's built-in tools
|
|
22
26
|
# (bash/read/edit/...) operate relative to it. nil leaves the
|
|
23
27
|
# child in this process's working directory.
|
|
24
|
-
|
|
28
|
+
#
|
|
29
|
+
# `on_close`, when given, is invoked exactly once with a short
|
|
30
|
+
# human-readable reason when the child exits on its own (see
|
|
31
|
+
# watch_exit). A caller-initiated #close is not reported.
|
|
32
|
+
def initialize(command:, env: {}, cwd: nil, on_message: nil, on_stderr: nil, on_close: nil)
|
|
25
33
|
@command = Array(command)
|
|
26
34
|
@env = env.transform_keys(&:to_s)
|
|
27
35
|
@cwd = cwd
|
|
28
36
|
@on_message = on_message
|
|
29
37
|
@on_stderr = on_stderr
|
|
38
|
+
@on_close = on_close
|
|
30
39
|
@write_mutex = Mutex.new
|
|
31
|
-
@
|
|
40
|
+
@owner_closed = false
|
|
32
41
|
end
|
|
33
42
|
|
|
34
43
|
def start
|
|
@@ -39,13 +48,14 @@ module PiAgent
|
|
|
39
48
|
@stderr.binmode
|
|
40
49
|
@stdout_thread = Thread.new { read_loop(@stdout, :stdout) }
|
|
41
50
|
@stderr_thread = Thread.new { read_loop(@stderr, :stderr) }
|
|
51
|
+
@exit_watch_thread = Thread.new { watch_exit }
|
|
42
52
|
self
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
def write(obj)
|
|
46
56
|
payload = "#{JSON.generate(obj)}\n"
|
|
47
57
|
@write_mutex.synchronize do
|
|
48
|
-
raise ProtocolError, "Transport closed" if @
|
|
58
|
+
raise ProtocolError, "Transport closed" if @owner_closed
|
|
49
59
|
|
|
50
60
|
@stdin.write(payload)
|
|
51
61
|
@stdin.flush
|
|
@@ -55,13 +65,18 @@ module PiAgent
|
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
def close(timeout: DEFAULT_CLOSE_TIMEOUT)
|
|
58
|
-
return if
|
|
68
|
+
return if mark_owner_closed!
|
|
59
69
|
|
|
60
70
|
safe_close(@stdin)
|
|
61
71
|
wait_for_exit(timeout)
|
|
62
|
-
[@stdout_thread, @stderr_thread].compact
|
|
72
|
+
readers = [@stdout_thread, @stderr_thread].compact
|
|
73
|
+
# Bounded drain: a descendant that inherited a pipe can hold it
|
|
74
|
+
# open past the child's exit; force the readers out by closing
|
|
75
|
+
# the pipes after the window rather than hanging the close.
|
|
76
|
+
readers.each { |t| t.join(EXIT_DRAIN_TIMEOUT) }
|
|
63
77
|
safe_close(@stdout)
|
|
64
78
|
safe_close(@stderr)
|
|
79
|
+
(readers + [@exit_watch_thread]).compact.each(&:join)
|
|
65
80
|
end
|
|
66
81
|
|
|
67
82
|
def alive?
|
|
@@ -80,11 +95,13 @@ module PiAgent
|
|
|
80
95
|
args
|
|
81
96
|
end
|
|
82
97
|
|
|
83
|
-
|
|
98
|
+
# Marks that the owner requested shutdown via #close (as opposed to
|
|
99
|
+
# the child dying on its own). Returns whether it was already set.
|
|
100
|
+
def mark_owner_closed!
|
|
84
101
|
@write_mutex.synchronize do
|
|
85
|
-
return true if @
|
|
102
|
+
return true if @owner_closed
|
|
86
103
|
|
|
87
|
-
@
|
|
104
|
+
@owner_closed = true
|
|
88
105
|
false
|
|
89
106
|
end
|
|
90
107
|
end
|
|
@@ -117,6 +134,21 @@ module PiAgent
|
|
|
117
134
|
# Pipe closed; reader exits normally. (EOFError descends from IOError.)
|
|
118
135
|
end
|
|
119
136
|
|
|
137
|
+
# Sole death notifier. Watches the child process itself rather than
|
|
138
|
+
# the stdout pipe, whose EOF a descendant that inherited the write
|
|
139
|
+
# end can defer indefinitely. Gives the stdout reader a bounded
|
|
140
|
+
# window to drain buffered output first, so responses that raced the
|
|
141
|
+
# exit are dispatched before the notification. Owner-initiated
|
|
142
|
+
# #close marks @owner_closed before teardown, so the expected exit
|
|
143
|
+
# stays silent.
|
|
144
|
+
def watch_exit
|
|
145
|
+
status = @wait_thr.value
|
|
146
|
+
@stdout_thread&.join(EXIT_DRAIN_TIMEOUT)
|
|
147
|
+
return if @on_close.nil? || owner_closed?
|
|
148
|
+
|
|
149
|
+
@on_close.call(close_reason(status))
|
|
150
|
+
end
|
|
151
|
+
|
|
120
152
|
def dispatch_stdout(line)
|
|
121
153
|
msg = JSON.parse(line)
|
|
122
154
|
@on_message&.call(msg)
|
|
@@ -124,6 +156,18 @@ module PiAgent
|
|
|
124
156
|
@on_stderr&.call("[pi-agent-rb] invalid JSON on stdout: #{e.message}: #{line.inspect}")
|
|
125
157
|
end
|
|
126
158
|
|
|
159
|
+
def owner_closed?
|
|
160
|
+
@write_mutex.synchronize { @owner_closed }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def close_reason(status)
|
|
164
|
+
if status.signaled?
|
|
165
|
+
"process terminated by signal #{status.termsig}"
|
|
166
|
+
else
|
|
167
|
+
"process exited with status #{status.exitstatus}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
127
171
|
def terminate_process
|
|
128
172
|
Process.kill("TERM", @pid)
|
|
129
173
|
rescue Errno::ESRCH, Errno::EPERM
|
data/lib/pi_agent/transport.rb
CHANGED
|
@@ -6,10 +6,11 @@ module PiAgent
|
|
|
6
6
|
# subprocess (Transport::Subprocess), or — via a caller-supplied
|
|
7
7
|
# transport — a process inside a remote sandbox.
|
|
8
8
|
#
|
|
9
|
-
# Contract. A transport is constructed with
|
|
9
|
+
# Contract. A transport is constructed with callables:
|
|
10
10
|
#
|
|
11
11
|
# on_message: ->(Hash) # one parsed JSON message from pi's stdout
|
|
12
12
|
# on_stderr: ->(String) # one line from pi's stderr
|
|
13
|
+
# on_close: ->(String) # death notification (optional; see below)
|
|
13
14
|
#
|
|
14
15
|
# and responds to:
|
|
15
16
|
#
|
|
@@ -18,6 +19,23 @@ module PiAgent
|
|
|
18
19
|
# #close(timeout:) -> shut pi down
|
|
19
20
|
# #alive? -> Boolean
|
|
20
21
|
#
|
|
22
|
+
# Death notification (on_close). A transport MUST invoke on_close
|
|
23
|
+
# exactly once when it reaches a terminal state on its own — child
|
|
24
|
+
# process exit, read-stream EOF, fatal stream error — with a short
|
|
25
|
+
# human-readable reason (e.g. "process terminated by signal 9").
|
|
26
|
+
# Dispatch any already-read stdout messages via on_message *before*
|
|
27
|
+
# calling on_close, so responses that raced the death are not lost. A
|
|
28
|
+
# shutdown initiated by the owner via #close is not a death and must
|
|
29
|
+
# not be reported. On notification, Client rejects in-flight requests
|
|
30
|
+
# and ends Session event streams promptly with TransportClosedError
|
|
31
|
+
# instead of letting them wait out their timeouts.
|
|
32
|
+
#
|
|
33
|
+
# on_close is optional for compatibility: Client inspects the transport
|
|
34
|
+
# factory's parameters and passes the keyword only when the factory
|
|
35
|
+
# accepts it (explicitly or via **kwargs). A factory with the older
|
|
36
|
+
# `(on_message:, on_stderr:)` shape keeps its previous behavior —
|
|
37
|
+
# request and event timeouts remain the only death backstop.
|
|
38
|
+
#
|
|
21
39
|
# Implementations own framing (pi speaks strict-LF JSONL — see Framer)
|
|
22
40
|
# and the thread-safety of #write. Client injects its own handlers via
|
|
23
41
|
# a transport factory, so transports never need settable callbacks.
|
data/lib/pi_agent/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pi-agent-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- chagel
|
|
@@ -85,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
85
85
|
- !ruby/object:Gem::Version
|
|
86
86
|
version: '0'
|
|
87
87
|
requirements: []
|
|
88
|
-
rubygems_version:
|
|
88
|
+
rubygems_version: 4.0.10
|
|
89
89
|
specification_version: 4
|
|
90
90
|
summary: Ruby client for the pi coding agent (drives `pi --mode rpc`)
|
|
91
91
|
test_files: []
|