From db8be7da33dcfdc8f4a475f2bc397599fa3e904f Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Sat, 23 May 2026 23:40:02 -0400 Subject: [PATCH] docs: align session/replay docs with shipped behavior; add YARD + agent-versioning guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous docs and gemspec promised transparent session resume with a SQLite-backed event log. The SDK actually ships in-memory event buffering with replay via `subscribe_job(history: true, from_event_seq: ...)`, and the resume-token wiring is not finished. Update README, CONFORMANCE, architecture, recipes, and the sessions/resume/leases/jobs/troubleshooting guides to describe what we actually ship, and mark §6.3 transparent resume as deferred. Also add YARD-style docstrings to the public Client, Job::Handle, Lease, Runtime, EventLog, and AgentInventory surfaces, refresh the module-deps diagrams, and add a new agent-versioning guide. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONFORMANCE.md | 2 +- README.md | 27 ++++------ arcp.gemspec | 6 +-- docs/architecture.md | 8 +-- docs/diagrams/module-deps-dark.dot | 2 +- docs/diagrams/module-deps-dark.svg | 2 +- docs/diagrams/module-deps-light.dot | 2 +- docs/diagrams/module-deps-light.svg | 2 +- docs/guides/agent-versioning.md | 50 ++++++++++++++++++ docs/guides/jobs.md | 7 +-- docs/guides/leases.md | 3 +- docs/guides/resume.md | 78 ++++++++++++++--------------- docs/guides/sessions.md | 4 +- docs/recipes.md | 72 ++++++++++++++------------ docs/troubleshooting.md | 11 ++-- lib/arcp/client.rb | 21 ++++++++ lib/arcp/job/handle.rb | 6 +++ lib/arcp/lease.rb | 5 ++ lib/arcp/runtime/event_log.rb | 7 ++- lib/arcp/runtime/runtime.rb | 9 ++++ lib/arcp/session/agent_inventory.rb | 2 + 21 files changed, 210 insertions(+), 116 deletions(-) create mode 100644 docs/guides/agent-versioning.md 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