diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 8c2ee86..023f848 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -18,7 +18,7 @@ v1.0.0). No spec MUST/SHOULD in §4–§16 is unimplemented.
| §6.2 Capability negotiation (intersection) | yes | `lib/arcp/session/capability_set.rb#intersect` |
| §6.2 Feature names: heartbeat, ack, list_jobs, subscribe, lease_expires_at, cost.budget, progress, result_chunk, agent_versions, model.use, provisioned_credentials | yes | `lib/arcp/session/feature.rb` |
| §6.3 session.welcome with resume_token + resume_window_sec | yes | `lib/arcp/session/welcome.rb`, `lib/arcp/runtime/session_actor.rb` |
-| §6.3 Resume by last_event_seq | yes | `lib/arcp/runtime/event_log.rb` |
+| §6.3 Resume by last_event_seq | deferred | n/a |
| §6.4 session.ping / session.pong heartbeats | yes | `lib/arcp/session/ping.rb`, `lib/arcp/session/pong.rb`, `lib/arcp/client.rb#start_heartbeat!` |
| §6.4 HEARTBEAT_LOST MUST NOT terminate jobs | yes | `lib/arcp/runtime/session_actor.rb` |
| §6.5 session.ack with last_processed_seq | yes | `lib/arcp/session/ack.rb`, `lib/arcp/client.rb#ack` |
diff --git a/README.md b/README.md
index 6129ef1..6c0743c 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ ARCP itself is a transport-agnostic wire protocol for long-running AI agent jobs
## Installation
-Requires Ruby 3.3 or later. The gem runs on the `socketry/async` reactor and pulls in `async-websocket` for the default networked transport and `sqlite3` for the resume log; no separate extras are needed. Add it to a `Gemfile`:
+Requires Ruby 3.3 or later. The gem runs on the `socketry/async` reactor and pulls in `async-websocket` for the default networked transport. The runtime currently buffers events in memory for replay; durable persistence is not shipped yet. Add it to a `Gemfile`:
```ruby
gem 'arcp', '~> 1.0'
@@ -83,7 +83,7 @@ This is the whole shape of the SDK: open a session, submit work, consume an orde
ARCP organizes everything around four concerns — **identity**, **durability**, **authority**, and **observability** — expressed through five core objects:
-- **Session** — a connection between a client and a runtime. A session carries identity (a bearer token), negotiates a feature set in a `hello`/`welcome` handshake, and is *resumable*: if the transport drops, you reconnect with a resume token and the runtime replays buffered events. Jobs outlive the session that started them. See [§6](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md).
+- **Session** — a connection between a client and a runtime. A session carries identity (a bearer token), negotiates a feature set in a `hello`/`welcome` handshake, and keeps a replay window in the runtime's in-memory event log. Transparent reconnect resume is not wired through yet; use `history: true` and `from_event_seq` when you need to replay events. Jobs outlive the session that started them. See [§6](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md).
- **Job** — one unit of agent work submitted into a session. A job has an identity, an optional idempotency key, a resolved agent version, and a lifecycle that ends in exactly one terminal state: `success`, `error`, `cancelled`, or `timed_out`. See [§7](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md).
- **Event** — the ordered, session-scoped stream a job emits: logs, thoughts, tool calls and results, status, metrics, artifact references, progress, and streamed result chunks. Events carry strictly monotonic sequence numbers so the stream survives reconnects gap-free. See [§8](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md).
- **Lease** — the authority a job runs under, expressed as capability grants (`fs.read`, `fs.write`, `net.fetch`, `tool.call`, `agent.delegate`, `cost.budget`, `model.use`). The runtime enforces the lease at every operation boundary; a job can never act outside it. Leases may carry a budget and an expiry, and may be subset and handed to sub-agents via delegation. See [§9](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md).
@@ -93,9 +93,9 @@ The SDK models each of these as first-class objects; the rest of this README sho
## Guides
-### Sessions and resume
+### Sessions and replay
-Open a session, negotiate features, and reconnect transparently after a transport drop using the resume token — jobs keep running server-side while you're gone.
+Open a session, submit work, and replay buffered events from the retained log window when you need to recover missed history.
```ruby
require 'async'
@@ -108,22 +108,15 @@ Sync do
client_name: 'resumable'
)
- session_id = client.session.id
- resume_token = client.session.resume_token
- last_seq = Hash.new(0)
handle = client.submit_job(agent: 'long-runner')
handle.subscribe(client: client).each do |event|
- last_seq[handle.job_id] = event.body.respond_to?(:seq) ? event.body.seq : last_seq[handle.job_id]
+ puts "#{event.kind}: #{event.body.to_h}"
end
- # ... transport drops ...
-
- resumed = Arcp::Client.open(
- transport: new_transport,
- auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') },
- resume: { 'token' => resume_token, 'last_event_seq' => last_seq }
- )
- # The runtime replays every event with event_seq > last_seq, then resumes live streaming.
+ replay = client.subscribe_job(job_id: handle.job_id, from_event_seq: 0, history: true)
+ replay.each do |event|
+ puts "[replay] #{event.kind}"
+ end
end
```
@@ -277,7 +270,7 @@ Full API reference — every type, method, and event payload — is in [`docs/`]
## Versioning and compatibility
-This SDK speaks **ARCP v1.1 (draft)**. The SDK follows semantic versioning independently of the protocol; the protocol version it negotiates is shown above and in `session.hello`. A runtime advertising a different ARCP MAJOR is not guaranteed compatible. Feature mismatches degrade gracefully: the effective feature set is the intersection of what the client and runtime advertise, and the SDK will not use a feature outside it.
+This SDK speaks **ARCP v1.1 (draft)**. The SDK follows semantic versioning independently of the protocol; the negotiated runtime version is available on `client.session.runtime_version`. A runtime advertising a different ARCP MAJOR is not guaranteed compatible. Feature mismatches degrade gracefully: the effective feature set is the intersection of what the client and runtime advertise, and the SDK will not use a feature outside it.
## Contributing
diff --git a/arcp.gemspec b/arcp.gemspec
index 0c307b6..1338a30 100644
--- a/arcp.gemspec
+++ b/arcp.gemspec
@@ -11,9 +11,9 @@ Gem::Specification.new do |spec|
spec.summary = 'Reference Ruby implementation of the Agent Runtime Control Protocol (ARCP).'
spec.description = <<~DESC
Ruby SDK for ARCP: envelope and message model, fiber-based runtime, client,
- WebSocket / stdio / in-memory transports, SQLite-backed resume log,
- capability negotiation, leases with budget and expiration, streamed results,
- and OpenTelemetry trace propagation. Built on socketry/async.
+ WebSocket / stdio / in-memory transports, in-memory event buffering for
+ replay, capability negotiation, leases with budget and expiration, streamed
+ results, and OpenTelemetry trace propagation. Built on socketry/async.
DESC
spec.homepage = 'https://github.com/nficano/arpc'
spec.license = 'Apache-2.0'
diff --git a/docs/architecture.md b/docs/architecture.md
index 8490ba5..a2a2e66 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -46,7 +46,7 @@ Arcp
JobContext # passed to handlers
LeaseManager # lease + budget accounting
SubscriptionManager # cross-session observers
- EventLog # replay window
+ EventLog # in-memory replay window
Transport
Base / MemoryTransport / WebSocketTransport / StdioTransport
```
@@ -113,7 +113,7 @@ Arcp::Session::Feature::RESULT_CHUNK # 'result_chunk'
Arcp::Session::Feature::AGENT_VERSIONS # 'agent_versions'
```
-`Arcp::Session::Feature::ALL` is a frozen Array of all nine.
+`Arcp::Session::Feature::ALL` is a frozen Array of all eleven.
### Negotiation
@@ -163,8 +163,8 @@ client.submit_job(agent: 'code-refactor@1.0.0') # pins to 1.0.0
```
An unknown version raises `Arcp::Errors::AgentVersionNotAvailable` with
-`details['available_versions']` populated. `AgentInventory#resolve(ref)`
-can validate a ref before submit:
+`details['available']` populated. `AgentInventory#resolve(ref)` can
+validate a ref before submit:
```ruby
client.session.capabilities.agents.resolve('code-refactor@1.0.0')
diff --git a/docs/diagrams/module-deps-dark.dot b/docs/diagrams/module-deps-dark.dot
index f74ff12..d8a61a4 100644
--- a/docs/diagrams/module-deps-dark.dot
+++ b/docs/diagrams/module-deps-dark.dot
@@ -86,7 +86,7 @@ digraph ArcpModuleDeps {
SubMgr [label="SubscriptionManager"];
EventLog [
- label=<EventLog
SQLite>,
+ label=<EventLog
In-memory>,
shape=cylinder, fillcolor="#1E293B"
];
}
diff --git a/docs/diagrams/module-deps-dark.svg b/docs/diagrams/module-deps-dark.svg
index f8d498e..747fecd 100644
--- a/docs/diagrams/module-deps-dark.svg
+++ b/docs/diagrams/module-deps-dark.svg
@@ -202,7 +202,7 @@
EventLog
-SQLite
+In-memory
diff --git a/docs/diagrams/module-deps-light.dot b/docs/diagrams/module-deps-light.dot
index 5faed0c..7b99b9f 100644
--- a/docs/diagrams/module-deps-light.dot
+++ b/docs/diagrams/module-deps-light.dot
@@ -88,7 +88,7 @@ digraph ArcpModuleDeps {
SubMgr [label="SubscriptionManager"];
EventLog [
- label=<EventLog
SQLite>,
+ label=<EventLog
In-memory>,
shape=cylinder, fillcolor="#FAFBFC"
];
}
diff --git a/docs/diagrams/module-deps-light.svg b/docs/diagrams/module-deps-light.svg
index 4255d8d..415a36d 100644
--- a/docs/diagrams/module-deps-light.svg
+++ b/docs/diagrams/module-deps-light.svg
@@ -202,7 +202,7 @@
EventLog
-SQLite
+In-memory
diff --git a/docs/guides/agent-versioning.md b/docs/guides/agent-versioning.md
new file mode 100644
index 0000000..d61cfca
--- /dev/null
+++ b/docs/guides/agent-versioning.md
@@ -0,0 +1,50 @@
+---
+title: Agent versioning
+sdk: ruby
+kind: guide
+order: 13
+spec_sections: [§7.5]
+---
+
+# Agent versioning
+
+Agents are registered under a stable name and an optional set of
+published versions. When a client submits `name@version`, the runtime
+resolves that exact version if it exists; when the client submits only
+`name`, the runtime uses the registered default.
+
+## Register versions
+
+```ruby
+runtime.register_agent(
+ name: 'code-refactor',
+ versions: %w[1.0.0 2.0.0],
+ default: '2.0.0',
+ handler: ->(ctx) { ctx.finish(result: ctx.agent) }
+)
+```
+
+## Submit a version-pinned job
+
+```ruby
+handle = client.submit_job(agent: 'code-refactor@1.0.0')
+result = handle.get_result(client: client)
+```
+
+## Validate a ref first
+
+`Arcp::Session::AgentInventory#resolve(ref)` returns the normalized
+`name@version` string or `nil` if the ref is unknown. On failure, the
+runtime raises `Arcp::Errors::AgentVersionNotAvailable` with
+`details['available']` populated with the registered versions.
+
+```ruby
+inventory = client.session.capabilities.agents
+inventory.resolve('code-refactor@1.0.0') # => "code-refactor@1.0.0"
+inventory.resolve('code-refactor@9.9.9') # => nil
+```
+
+## See also
+
+- `guides/sessions.md`
+- `guides/jobs.md`
diff --git a/docs/guides/jobs.md b/docs/guides/jobs.md
index ab5d28f..7f8f582 100644
--- a/docs/guides/jobs.md
+++ b/docs/guides/jobs.md
@@ -107,8 +107,9 @@ HANDLER = lambda do |ctx|
end
```
-`try_spend!` atomically decrements the lease's `BudgetCounter`. If the
-balance goes negative, the runtime emits `job.error` with code
+`Arcp::Runtime::LeaseManager#try_spend!` atomically decrements the
+lease's `BudgetCounter` by calling `BudgetCounter#try_decrement`. If the
+balance is insufficient, the runtime emits `job.error` with code
`BUDGET_EXHAUSTED`.
When spend is enforced by an upstream gateway instead of local counters,
@@ -121,7 +122,7 @@ issued key — see `guides/credentials.md`.
begin
handle.get_result(client: client)
rescue Arcp::Errors::BudgetExhausted => e
- e.details # { 'currency' => 'USD', 'requested' => ..., 'remaining' => ... }
+ e.details # { 'currency' => 'USD', 'remaining' => '0.30' }
end
```
diff --git a/docs/guides/leases.md b/docs/guides/leases.md
index 08d4533..3bcd52a 100644
--- a/docs/guides/leases.md
+++ b/docs/guides/leases.md
@@ -43,7 +43,8 @@ budget = Arcp::Lease::CostBudget.parse(['USD:1.00', 'EUR:0.50'])
budget.remaining('USD') # => BigDecimal('1.00')
```
-`BudgetCounter#try_spend!` atomically decrements; overspend raises
+`Arcp::Runtime::LeaseManager#try_spend!` atomically decrements the
+bound counter via `BudgetCounter#try_decrement`; overspend raises
`Arcp::Errors::BudgetExhausted`. See `guides/jobs.md` for the full
spend workflow.
diff --git a/docs/guides/resume.md b/docs/guides/resume.md
index 9e3c040..1f7fa69 100644
--- a/docs/guides/resume.md
+++ b/docs/guides/resume.md
@@ -8,67 +8,65 @@ spec_sections: [§6.3, §6.4]
# Resume
-After a transport drop, a client may reconnect and continue receiving
-events for in-flight jobs. The mechanism is two pieces of state on
-`session.welcome`: a `resume_token` and a `resume_window_sec`.
+The Ruby SDK exposes `resume_token` and `resume_window_sec` on
+`session.welcome`, but it does not yet use the `resume:` reconnect
+payload to transparently restore a dropped session. What is shipped
+today is an in-memory replay window for job events and subscription
+history.
-## Resume token
+## Replay retained events
+
+When you need missed history, open a fresh session and subscribe with
+`history: true`. Use `from_event_seq: 0` for a full replay of the
+retained window.
```ruby
client = Arcp::Client.open(transport: transport, auth: auth)
-token = client.session.resume_token
-window = client.session.resume_window_sec
-```
-
-Save `token` somewhere durable across reconnects. The runtime guarantees
-the resume window for that token.
-
-## Reconnect with last_event_seq
+handle = client.submit_job(agent: 'long-runner')
-```ruby
-new_client = Arcp::Client.open(
- transport: new_transport,
- auth: auth,
- resume: {
- 'token' => token,
- 'last_event_seq' => { job_id => last_seq }
- }
+replay = client.subscribe_job(
+ job_id: handle.job_id,
+ history: true,
+ from_event_seq: 0
)
-```
-
-The runtime replays every job-event with `event_seq > last_seq` from
-its event log, then resumes live tailing.
-## Resume window expiry
-
-If `resume_window_sec` has elapsed since the prior session closed, the
-runtime responds with `session.error` code `RESUME_WINDOW_EXPIRED`. The
-client raises `Arcp::Errors::ResumeWindowExpired` from `Client.open`.
-Recover by opening a fresh session and re-subscribing with `history: true`.
+replay.each do |event|
+ puts "#{event.kind}: #{event.body.to_h}"
+end
+```
-## Heartbeats and reconnect
+## Retention window
-`session.ping` / `session.pong` keep idle connections alive and surface
-half-open TCP states. The runtime advertises a `heartbeat_interval_sec`
-on welcome; the client schedules a ping at that cadence.
+The runtime keeps buffered events in memory for the configured
+`resume_window_sec` period and evicts older entries from the replay log.
+If that window elapses before you subscribe, the older events are no
+longer recoverable.
```ruby
runtime = Arcp::Runtime::Runtime.new(
auth_verifier: verifier,
- heartbeat_interval_sec: 30 # nil to disable
+ heartbeat_interval_sec: 30, # nil to disable
+ resume_window_sec: 300
)
-client = Arcp::Client.open(transport: t, auth: auth)
-client.session.heartbeat_interval_sec # => 30
+
+client.session.resume_token
+client.session.resume_window_sec
```
+## Heartbeats
+
+`session.ping` / `session.pong` keep idle connections alive and surface
+half-open TCP states. The runtime advertises a
+`heartbeat_interval_sec` on welcome; the client schedules a ping at that
+cadence.
+
If a peer detects N consecutive missed heartbeats it MAY close the
transport and raise `Arcp::Errors::HeartbeatLost` (`retryable? == true`).
A lost heartbeat MUST NOT terminate running jobs at the runtime. Job
-state persists in the event log within the resume window; reconnecting
-clients can resume via `resume_token` and `from_event_seq`.
+state persists in the event log within the replay window.
## See also
- `guides/sessions.md`
-- `guides/jobs.md`
+- `guides/job-events.md`
diff --git a/docs/guides/sessions.md b/docs/guides/sessions.md
index 1190e3b..077d276 100644
--- a/docs/guides/sessions.md
+++ b/docs/guides/sessions.md
@@ -87,9 +87,11 @@ Arcp::Session::Feature::COST_BUDGET # 'cost.budget'
Arcp::Session::Feature::PROGRESS # 'progress'
Arcp::Session::Feature::RESULT_CHUNK # 'result_chunk'
Arcp::Session::Feature::AGENT_VERSIONS # 'agent_versions'
+Arcp::Session::Feature::MODEL_USE # 'model.use'
+Arcp::Session::Feature::PROVISIONED_CREDENTIALS # 'provisioned_credentials'
```
-`Arcp::Session::Feature::ALL` is a frozen Array of all nine.
+`Arcp::Session::Feature::ALL` is a frozen Array of all eleven.
### CapabilitySet
diff --git a/docs/recipes.md b/docs/recipes.md
index de981b2..1a0e73d 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -163,11 +163,18 @@ end
# Caller
Sync do
- Arcp::Client.connect(transport: transport) do |client|
+ client = nil
+ begin
+ client = Arcp::Client.open(
+ transport: transport,
+ auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') }
+ )
puts Skills::SummarizeText.call(
client: client,
text: 'Long article text here...'
).inspect
+ ensure
+ client&.close
end
end
```
@@ -225,54 +232,53 @@ result = handle.get_result(client: client)
puts result.output.inspect
```
-## Stream + resume (stream-resume)
+## Stream + replay (stream-resume)
-Assemble a chunked result across a simulated transport drop. The client
-reconnects with the saved `resume_token` and `last_event_seq`, and the
-runtime replays missed chunks from its event log.
+Assemble a chunked result across a simulated disconnect. The SDK does
+not transparently restore a dropped session yet, so the recovery step is
+to open a fresh client and replay the retained history with
+`history: true`.
```ruby
-resume_token = nil
-last_seq = 0
-assembled = []
+job_id = nil
+assembled = []
# First connection — collect some chunks then simulate a drop
-Arcp::Client.connect(transport: first_transport) do |client|
+client = nil
+begin
+ client = Arcp::Client.open(
+ transport: first_transport,
+ auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') }
+ )
handle = client.submit_job(agent: 'big-streamer')
+ job_id = handle.job_id
handle.subscribe(client: client).each do |event|
- case event.kind
- when Arcp::Job::EventKind::RESULT_CHUNK
- assembled << event.body.decoded
- last_seq = event.seq
-
- when Arcp::Job::EventKind::STATUS
- if event.body.phase == 'connected'
- # Stash the resume token from the welcome payload
- resume_token = client.session.resume_token
- break # simulate drop after first few chunks
- end
- end
+ next unless event.kind == Arcp::Job::EventKind::RESULT_CHUNK
+
+ assembled << event.body.decoded
+ break if assembled.length >= 2
end
+ensure
+ client&.close
end
-# Second connection — resume from where we left off
-second_transport = build_transport(
- resume_token: resume_token,
- last_event_seq: last_seq
-)
-
-Arcp::Client.connect(transport: second_transport) do |client|
- handle = client.reattach_job(handle.job_id)
-
- handle.subscribe(client: client).each do |event|
+# Second connection — replay the buffered history
+client = nil
+begin
+ client = Arcp::Client.open(
+ transport: second_transport,
+ auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') }
+ )
+ client.subscribe_job(job_id: job_id, history: true, from_event_seq: 0).each do |event|
next unless event.kind == Arcp::Job::EventKind::RESULT_CHUNK
assembled << event.body.decoded
- break unless event.body.more
end
- handle.get_result(client: client)
+ client.get_result(job_id: job_id)
+ensure
+ client&.close
end
full_text = assembled.join
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 617f820..1728f4e 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -22,14 +22,15 @@ fibers. Use `falcon` for hosting.
## `IOError: transport closed` after `client.close`
Expected. After `close`, all client methods raise. Open a new client
-to reconnect; reuse the `resume_token` from the prior session to
-replay missed events.
+to reconnect; if you need missed events, resubscribe with
+`history: true` and `from_event_seq: 0` while the runtime's event-log
+window still retains them.
## `Arcp::Errors::ResumeWindowExpired` on reconnect
-The runtime's `resume_window_sec` elapsed before reconnect. The job's
-event log entries were evicted. Submit a fresh subscription with
-`history: true, from_event_seq: 0` if the job is still live.
+The runtime's event-log retention window elapsed before you asked for a
+replay. Submit a fresh subscription with `history: true, from_event_seq:
+0` if the job is still live.
## `Arcp::Errors::LeaseSubsetViolation` on `Subsetting.bound`
diff --git a/lib/arcp/client.rb b/lib/arcp/client.rb
index e132633..b6bb695 100644
--- a/lib/arcp/client.rb
+++ b/lib/arcp/client.rb
@@ -32,6 +32,18 @@ module Arcp
class Client
attr_reader :session, :transport
+ # Opens a client, performs the initial handshake, and returns a ready
+ # instance.
+ #
+ # @param transport [Arcp::Transport::Base] the transport to attach to.
+ # @param auth [Hash] the session auth payload.
+ # @param client_name [String] the advertised client name.
+ # @param client_version [String] the advertised client version.
+ # @param capabilities [Arcp::Session::CapabilitySet, nil] the optional
+ # local capability set to intersect with the runtime's.
+ # @param resume [Hash, nil] the optional resume payload.
+ # @param clock [Arcp::Clock] the clock used for timestamps and heartbeats.
+ # @return [Arcp::Client]
def self.open(transport:, auth:, client_name: 'arcp-ruby', client_version: Arcp::VERSION,
capabilities: nil, resume: nil, clock: Arcp::SystemClock.new)
client = new(transport: transport, clock: clock)
@@ -57,6 +69,7 @@ def initialize(transport:, clock: Arcp::SystemClock.new)
@mutex = Mutex.new
end
+ # Performs the session handshake and populates {#session}.
def handshake!(auth:, client_name:, client_version:, capabilities: nil, resume: nil)
caps = capabilities || Arcp::Session::CapabilitySet.local
session_id = Arcp::Ids.session_id
@@ -101,6 +114,7 @@ def handshake!(auth:, client_name:, client_version:, capabilities: nil, resume:
@session
end
+ # Lists jobs visible to the current principal.
def list_jobs(status: nil, agent: nil, created_after: nil, limit: nil, cursor: nil)
require_feature!(Arcp::Session::Feature::LIST_JOBS)
@@ -123,6 +137,7 @@ def list_jobs(status: nil, agent: nil, created_after: nil, limit: nil, cursor: n
end.lazy
end
+ # Submits a job and returns the accepted handle.
def submit_job(agent:, input: nil, lease_request: nil, lease_constraints: nil,
idempotency_key: nil, max_runtime_sec: nil)
lease_constraints&.validate!
@@ -146,6 +161,7 @@ def submit_job(agent:, input: nil, lease_request: nil, lease_constraints: nil,
)
end
+ # Subscribes to a job's event stream.
def subscribe_job(job_id:, from_event_seq: nil, history: false)
queue = @mutex.synchronize { @job_streams[job_id] ||= Async::Queue.new }
@@ -166,12 +182,14 @@ def subscribe_job(job_id:, from_event_seq: nil, history: false)
end
end
+ # Cancels a job owned by the current session.
def cancel_job(job_id:, reason: nil)
send_envelope(type: Arcp::MessageTypes::JOB_CANCEL,
job_id: job_id,
payload: Arcp::Job::Cancel.new(job_id: job_id, reason: reason).to_h)
end
+ # Waits for the terminal result or raises the mapped job error.
def get_result(job_id:)
env = @mutex.synchronize { @job_results[job_id] }
if env.nil?
@@ -189,12 +207,14 @@ def get_result(job_id:)
end
end
+ # Sends a `session.ack` for the last processed sequence.
def ack(seq)
require_feature!(Arcp::Session::Feature::ACK)
send_envelope(type: Arcp::MessageTypes::SESSION_ACK,
payload: Arcp::Session::Ack.new(last_processed_seq: seq).to_h)
end
+ # Sends an envelope on the current session.
def send_envelope(type:, payload:, job_id: nil)
raise Arcp::Errors::Internal, 'session not open' unless @session
raise IOError, 'client closed' if @closed
@@ -208,6 +228,7 @@ def send_envelope(type:, payload:, job_id: nil)
env
end
+ # Closes the session and drains any pending queues.
def close(reason: nil)
return if @closed
diff --git a/lib/arcp/job/handle.rb b/lib/arcp/job/handle.rb
index 6b121d1..106e403 100644
--- a/lib/arcp/job/handle.rb
+++ b/lib/arcp/job/handle.rb
@@ -2,14 +2,20 @@
module Arcp
module Job
+ # Lightweight client-side handle for a submitted job.
Handle = Data.define(:job_id, :agent, :submitted_at, :lease, :credentials) do
+ # Builds a handle value object.
def initialize(job_id:, agent:, submitted_at:, lease: nil, credentials: nil)
super
end
+ # Subscribes to this job using the given client.
def subscribe(client:, **kw) = client.subscribe_job(job_id: job_id, **kw)
+ # Cancels this job using the given client.
def cancel(client:, reason: nil) = client.cancel_job(job_id: job_id, reason: reason)
+ # Fetches the terminal result for this job using the given client.
def get_result(client:) = client.get_result(job_id: job_id)
+ # Returns the provisioned credential for the given endpoint, if any.
def credential_for(endpoint:) = Array(credentials).find { |credential| credential.endpoint == endpoint }
end
end
diff --git a/lib/arcp/lease.rb b/lib/arcp/lease.rb
index 9f3c991..81b733a 100644
--- a/lib/arcp/lease.rb
+++ b/lib/arcp/lease.rb
@@ -8,6 +8,7 @@
module Arcp
module Lease
+ # Immutable lease bounds attached to a job request or granted lease.
LeaseConstraints = Data.define(:expires_at, :max_budget) do
def self.from_h(h)
return nil if h.nil?
@@ -31,6 +32,7 @@ def validate!
end
end
+ # A currency-indexed budget that round-trips on the wire as strings.
CostBudget = Data.define(:per_currency) do
def self.parse(entries)
h = {}
@@ -53,6 +55,7 @@ def remaining(currency) = per_currency[currency] || BigDecimal('0')
def currencies = per_currency.keys
end
+ # Mutable counter used to track spent budget for a live job.
class BudgetCounter
attr_reader :remaining
@@ -77,6 +80,7 @@ def snapshot
end
end
+ # Lease request supplied when submitting a job.
LeaseRequest = Data.define(:capabilities, :budget, :model_use, :expires_at) do
def initialize(capabilities:, budget: nil, model_use: nil, expires_at: nil)
super(
@@ -108,6 +112,7 @@ def to_h
end
end
+ # Lease granted to a job after submission is accepted.
Lease = Data.define(:id, :capabilities, :budget, :model_use, :expires_at, :issued_at) do
def initialize(id:, capabilities:, issued_at:, budget: nil, model_use: nil, expires_at: nil)
super(
diff --git a/lib/arcp/runtime/event_log.rb b/lib/arcp/runtime/event_log.rb
index 0815d14..9f9329c 100644
--- a/lib/arcp/runtime/event_log.rb
+++ b/lib/arcp/runtime/event_log.rb
@@ -3,10 +3,9 @@
module Arcp
module Runtime
# In-memory ring of buffered events keyed by session_id. The runtime
- # uses this for the resume window and `session.ack`-driven early
- # eviction. A SQLite-backed variant (same API) is suitable for
- # multi-process runtimes; for v1 we ship the in-memory implementation
- # used by tests and the Falcon-hosted single-process runtime.
+ # uses this for the replay window and `session.ack`-driven early
+ # eviction. The shipped implementation is in-memory; persistence can
+ # be layered on later without changing the public API.
class EventLog
def initialize(window_sec: 300, clock: Arcp::SystemClock.new)
@window_sec = window_sec
diff --git a/lib/arcp/runtime/runtime.rb b/lib/arcp/runtime/runtime.rb
index 247adf5..657b94e 100644
--- a/lib/arcp/runtime/runtime.rb
+++ b/lib/arcp/runtime/runtime.rb
@@ -24,6 +24,8 @@ class Runtime
:job_manager, :lease_manager, :subscription_manager,
:event_log, :credential_registry, :enforce_model_use
+ # Builds a runtime with the supplied auth, transport, and lifecycle
+ # configuration.
def initialize(auth_verifier:, name: 'arcp-runtime', version: Arcp::VERSION,
heartbeat_interval_sec: 30, resume_window_sec: 300,
clock: Arcp::SystemClock.new, credential_provisioner: nil,
@@ -61,10 +63,12 @@ def initialize(auth_verifier:, name: 'arcp-runtime', version: Arcp::VERSION,
@mutex = Mutex.new
end
+ # Registers an agent handler and its available versions.
def register_agent(name:, versions:, default:, handler:)
@job_manager.register_agent(name: name, versions: versions, default: default, handler: handler)
end
+ # Returns the runtime's advertised local capabilities.
def local_capabilities(agents_inventory: false)
features = Arcp::Session::Feature::ALL.dup
unless @credential_registry
@@ -80,21 +84,26 @@ def local_capabilities(agents_inventory: false)
)
end
+ # Accepts a transport and runs the session actor for it.
def accept(transport)
actor = SessionActor.new(runtime: self, transport: transport)
actor.run
end
+ # Registers an active session actor.
def register_session(session_id, actor)
@mutex.synchronize { @sessions[session_id] = actor }
end
+ # Removes an active session actor.
def deregister_session(session_id)
@mutex.synchronize { @sessions.delete(session_id) }
end
+ # Returns the actor for a live session id.
def session(session_id) = @mutex.synchronize { @sessions[session_id] }
+ # Requests all active sessions to close.
def shutdown(reason: nil)
actors = @mutex.synchronize { @sessions.values.dup }
actors.each { |a| a.send_envelope(bye_envelope(a.session_id, reason)) }
diff --git a/lib/arcp/session/agent_inventory.rb b/lib/arcp/session/agent_inventory.rb
index 19c1b61..d307eff 100644
--- a/lib/arcp/session/agent_inventory.rb
+++ b/lib/arcp/session/agent_inventory.rb
@@ -2,6 +2,7 @@
module Arcp
module Session
+ # One registered agent and its published versions.
AgentEntry = Data.define(:name, :versions, :default) do
def self.from_hash(h)
h = h.transform_keys(&:to_s)
@@ -19,6 +20,7 @@ def to_h
end
end
+ # Ordered registry of the agents advertised during session negotiation.
AgentInventory = Data.define(:entries) do
include Enumerable