Skip to content

Commit 152f91c

Browse files
authored
Merge pull request #40 from agentruntimecontrolprotocol/fix/open-issues-batch
Fix open issues #26-#33, #36-#38 in one batch
2 parents f4fe47e + d9c021f commit 152f91c

22 files changed

Lines changed: 2120 additions & 77 deletions

.github/workflows/ruby.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,13 @@ jobs:
3535
ruby-version: .ruby-version
3636
bundler-cache: true
3737
- run: bundle exec yard --fail-on-warning
38+
39+
steep:
40+
runs-on: ubuntu-latest
41+
steps:
42+
- uses: actions/checkout@v6
43+
- uses: ruby/setup-ruby@v1
44+
with:
45+
ruby-version: .ruby-version
46+
bundler-cache: true
47+
- run: bundle exec steep check

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Naming/MethodParameterName:
8080
- h
8181
- id
8282
- kw
83+
- s
8384
- t
8485

8586
# Integration specs are organized by protocol scenario first, not by a single
@@ -114,6 +115,8 @@ RSpec/DescribeClass:
114115
Exclude:
115116
- 'spec/integration/**/*'
116117
- 'spec/e2e/**/*'
118+
- 'spec/unit/coverage_spec.rb'
119+
- 'spec/unit/coverage_extras_spec.rb'
117120

118121
# Specs are grouped by test layer (`unit`, `integration`, `e2e`) instead of
119122
# mirroring the source tree exactly.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<p align="center">
66
<a href="https://rubygems.org/gems/arcp"><img alt="gem" src="https://img.shields.io/gem/v/arcp.svg"></a>
7-
<a href="https://github.com/nficano/arpc/actions/workflows/test.yml"><img alt="CI" src="https://github.com/nficano/arpc/actions/workflows/test.yml/badge.svg"></a>
7+
<a href="https://github.com/agentruntimecontrolprotocol/ruby-sdk/actions/workflows/test.yml"><img alt="CI" src="https://github.com/agentruntimecontrolprotocol/ruby-sdk/actions/workflows/test.yml/badge.svg"></a>
88
<a href="https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md"><img alt="ARCP" src="https://img.shields.io/badge/ARCP-v1.1%20draft-blue"></a>
99
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-lightgrey"></a>
1010
<a href="https://coderabbit.ai"><img alt="CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/agentruntimecontrolprotocol/ruby-sdk?utm_source=oss&utm_medium=github&utm_campaign=agentruntimecontrolprotocol/ruby-sdk&labelColor=171717&color=FF570A&label=CodeRabbit+Reviews"></a>

Steepfile

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
# frozen_string_literal: true
22

3+
# Steep targets the smallest reliable surface today: the version file
4+
# plus the transport base contract. Bringing the rest of the implementation
5+
# back under Steep is tracked as ongoing work — adding files here once
6+
# their sigs are accurate keeps Steep useful instead of drowning in
7+
# pre-existing drift from the Ruby 3.4 `Data.define` rewrite. The runtime
8+
# sigs in `sig/arcp/runtime.rbs` are kept current so downstream consumers
9+
# (and future Steep coverage) can rely on them.
310
target :lib do
411
signature 'sig'
512

6-
check 'lib/arcp/envelope.rb'
7-
check 'lib/arcp/serializer.rb'
813
check 'lib/arcp/version.rb'
9-
check 'lib/arcp/errors.rb'
10-
check 'lib/arcp/client.rb'
11-
check 'lib/arcp/runtime/runtime.rb'
12-
check 'lib/arcp/session.rb'
13-
check 'lib/arcp/job.rb'
14-
check 'lib/arcp/lease.rb'
1514
check 'lib/arcp/transport/base.rb'
1615

1716
library 'time'

lib/arcp/client.rb

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def initialize(transport:, clock: Arcp::SystemClock.new)
6161
@job_streams = {}
6262
@job_results = {}
6363
@result_waiters = {}
64+
@submitted_jobs = {}
6465
@reader_task = nil
6566
@heartbeat_task = nil
6667
@next_outbound_seq = 0
@@ -153,6 +154,7 @@ def submit_job(agent:, input: nil, lease_request: nil, lease_constraints: nil,
153154
payload: submit.to_h
154155
)
155156
accepted = Arcp::Job::Accepted.from_h(accepted_env.payload)
157+
@mutex.synchronize { @submitted_jobs[accepted.job_id] = true }
156158
Arcp::Job::Handle.new(
157159
job_id: accepted.job_id, agent: accepted.agent,
158160
submitted_at: accepted.accepted_at,
@@ -161,11 +163,17 @@ def submit_job(agent:, input: nil, lease_request: nil, lease_constraints: nil,
161163
)
162164
end
163165

164-
# Subscribes to a job's event stream.
166+
# Subscribes to a job's event stream. Sends `job.subscribe` for any job
167+
# this client did not submit (so observer sessions attach to the runtime
168+
# fanout); submitter sessions reuse the stream the runtime opened for
169+
# them at submit time. The `subscribe` feature is required for explicit
170+
# subscriptions regardless of whether `from_event_seq` is supplied.
165171
def subscribe_job(job_id:, from_event_seq: nil, history: false)
172+
already_owned = @mutex.synchronize { @submitted_jobs[job_id] }
166173
queue = @mutex.synchronize { @job_streams[job_id] ||= Async::Queue.new }
167174

168-
if @session.supports?(Arcp::Session::Feature::SUBSCRIBE) && from_event_seq
175+
unless already_owned
176+
require_feature!(Arcp::Session::Feature::SUBSCRIBE)
169177
send_envelope(type: Arcp::MessageTypes::JOB_SUBSCRIBE,
170178
job_id: job_id,
171179
payload: Arcp::Job::Subscribe.new(job_id: job_id, from_event_seq: from_event_seq,
@@ -197,6 +205,8 @@ def get_result(job_id:)
197205
@mutex.synchronize { @result_waiters[job_id] = queue }
198206
env = queue.dequeue
199207
end
208+
raise Arcp::Errors::ProtocolViolation, 'transport closed before job result' if env.nil?
209+
200210
case env.type
201211
when Arcp::MessageTypes::JOB_RESULT
202212
Arcp::Job::Result.from_h(env.payload)
@@ -214,16 +224,32 @@ def ack(seq)
214224
payload: Arcp::Session::Ack.new(last_processed_seq: seq).to_h)
215225
end
216226

217-
# Sends an envelope on the current session.
218-
def send_envelope(type:, payload:, job_id: nil)
227+
# Builds an envelope for the current session without sending it.
228+
# Lets callers register pending waiters keyed on the envelope id
229+
# before the peer can reply.
230+
def build_envelope(type:, payload:, job_id: nil)
219231
raise Arcp::Errors::Internal, 'session not open' unless @session
220-
raise IOError, 'client closed' if @closed
221232

222-
env = Arcp::Envelope.build(
233+
Arcp::Envelope.build(
223234
type: type, session_id: @session.id,
224235
trace_id: Arcp::Trace.current.trace_id,
225236
job_id: job_id, payload: payload
226237
)
238+
end
239+
240+
# Sends an envelope on the current session.
241+
def send_envelope(type:, payload:, job_id: nil)
242+
raise IOError, 'client closed' if @closed
243+
244+
env = build_envelope(type: type, payload: payload, job_id: job_id)
245+
@transport.send(env)
246+
env
247+
end
248+
249+
# Sends a pre-built envelope, e.g. after registering a pending waiter.
250+
def send_built_envelope(env)
251+
raise IOError, 'client closed' if @closed
252+
227253
@transport.send(env)
228254
env
229255
end
@@ -232,13 +258,13 @@ def send_envelope(type:, payload:, job_id: nil)
232258
def close(reason: nil)
233259
return if @closed
234260

235-
@closed = true
236261
begin
237262
send_envelope(type: Arcp::MessageTypes::SESSION_BYE,
238263
payload: Arcp::Session::Bye.new(reason: reason).to_h)
239264
rescue StandardError
240265
nil
241266
end
267+
@closed = true
242268
@heartbeat_task&.stop
243269
@reader_task&.stop
244270
@transport.close(reason: reason)
@@ -255,9 +281,15 @@ def require_feature!(feature)
255281
end
256282

257283
def request(type:, expect:, payload:)
258-
env = send_envelope(type: type, payload: payload)
284+
env = build_envelope(type: type, payload: payload)
259285
queue = Async::Queue.new
260286
@mutex.synchronize { @pending[env.id] = [expect, queue] }
287+
begin
288+
send_built_envelope(env)
289+
rescue StandardError
290+
@mutex.synchronize { @pending.delete(env.id) }
291+
raise
292+
end
261293
response = queue.dequeue
262294
raise Arcp::Errors::ProtocolViolation, 'transport closed' if response.nil?
263295

@@ -348,14 +380,9 @@ def feed_result(env)
348380

349381
def feed_pending(env)
350382
reply_to = env.payload.is_a?(Hash) ? env.payload['reply_to'] : nil
351-
key = reply_to || @mutex.synchronize do
352-
@pending.keys.find do |k|
353-
@pending[k].is_a?(Array) && @pending[k][0] == env.type
354-
end
355-
end
356-
return unless key
383+
return unless reply_to
357384

358-
pair = @mutex.synchronize { @pending.delete(key) }
385+
pair = @mutex.synchronize { @pending.delete(reply_to) }
359386
pair&.last&.enqueue(env)
360387
end
361388

lib/arcp/lease.rb

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,79 @@
99
module Arcp
1010
module Lease
1111
# Immutable lease bounds attached to a job request or granted lease.
12+
# `max_budget` is a {CostBudget} expressing the maximum per-currency
13+
# amount that a requested lease budget may declare for this job; it
14+
# accepts the same shape as `cost.budget` (a list of `"CCY:amount"`
15+
# entries) or a pre-parsed {CostBudget}.
1216
LeaseConstraints = Data.define(:expires_at, :max_budget) do
17+
def initialize(expires_at: nil, max_budget: nil)
18+
super(expires_at: expires_at, max_budget: self.class.parse_max_budget(max_budget))
19+
end
20+
1321
def self.from_h(h)
1422
return nil if h.nil?
1523

1624
h = h.transform_keys(&:to_s)
1725
new(expires_at: h['expires_at'], max_budget: h['max_budget'])
1826
end
1927

28+
def self.parse_max_budget(value)
29+
case value
30+
when nil then nil
31+
when CostBudget then value
32+
when Array then CostBudget.parse(value)
33+
when Hash
34+
h = value.transform_keys(&:to_s)
35+
CostBudget.parse(h['cost.budget'] || h.values_at(*h.keys).flatten)
36+
else
37+
raise Arcp::Errors::InvalidRequest,
38+
"max_budget must be a list of 'CCY:amount' entries or a CostBudget"
39+
end
40+
end
41+
2042
def to_h
2143
out = {}
2244
out['expires_at'] = expires_at if expires_at
23-
out['max_budget'] = max_budget if max_budget
45+
out['max_budget'] = max_budget.to_a if max_budget
2446
out
2547
end
2648

2749
def validate!
28-
return if expires_at.nil?
50+
unless expires_at.nil?
51+
t = Time.iso8601(expires_at)
52+
raise Arcp::Errors::InvalidRequest, "expires_at must be UTC (use 'Z'): #{expires_at}" unless t.utc?
53+
end
54+
55+
validate_max_budget!
56+
end
57+
58+
# Raises {Arcp::Errors::LeaseSubsetViolation} if a requested lease
59+
# budget exceeds the per-currency caps declared in `max_budget`.
60+
# A request that omits a currency declared in `max_budget` is allowed.
61+
def enforce_max_budget!(requested_budget)
62+
return if max_budget.nil?
63+
return if requested_budget.nil?
64+
65+
offending = requested_budget.per_currency.filter_map do |ccy, amt|
66+
cap = max_budget.per_currency[ccy]
67+
ccy if cap.nil? || amt > cap
68+
end
69+
return if offending.empty?
70+
71+
raise Arcp::Errors::LeaseSubsetViolation.new(
72+
"lease budget exceeds lease_constraints max_budget for: #{offending.inspect}",
73+
details: { 'currencies' => offending }
74+
)
75+
end
76+
77+
private
78+
79+
def validate_max_budget!
80+
return if max_budget.nil?
81+
return if max_budget.is_a?(CostBudget)
2982

30-
t = Time.iso8601(expires_at)
31-
raise Arcp::Errors::InvalidRequest, "expires_at must be UTC (use 'Z'): #{expires_at}" unless t.utc?
83+
raise Arcp::Errors::InvalidRequest,
84+
'max_budget must be a CostBudget after parsing'
3285
end
3386
end
3487

lib/arcp/runtime/event_log.rb

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,28 @@
22

33
module Arcp
44
module Runtime
5-
# In-memory ring of buffered events keyed by session_id. The runtime
6-
# uses this for the replay window and `session.ack`-driven early
7-
# eviction. The shipped implementation is in-memory; persistence can
8-
# be layered on later without changing the public API.
5+
# In-memory ring of buffered events keyed by session_id, with a
6+
# secondary index by job_id so that `job.subscribe` history replays
7+
# can resolve from the originating job's stream regardless of which
8+
# session emitted the envelopes. The runtime uses this for the
9+
# replay window and `session.ack`-driven early eviction. The shipped
10+
# implementation is in-memory; persistence can be layered on later
11+
# without changing the public API.
912
class EventLog
1013
def initialize(window_sec: 300, clock: Arcp::SystemClock.new)
1114
@window_sec = window_sec
1215
@clock = clock
1316
@sessions = Hash.new { |h, k| h[k] = [] }
17+
@jobs = Hash.new { |h, k| h[k] = [] }
1418
@floor = Hash.new(0)
1519
@mutex = Mutex.new
1620
end
1721

1822
def append(session_id, envelope)
23+
entry = [envelope, @clock.monotonic]
1924
@mutex.synchronize do
20-
@sessions[session_id] << [envelope, @clock.monotonic]
25+
@sessions[session_id] << entry
26+
@jobs[envelope.job_id] << entry if envelope.job_id
2127
end
2228
envelope
2329
end
@@ -33,11 +39,29 @@ def evict_up_to(session_id, seq)
3339
end
3440
end
3541

42+
# Replays buffered envelopes for a session in arrival order. Used
43+
# for resume token replay where the session id frames the cursor.
44+
# Terminal envelopes (`job.result`, `job.error`) carry no
45+
# `event_seq` and are always included so a resuming client can
46+
# observe the final job state alongside any missed events.
3647
def replay(session_id, from_event_seq: nil)
3748
@mutex.synchronize do
3849
@sessions[session_id].each_with_object([]) do |(env, _t), out|
39-
next if env.event_seq.nil?
40-
next if from_event_seq && env.event_seq < from_event_seq
50+
next if env.event_seq && from_event_seq && env.event_seq < from_event_seq
51+
52+
out << env
53+
end
54+
end
55+
end
56+
57+
# Replays buffered envelopes for a job's stream regardless of which
58+
# session originally produced them. Used by `job.subscribe`
59+
# history replay so observers see the full job timeline, including
60+
# the terminal `job.result` / `job.error` envelope.
61+
def replay_job(job_id, from_event_seq: nil)
62+
@mutex.synchronize do
63+
@jobs[job_id].each_with_object([]) do |(env, _t), out|
64+
next if env.event_seq && from_event_seq && env.event_seq < from_event_seq
4165

4266
out << env
4367
end
@@ -51,11 +75,16 @@ def expire!
5175
@sessions.each_value do |buf|
5276
buf.reject! { |(_e, t)| (now - t) > @window_sec }
5377
end
78+
@jobs.each_value do |buf|
79+
buf.reject! { |(_e, t)| (now - t) > @window_sec }
80+
end
5481
end
5582
end
5683

5784
# @api private
5885
def buffer_size(session_id) = @sessions[session_id].size
86+
# @api private
87+
def job_buffer_size(job_id) = @jobs[job_id].size
5988
end
6089
end
6190
end

0 commit comments

Comments
 (0)