ruby_llm-ag_ui 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8529d394d1ef5d52bc73952e047ee5303634af7aa7738cc894acc8dde378f8b
4
- data.tar.gz: ed676da00af762d17fbf6d1a743b995f03d1e7744aba9afb985b95c20bedf93f
3
+ metadata.gz: fd1d89205c88b89562445e5f654f2f2f931753ac4ff4e2ed4434031ad65251d6
4
+ data.tar.gz: ec70b5e27313a50c8596beaa33c2de9f3b8fc46fab5968b309cc8f626b4a70a0
5
5
  SHA512:
6
- metadata.gz: 34df865e3f9dd71cbf573ad7a89d19259d16931685cb153df6aab6e989d92be68407f6207e654fc44f615b625a840a90a44e6c4e725e5781e976e253d6744457
7
- data.tar.gz: 40c5c7fd607e2ce6ccd54e53e6e6e8c2482bf7645d3155e4cdf0eedb95c58f3337bb481650a44c902863629a52bc70cd03dd4224ecaad2ab0546a947f633a0e1
6
+ metadata.gz: 7526b6e10f776726e8647efa1a47819d4958b0f0e54cb57f65c81968c4929bbf7330a326197adcbc869f1cc3333921d37f8eb74eb088da9f71690cea7a2c6715
7
+ data.tar.gz: ec8fd2a7271e5aa28f8084716a75ebd8d5067a2d7d4e164aa521e8ceeea4b24897397242f1110ef0cd6cd4b5588dd0fb7c25e732c137ea196c5249ef5ece7f28
data/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - Explicit stopped-worker recovery with a `worker_lost` outcome, retaining run
14
+ identity and exposing unknown tool effects for host reconciliation.
15
+ - Atomic storage revisions and independent-process SQLite reservation,
16
+ approval redemption, conflicting-write, and worker-death tests.
17
+ - Exact-run, actor-scoped cancellation and HTTP abort workflows in the offline
18
+ browser example, plus real RubyLLM HTTP timeout and cancellation checkpoints
19
+ verified without a provider connection.
20
+
21
+ ### Fixed
22
+
23
+ - Continue a live run after its response transport disconnects, preserving the
24
+ real outcome and side-effect identity instead of failing the run on a write.
25
+ - Reject stale record writes and preserve uncertain database-write outcomes.
26
+ Disable callbacks even when a producer cannot persist its final record.
27
+ - Keep uncertain reservation and approval acknowledgements separate from run
28
+ failures, without executing a provider turn or guessing whether a decision committed.
29
+ - Recognize Puma connection failures as disconnected transports and classify
30
+ Faraday timeouts as `transport_timeout` instead of generic transport errors.
31
+ - Require JSON 2.x so Rails 8.0 can load a real RubyLLM chat without passing
32
+ a removed `quirks_mode` option to JSON 3.
33
+
9
34
  ## [0.2.0] - 2026-10-19
10
35
 
11
36
  ### Added
data/README.md CHANGED
@@ -19,7 +19,7 @@ RubyLLM and AG-UI maintainers.
19
19
  ## Install
20
20
 
21
21
  ```ruby
22
- gem 'ruby_llm-ag_ui', '~> 0.2.0'
22
+ gem 'ruby_llm-ag_ui', '~> 0.3.0'
23
23
  ```
24
24
 
25
25
  Requires Ruby >= 3.2, RubyLLM >= 2.1 and < 3.0, and `ag-ui-protocol` 0.2.x.
@@ -45,6 +45,17 @@ redacted by default. Approved tools also publish synthetic progress counts;
45
45
  the host policy withholds progress messages. The text, sanitized failure, and cancelled scenarios show
46
46
  the other run outcomes.
47
47
 
48
+ During approved progress, **Cancel server run** requests cancellation for the
49
+ exact fixture actor, thread, and run. The host rejects another actor, an old
50
+ run id, or a completed run. The synthetic tool checks cancellation before its
51
+ effect. This does not establish cancellation latency for a real provider or
52
+ arbitrary tool.
53
+
54
+ **Disconnect stream** calls the client's HTTP abort. The producer keeps running
55
+ and records its actual outcome. Reconnect after it finishes to read that result
56
+ without repeating the tool. An early reconnect is refused while the host still
57
+ owns the active chat. The timeout scenario produces a sanitized recorded failure.
58
+
48
59
  Use **Reconnect completed run** after a successful turn. The client sends the
49
60
  original input and receives the recorded outcome and snapshot; no new model
50
61
  turn or text delta is produced.
@@ -106,8 +106,11 @@ what makes run identity survive a worker:
106
106
 
107
107
  - `reserve` inserts; a `RecordNotUnique` means another worker won, and the
108
108
  existing row is attached or refused by digest.
109
- - `redeem_interrupt` runs under `row.with_lock`, so exactly one resume records a
110
- decision and the tool runs once.
109
+ - `redeem_interrupt` uses a conditional update and revalidates competing
110
+ decisions, so only one caller redeems an approval.
111
+ - `save` checks record revisions and refuses stale writers. Keep its returned
112
+ record for the next update. See [Worker Recovery]({% link _advanced/worker-recovery.md %})
113
+ before closing a run whose producer stopped.
111
114
  - `find_interrupt` is scoped by `(scope, thread_id, interrupt_id)`, so an
112
115
  interrupt id is not a global key.
113
116
 
@@ -0,0 +1,104 @@
1
+ ---
2
+ layout: default
3
+ title: Worker Recovery
4
+ nav_order: 4
5
+ description: Close a run after confirmed worker loss without repeating unknown tool effects.
6
+ ---
7
+
8
+ # {{ page.title }}
9
+
10
+ {{ page.description }}
11
+ {: .fs-6 .fw-300 }
12
+
13
+ After reading this guide, you will know:
14
+
15
+ * How to close a run whose worker has stopped.
16
+ * How record revisions prevent stale writes.
17
+ * Why recovery cannot prove or undo tool effects.
18
+
19
+ ## Recover a Stopped Worker
20
+
21
+ First confirm through your process manager that the producer has stopped.
22
+ Then load its record using an authorized scope and exact identity:
23
+
24
+ ```ruby
25
+ record = store.find(scope: authenticated_scope, thread_id: thread_id, run_id: run_id)
26
+ raise 'Run not found' unless record
27
+
28
+ # The host has confirmed that the producer and its tool threads have stopped.
29
+ recovered = store.recover(record)
30
+ recovered.status # => :failed
31
+ recovered.failure.fetch('code') # => 'worker_lost'
32
+ ```
33
+
34
+ Use `ActiveRecordStore` when the record must survive a process exit. Recovery
35
+ changes a reserved or started record to failed. It retains the identity,
36
+ snapshot, and input digest. The original request can reconnect to a sanitized
37
+ `RUN_ERROR` with code `worker_lost`; it cannot execute the run again.
38
+
39
+ Recovery does not stop a worker. A timeout, disconnected browser, or old
40
+ `updated_at` value does not prove the worker is dead. Confirm that all producer
41
+ and tool activity has stopped before calling this method. The gem does not
42
+ provide leases, worker discovery, or automatic recovery.
43
+
44
+ ## Keep the Latest Record
45
+
46
+ Each successful store write increments `RunRecord#revision`. Keep the record
47
+ returned by `save` if you write it again:
48
+
49
+ ```ruby
50
+ record = store.save(record.start(now: Time.now))
51
+ record = store.save(record.finish(:succeeded, now: Time.now))
52
+ ```
53
+
54
+ `save` and `recover` raise `StaleRecordError` when the stored revision changed
55
+ since you read it. This error is a `RunConflictError`. Reload and inspect the
56
+ current outcome before making another decision. A terminal outcome cannot be
57
+ replaced by recovery. The revision is separate from event sequence numbers.
58
+
59
+ The memory store checks revisions under its mutex. The ActiveRecord store uses
60
+ a conditional database update against the observed payload. Competing approval
61
+ redemptions reload and revalidate after a conflict; a repeated decision is a
62
+ replay, and a disagreeing decision is refused.
63
+
64
+ ## Handle an Uncertain Database Write
65
+
66
+ `Run#stream` raises `PersistenceError` if storage fails without a reliable
67
+ acknowledgement. It does not attempt another terminal write or send a guessed
68
+ `RUN_ERROR`. The first write may already have committed. When storage becomes
69
+ available, reload the exact identity. Reconnect if it ended; confirm the worker
70
+ has stopped before recovering a non-terminal record.
71
+
72
+ This applies to identity reservation and approval redemption as well as status
73
+ writes. If approval acknowledgement is lost, the decision may already be stored
74
+ even though the chat never received it. Inspect both records and reconcile the
75
+ chat; do not issue a new resume or execute the tool on the assumption that the
76
+ decision failed.
77
+
78
+ A stale or failed producer disables its mapper callbacks before returning, so
79
+ reusing its chat cannot append events to that old stream.
80
+
81
+ ## Reconcile External Effects
82
+
83
+ A tool may complete its external change before the worker dies. A recovered
84
+ record says that execution ended without a recorded outcome; it does not say
85
+ that the tool failed or that no effect occurred. Inspect the external system
86
+ and use host-owned idempotency keys before starting another run. Never delete
87
+ the reservation to retry an uncertain effect. The gem does not promise
88
+ exactly-once external effects across crashes.
89
+
90
+ The host also serializes different run ids that share one mutable chat. Run
91
+ identity and approval redemption do not lock an entire conversation.
92
+
93
+ ## Upgrade the Store Contract
94
+
95
+ The revision lives in the JSON payload, so the shipped table needs no new
96
+ migration. Records written before 0.3 load with revision zero. Drain old workers
97
+ before upgrading: a 0.2 writer does not honor the new conditional-write contract.
98
+ Custom stores must implement atomic revision checks, return the incremented
99
+ record from `save`, and provide the same recovery semantics as `Store::Base`.
100
+
101
+ ## Next Steps
102
+
103
+ * [Reconnects and Run Identity]({% link _core_features/reconnects.md %}) - report a recorded outcome.
104
+ * [Security and Deployment]({% link _advanced/security-and-deployment.md %}) - keep authorization in the host.
@@ -0,0 +1,84 @@
1
+ ---
2
+ layout: default
3
+ title: Cancellation and Timeouts
4
+ nav_order: 5
5
+ description: Request cancellation on the authorized producer and distinguish it from a disconnected stream or provider timeout.
6
+ ---
7
+
8
+ # {{ page.title }}
9
+
10
+ {{ page.description }}
11
+ {: .fs-6 .fw-300 }
12
+
13
+ After reading this guide, you will know:
14
+
15
+ * How to request cancellation on the producer's chat.
16
+ * Why aborting a browser request does not cancel server work.
17
+ * How provider timeouts become recorded failures.
18
+
19
+ ## Request Cancellation in the Host
20
+
21
+ Resolve the active chat through your host's authenticated registry, using the
22
+ exact actor, thread, and run identity. The lookup below is application code:
23
+
24
+ ```ruby
25
+ chat = authorized_active_chat_for(current_actor, thread_id: thread_id, run_id: run_id)
26
+ chat.cancel
27
+ ```
28
+
29
+ RubyLLM checks cancellation cooperatively. When it raises `CancelledError`,
30
+ `Run` closes open content and persists a cancelled outcome. The client receives
31
+ `RUN_FINISHED` with `outcome.type` equal to `cancelled`. A cancellation request
32
+ is not proof that the run has stopped; wait for its recorded outcome.
33
+
34
+ Reject a cancellation for another actor, a previous run, or an inactive chat.
35
+ Otherwise a delayed cancellation can stop a later turn. The host serializes
36
+ operations on a mutable chat and delivers cancellation to the worker that owns
37
+ it. Calling `cancel` on a newly loaded chat in another process does not cancel
38
+ the original in-memory object. The gem supplies no cross-worker message broker.
39
+
40
+ ## Keep HTTP Abort Separate
41
+
42
+ `HttpAgent#abortRun()` stops the browser's HTTP subscription. It does not call
43
+ `Chat#cancel`. While the producer remains alive, the gem detaches its failed
44
+ writer and continues to record the actual outcome. Reconnect with the same
45
+ input and run id after completion to read that result.
46
+
47
+ Wire an explicit, authorized cancellation endpoint when your application needs
48
+ server cancellation. The maintained client example shows both actions. See
49
+ [Client Example]({% link _advanced/client-example.md %}).
50
+
51
+ ## Bound Provider Requests
52
+
53
+ Set provider transport limits through RubyLLM's context:
54
+
55
+ ```ruby
56
+ context = RubyLLM.context do |config|
57
+ config.request_timeout = 30
58
+ config.max_retries = 0
59
+ end
60
+ chat = context.chat(model: 'gpt-4o-mini')
61
+ ```
62
+
63
+ A provider request timeout ends the AG-UI run as failed, with a sanitized
64
+ `RUN_ERROR` code `transport_timeout`. Reconnecting reports the same failure
65
+ without a new provider request. A timeout is not a cancelled outcome.
66
+
67
+ The request timeout does not bound an entire multi-request turn or a local
68
+ tool. Provider retries can extend elapsed time. Put deadlines and cancellation
69
+ checkpoints in your tools and reconcile external effects before retrying an
70
+ uncertain action. Cancellation cannot undo a tool that already changed an
71
+ external system.
72
+
73
+ ## Check the Offline Evidence
74
+
75
+ `spec/integration/provider_lifecycle_spec.rb` runs the real RubyLLM chat with an
76
+ intercepted HTTP request. It verifies timeout classification and cancellation
77
+ before a provider request. It uses synthetic configuration and makes no live
78
+ provider call. Browser fixtures separately exercise host cancellation and
79
+ HTTP abort; they do not establish a provider's cancellation latency.
80
+
81
+ ## Next Steps
82
+
83
+ * [Streaming Runs]({% link _core_features/streaming-runs.md %}) - understand terminal outcomes.
84
+ * [Worker Recovery]({% link _advanced/worker-recovery.md %}) - close a run after confirmed worker death.
@@ -37,10 +37,16 @@ run id is a conflict.
37
37
 
38
38
  ## Choose a Store
39
39
 
40
- `MemoryStore` is suitable for tests and a single process. Its records disappear on restart and are not shared between workers. The Rails adapter's `ActiveRecordStore` persists records and redeems interrupts under a database lock.
40
+ `MemoryStore` is suitable for tests and a single process. Its records disappear on restart and are not shared between workers. The Rails adapter's `ActiveRecordStore` persists records and redeems interrupts with atomic conditional updates.
41
41
 
42
42
  The store boundary is `RubyLLM::AgUI::Store::Base`. Configure a compatible store globally or pass it to `Run.new`. For multi-process deployments, the database must enforce unique run identity and serialize interrupt redemption.
43
43
 
44
+ Store writes check `RunRecord#revision` and refuse stale snapshots. A run whose
45
+ worker died remains non-terminal until the host confirms the worker stopped
46
+ and explicitly recovers it. See
47
+ [Worker Recovery]({% link _advanced/worker-recovery.md %}) before changing a
48
+ stuck record; deleting its reservation can repeat unknown tool effects.
49
+
44
50
  ## Next Steps
45
51
 
46
52
  * [Configuration]({% link _getting_started/configuration.md %}) - set the default store.
@@ -42,9 +42,23 @@ An assistant message opens on its first non-empty chunk and closes before a tool
42
42
 
43
43
  The run record reaches a terminal state before the terminal event is written. If the connection closes in the middle, the record remains available for a later reconnect.
44
44
 
45
+ ## When the Client Disconnects
46
+
47
+ The producer detaches a failed transport and continues the same chat turn while
48
+ its worker stays alive. A broken connection does not cancel the provider or
49
+ undo a tool. The store retains the real success, failure, approval pause, or
50
+ cancelled outcome. A retry while the turn is running is refused; after it ends,
51
+ the same input and run id receive the recorded outcome without execution.
52
+
53
+ This also applies when the first write or a flush fails. A terminal write
54
+ failure cannot replace an outcome that is already persisted. `SSEWriter` used
55
+ directly raises `TransportError`, a subclass of `StreamError`; `Run` handles
56
+ that transport error. Mapping and persistence errors are separate failures.
57
+
45
58
  ## Next Steps
46
59
 
47
60
  * [Validating Input]({% link _core_features/input-validation.md %}) - parse untrusted request data.
48
61
  * [Tool Progress]({% link _core_features/tool-progress.md %}) - publish local tool updates.
62
+ * [Cancellation and Timeouts]({% link _core_features/cancellation.md %}) - connect host cancellation to the active chat.
49
63
  * [Approval and Resume]({% link _core_features/approval-and-resume.md %}) - answer an interrupt on a new run.
50
64
  * [Reconnects and Run Identity]({% link _core_features/reconnects.md %}) - serve a completed run again.
@@ -0,0 +1,61 @@
1
+ ---
2
+ layout: default
3
+ title: "What's New in 0.3"
4
+ nav_order: -1
5
+ description: Verify transport failure, scoped cancellation, and explicit worker recovery.
6
+ ---
7
+
8
+ # {{ page.title }}
9
+
10
+ {{ page.description }}
11
+ {: .fs-6 .fw-300 }
12
+
13
+ After reading this guide, you will know:
14
+
15
+ * How a run continues after its client disconnects.
16
+ * How to reconnect without executing a turn twice.
17
+ * How to recover after confirmed worker loss.
18
+ * How host cancellation differs from HTTP abort and provider timeout.
19
+
20
+ ## Keep the Recorded Outcome
21
+
22
+ ```ruby
23
+ record = RubyLLM::AgUI::Run.new(chat: chat, input: input, scope: scope,
24
+ store: store).stream(to: response_stream)
25
+ ```
26
+
27
+ If the response transport disconnects, the worker continues the same turn and
28
+ persists its real outcome. A failed terminal write cannot change an already
29
+ recorded result. This is not a background job: worker termination still stops
30
+ execution. See [Streaming Runs]({% link _core_features/streaming-runs.md %}).
31
+
32
+ ## Reconnect with the Same Input
33
+
34
+ Resend the original input and run id after completion. The store returns its
35
+ recorded outcome without running the chat or tool again. A second stream while
36
+ the original is still running is refused. See
37
+ [Reconnects and Run Identity]({% link _core_features/reconnects.md %}).
38
+
39
+ ## Recover Lost Workers Explicitly
40
+
41
+ After confirming a worker has stopped, load its authorized record and call
42
+ `store.recover(record)`. The recorded failure uses code `worker_lost` and keeps
43
+ the identity reserved. Stale record writes are refused by revision checks.
44
+ Storage acknowledgement failures propagate as `PersistenceError` rather than
45
+ inventing a terminal outcome. See
46
+ [Worker Recovery]({% link _advanced/worker-recovery.md %}).
47
+
48
+ ## Cancel the Authorized Run
49
+
50
+ The client example now shows separate controls for host cancellation and HTTP
51
+ abort. Its cancel endpoint checks the fixture actor and exact run identity;
52
+ disconnecting the browser leaves the producer running. RubyLLM's HTTP timeout
53
+ is recorded as `transport_timeout`, without raw provider details. See
54
+ [Cancellation and Timeouts]({% link _core_features/cancellation.md %}).
55
+
56
+ Version 0.3 adds independent-process SQLite tests, including a producer killed
57
+ after a synthetic tool effect.
58
+ These tests do not establish live provider or other-database behavior.
59
+
60
+ Before updating, read [Upgrading]({% link _reference/upgrading.md %}) for worker
61
+ draining, the custom-store contract, and the JSON 2.x dependency requirement.
@@ -23,10 +23,11 @@ dependency does.
23
23
 
24
24
  | Component | Range | Evidence |
25
25
  |---|---|---|
26
- | Ruby | `>= 3.2` | CI passes on 3.2.11, 3.3.12, and 3.4.11; 3.3.5 checked locally |
26
+ | Ruby | `>= 3.2` | Recorded 0.3 CI passes on 3.2.11, 3.3.12, and 3.4.11 |
27
27
  | RubyLLM | `>= 2.1`, `< 3.0` | 2.1.1 (callback, approval and cancellation behavior read from its source) |
28
28
  | `ag-ui-protocol` | `~> 0.2.0` | 0.2.0 |
29
- | Rails (adapter only) | `railties`, `actionpack`, `activerecord` `>= 8.0` | CI passes on 8.0.5.1 and 8.1.4 with Ruby 3.3/3.4; 8.1.4 checked locally |
29
+ | JSON | `>= 2.0`, `< 3` | 2.21.2 passes both Rails lines; JSON 3 removes the `quirks_mode` option used by Rails 8.0 |
30
+ | Rails (adapter only) | `railties`, `actionpack`, `activerecord` `>= 8.0` | Recorded 0.3 CI passes on 8.0.5.1 and 8.1.4 with Ruby 3.3/3.4 |
30
31
  | AG-UI schema | pinned commit `89cedd3fd549feeee41e50e1d7c6d373e549241a` | vendored, SHA-256 recorded in [AG-UI Protocol Coverage]({% link _reference/protocol-coverage.md %}) |
31
32
 
32
33
  The pure-Ruby core uses `ruby_llm` and `ag-ui-protocol`, plus the patched
@@ -53,7 +54,42 @@ adapter is only loaded when `Rails::Railtie` is already defined.
53
54
 
54
55
  The RSpec suite runs offline with fakes and no provider credentials. Package verification uses the network only to install dependencies, then runs its core and Rails checks without a provider. Its advisory check includes the actual default-library gem versions activated by the isolated core install.
55
56
 
56
- ## Recorded Client Verification
57
+ ## Local 0.3 Development Verification
58
+
59
+ On 2026-10-19, Ruby 3.3.5, RubyLLM 2.1.1, and Rails 8.1.4 pass 284 examples
60
+ with zero failures and one opt-in provider example skipped. The 18 browser
61
+ workflows pass with `@ag-ui/client` 1.0.3, Playwright 1.64.1, and Puma 8.0.2;
62
+ all 242 consumed events validate against the pinned schema. Desktop disconnect
63
+ and 390px cancellation screenshots were inspected.
64
+
65
+ Independent Ruby processes share one SQLite file to check reservations,
66
+ approval decisions, and competing outcome writes. Write barriers force both
67
+ processes to observe the old payload before either updates it. A producer
68
+ killed after a synthetic tool effect is explicitly recovered; reconnect never
69
+ executes the tool again. Mutation checks reject removal of either SQL compare
70
+ conditions or revision validation.
71
+
72
+ The real RubyLLM chat is also checked with intercepted HTTP: cancellation at
73
+ a checkpoint makes no provider request, and a request timeout produces a
74
+ sanitized, reconnectable failure. These tests make no live provider call.
75
+ Installed-host checks pass for both endpoints and generated Rails models.
76
+ ## Recorded 0.3 CI Verification
77
+
78
+ The [2026-10-20 CI run](https://github.com/yorzi/ruby_llm-ag-ui/actions/runs/39375830117)
79
+ passes all ten jobs at `aa9e035`: Ruby 3.2.11, 3.3.12, and 3.4.11; Rails
80
+ 8.0.5.1 and 8.1.4 on Ruby 3.3/3.4; lint; installed-host checks; and the browser
81
+ suite on Ubuntu 24.04. Each test job passes 284 examples with zero failures
82
+ and one opt-in provider skip. The browser suite passes 18 workflows and
83
+ validates all 242 consumed events with `@ag-ui/client` 1.0.3.
84
+ It uses Playwright 1.64.1 and Chromium 156.0.8078.4. Desktop disconnection and
85
+ 390px cancellation screenshots from this run were inspected.
86
+
87
+ The first candidate failed when Rails 8.0 and JSON 3 loaded a real RubyLLM
88
+ chat. Version 0.3 bounds JSON to 2.x; the passing matrix uses 2.21.2. Intercepted
89
+ HTTP tests reproduce the failure before the bound and pass after it. These
90
+ checks make no live provider call.
91
+
92
+ ## Recorded 0.2 Client Verification
57
93
 
58
94
  On 2026-10-19, the local 0.2.0 candidate passes the maintained browser workflows
59
95
  with `@ag-ui/client` 1.0.3, Playwright 1.64.1, and Chrome 155.0.8059.82 on macOS.
@@ -79,6 +115,12 @@ scenarios, with 154 consumed events validated against the schema. Its desktop
79
115
  progress and mobile denial screenshots were also inspected. These remain
80
116
  synthetic checks; no live provider session is recorded.
81
117
 
118
+ The [0.2.0 release workflow](https://github.com/yorzi/ruby_llm-ag-ui/actions/runs/39180349362)
119
+ also passes browser, advisory, installed-host, and clean-install verification.
120
+ The published GitHub and RubyGems downloads have matching checksums. The actual
121
+ published artifact separately passes the installed-host and clean core/Rails
122
+ checks after download.
123
+
82
124
  ## What is not asserted
83
125
 
84
126
  - **Other clients or versions.** CopilotKit and client versions other than the
@@ -94,8 +136,8 @@ synthetic checks; no live provider session is recorded.
94
136
  initializer, and its specs run on 8.0/8.1. It may work on 7.2 and it is not
95
137
  tested there.
96
138
  - **Databases other than SQLite** for the ActiveRecord store. The store uses a
97
- unique index and `with_lock`; PostgreSQL and MySQL should behave the same, and
98
- that is untested.
139
+ unique index and conditional payload updates. PostgreSQL and MySQL are
140
+ untested; the SQLite checks do not establish their concurrency behavior.
99
141
 
100
142
  ## Deliberate behavior worth knowing
101
143
 
@@ -17,7 +17,8 @@ description: Review RubyLLM AG-UI releases, newest first, with highlights and up
17
17
 
18
18
  | Release | Date | Highlights |
19
19
  | --- | --- | --- |
20
- | [0.2.0](https://github.com/yorzi/ruby_llm-ag-ui/releases/tag/v0.2.0) | 2026-10-19 | [Client workflows and tool progress]({% link _getting_started/whats-new-in-0-2.md %}): opt-in redacted progress, a real-client demo, and completed resume reconnect |
20
+ | [0.3.0](https://github.com/yorzi/ruby_llm-ag-ui/releases/tag/v0.3.0) | 2026-10-20 | [Lifecycle and worker recovery]({% link _getting_started/whats-new-in-0-3.md %}): disconnect handling, scoped cancellation, and conditional durable writes |
21
+ | [0.2.0](https://github.com/yorzi/ruby_llm-ag-ui/releases/tag/v0.2.0) | 2026-10-19 | [Client workflows and tool progress]({% link _reference/whats-new-in-0-2.md %}): opt-in redacted progress, a real-client demo, and completed resume reconnect |
21
22
  | [0.1.0](https://github.com/yorzi/ruby_llm-ag-ui/releases/tag/v0.1.0) | 2026-10-18 | [First release]({% link _reference/whats-new-in-0-1.md %}): AG-UI event mapping, approval and resume, cancellation, reconnect, and the optional Rails adapter |
22
23
 
23
24
  To move an application from one release to the next, follow [Upgrading]({% link _reference/upgrading.md %}).
@@ -67,3 +67,13 @@ Replace the example tag with the version you are releasing. The `Release Gem` wo
67
67
  On success, the workflow creates a GitHub Release with the gem, checksum, and CHANGELOG notes. The Docs workflow runs after the release workflow succeeds. It publishes the tagged version at `/` and current `main` at `/next/`.
68
68
 
69
69
  Check the version on RubyGems, the GitHub Release assets, and the documentation installation command. If a publish attempt fails, inspect its logs before retrying. A published RubyGems version cannot be overwritten.
70
+
71
+ Download the published gem from both RubyGems and the GitHub Release. Compare
72
+ their SHA-256 hashes with the release's `SHA256SUMS` and RubyGems metadata.
73
+ Verify that its packaged files match the tag. The publish job builds its own
74
+ artifact, so a verification job's earlier hash is not proof of the public bytes.
75
+
76
+ Place the downloaded gem under `pkg/` and repeat the installed-host check with
77
+ `--built`, followed by `bin/verify-package --rails --audit`. Do not rebuild the
78
+ gem between downloading and checking it. Confirm the rendered stable and
79
+ `/next/` guides, API links, and version menu after the Docs deployment succeeds.
@@ -15,33 +15,42 @@ After reading this guide, you will know:
15
15
  * How to move between versioned releases.
16
16
  * Where to find changes that need code updates.
17
17
 
18
- ## Move from 0.1 to 0.2
18
+ ## Move from 0.2 to 0.3
19
19
 
20
- Change your Gemfile and update the dependency:
20
+ Drain your old workers, change your Gemfile, and update the dependency:
21
21
 
22
22
  ```ruby
23
- gem 'ruby_llm-ag_ui', '~> 0.2.0'
23
+ gem 'ruby_llm-ag_ui', '~> 0.3.0'
24
24
  ```
25
25
 
26
26
  ```sh
27
27
  bundle update ruby_llm-ag_ui
28
28
  ```
29
29
 
30
- The 0.2 release keeps the 0.1 run, store, and Rails APIs. It does not require
31
- another migration or installer run.
32
-
33
- Tool progress is off by default. Set `config.emit_tool_progress = true` only
34
- when your client handles activity snapshots, and review its redaction policy.
35
- See [Tool Progress]({% link _core_features/tool-progress.md %}).
36
-
37
- ## Completed Resume Retries in 0.2
38
-
39
- An exact retry of a completed resume run now returns its recorded outcome.
40
- Keep the original run id and full input, including `resume`. A new run id that
41
- tries to answer an already answered interrupt is still refused, and changed
42
- input under the original id is a conflict. No approval or tool is executed again.
43
-
44
- ## Older Development Checkouts
45
-
46
- For the initial release's generator namespace correction, read the
47
- [0.1 upgrading guide](https://github.com/yorzi/ruby_llm-ag-ui/blob/v0.1.0/docs/_reference/upgrading.md).
30
+ Version 0.3 requires JSON 2.x. Rails 8.0 passes an option removed in JSON 3
31
+ when RubyLLM loads its chat class. If your Gemfile pins JSON 3, remove that pin
32
+ or select JSON 2.x before updating. This bound also applies to plain Ruby hosts.
33
+
34
+ `Run` now continues after a response write or flush fails. It saves the real
35
+ outcome instead of reporting the disconnected client as a failed run. Hosts
36
+ that previously relied on disconnects to stop work must explicitly request
37
+ cancellation on the authorized chat. `SSEWriter` still raises a `StreamError`
38
+ subclass when used directly. See
39
+ [Streaming Runs]({% link _core_features/streaming-runs.md %}).
40
+
41
+ Store writes now use an atomic revision check. Always retain the record returned
42
+ by `save`. Custom stores must implement this contract before upgrading. Existing
43
+ JSON payloads load with revision zero and the shipped table needs no migration.
44
+ Drain old workers first, since 0.2 writers do not enforce revision checks.
45
+ `PersistenceError` now propagates storage failures without guessing a terminal
46
+ outcome. Read [Worker Recovery]({% link _advanced/worker-recovery.md %}) for
47
+ explicit recovery and external-effect reconciliation.
48
+
49
+ Faraday request timeouts now use `transport_timeout` instead of the generic
50
+ `transport_error` code. Both remain failed outcomes. See
51
+ [Cancellation and Timeouts]({% link _core_features/cancellation.md %}).
52
+
53
+ ## Older Releases
54
+
55
+ For earlier upgrade steps, read the
56
+ [0.2 upgrading guide](https://github.com/yorzi/ruby_llm-ag-ui/blob/v0.2.0/docs/_reference/upgrading.md).
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  layout: default
3
3
  title: "What's New in 0.2"
4
- nav_order: 0
4
+ nav_order: 2
5
+ parent: Release History
5
6
  description: Verify real AG-UI client workflows and opt in to redacted tool progress.
6
7
  ---
7
8
 
@@ -94,7 +94,11 @@ module RubyLLM
94
94
  private
95
95
 
96
96
  def emit(event)
97
+ return if @detached
98
+
97
99
  @writer.write(event)
100
+ rescue TransportError
101
+ @detached = true
98
102
  end
99
103
 
100
104
  # Closes whatever content is still open, then writes the terminal event.
@@ -108,7 +112,7 @@ module RubyLLM
108
112
  end
109
113
 
110
114
  def persist(record)
111
- @store.save(record)
115
+ Persistence.write { @store.save(record) }
112
116
  end
113
117
 
114
118
  def now
@@ -37,6 +37,10 @@ module RubyLLM
37
37
  end
38
38
  end
39
39
 
40
+ # An observed run was changed by another writer. Reload before deciding
41
+ # whether an update is still valid; never overwrite the newer outcome.
42
+ class StaleRecordError < RunConflictError; end
43
+
40
44
  # Raised when run identity is required but missing.
41
45
  class InvalidRunIdentityError < InvalidInputError; end
42
46
 
@@ -56,10 +60,17 @@ module RubyLLM
56
60
  # Raised when the pinned protocol version is not the one this gem speaks.
57
61
  class UnsupportedProtocolVersionError < InputError; end
58
62
 
59
- # Raised when the stream cannot be closed in order (for example the
60
- # transport died mid-run). The run is reported as RUN_ERROR.
63
+ # Raised when run or stream ordering cannot be honored.
61
64
  class StreamError < Error; end
62
65
 
66
+ # The response transport is closed. A running producer detaches its writer
67
+ # and continues to persist the real outcome while its worker remains alive.
68
+ class TransportError < StreamError; end
69
+
70
+ # A store write failed without a reliable acknowledgement. The persisted
71
+ # outcome may be unknown; inspect storage before attempting recovery.
72
+ class PersistenceError < Error; end
73
+
63
74
  # Raised when RubyLLM paused the run for something the pinned AG-UI shape
64
75
  # cannot express as an interrupt this producer may answer, such as an MCP
65
76
  # input request or a long-running MCP task. Reported as a sanitized
@@ -40,6 +40,7 @@ module RubyLLM
40
40
  ],
41
41
  'RubyLLM::AgUI::StreamError' => ['The run could not be streamed to completion.', 'stream_failed'],
42
42
  'RubyLLM::Error' => ['The model provider returned an error.', 'provider_error'],
43
+ 'Faraday::TimeoutError' => ['The model provider request timed out.', 'transport_timeout'],
43
44
  'Faraday::Error' => ['The model provider request failed.', 'transport_error'],
44
45
  'Timeout::Error' => ['The model provider request timed out.', 'transport_timeout'],
45
46
  'Errno::ETIMEDOUT' => ['The model provider request timed out.', 'transport_timeout']
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Keep uncertain write acknowledgements separate from run outcomes.
6
+ module Persistence # :nodoc:
7
+ module_function
8
+
9
+ def write
10
+ yield
11
+ rescue Error
12
+ raise
13
+ rescue StandardError => e
14
+ raise PersistenceError, "The store write could not be acknowledged: #{e.class}"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -50,10 +50,8 @@ module RubyLLM
50
50
  def save(record)
51
51
  scope_key(record.scope)
52
52
  row = row_for(record.scope, record.thread_id, record.run_id)
53
- raise InvalidRunIdentityError, "run #{record.run_id} was never reserved" if row.nil?
54
- raise RunConflictError, conflict_message(record) if row.digest != record.digest
55
-
56
- row.write_run_record(record)
53
+ updated = next_record(row&.as_run_record, record)
54
+ row.write_run_record(updated)
57
55
  end
58
56
 
59
57
  def find_interrupt(scope:, thread_id:, interrupt_id:)
@@ -62,21 +60,34 @@ module RubyLLM
62
60
  row_for_interrupt(scope, thread_id, interrupt_id)&.as_run_record&.interrupt(interrupt_id)
63
61
  end
64
62
 
65
- # Records +decision+ on the interrupt under a row lock, so two resumes
66
- # that arrive together cannot both record a decision and run the tool.
63
+ # Records +decision+ with a conditional update. Competing writers reload
64
+ # and revalidate, so only one can redeem the approval.
67
65
  def redeem_interrupt(scope:, thread_id:, interrupt_id:, decision:, now:)
68
66
  unless Interrupt::DECISIONS.include?(decision)
69
67
  raise ConfigurationError, "decision must be one of #{Interrupt::DECISIONS.join(', ')}"
70
68
  end
71
69
 
72
- row = row_for_interrupt(scope, thread_id, interrupt_id)
73
- raise ResumeRejectedError, "no interrupt #{interrupt_id}" if row.nil?
70
+ retry_stale do
71
+ row = row_for_interrupt(scope, thread_id, interrupt_id)
72
+ raise ResumeRejectedError, "no interrupt #{interrupt_id}" if row.nil?
74
73
 
75
- row.with_lock { redeem(row.reload.as_run_record, interrupt_id, decision, now) }
74
+ redeem(row.as_run_record, interrupt_id, decision, now)
75
+ end
76
76
  end
77
77
 
78
78
  private
79
79
 
80
+ def retry_stale
81
+ attempts = 0
82
+ begin
83
+ yield
84
+ rescue StaleRecordError
85
+ attempts += 1
86
+ retry if attempts < 3
87
+ raise
88
+ end
89
+ end
90
+
80
91
  # A retry of the same identity attaches; a different input under the
81
92
  # same key is a conflict, because one run id may not mean two requests.
82
93
  def attach(row, digest)
@@ -91,8 +102,7 @@ module RubyLLM
91
102
  raise ResumeRejectedError, "interrupt #{interrupt_id} expired" if interrupt.expired?(now)
92
103
 
93
104
  answered = interrupt.decide(decision, now: now)
94
- row = row_for(record.scope, record.thread_id, record.run_id)
95
- row.write_run_record(record.replace_interrupt(answered, now: now))
105
+ save(record.replace_interrupt(answered, now: now))
96
106
  Redemption.new(interrupt: answered, replayed: false)
97
107
  end
98
108
 
@@ -28,13 +28,18 @@ module RubyLLM
28
28
  ::RubyLLM::AgUI::RunRecord.from_storage(::JSON.parse(payload))
29
29
  end
30
30
 
31
- # Writes +record+ onto this row and returns the record.
31
+ # Writes +record+ only if this row's observed payload is still current.
32
+ # Returns the record or raises StaleRecordError on a competing update.
32
33
  #
33
34
  # The identity columns are not writable: a row keeps the identity it was
34
35
  # reserved with, so a record cannot be stored under another run's key.
35
36
  def write_run_record(record)
36
- update!(digest: record.digest, status: record.status.to_s, sequence: record.sequence,
37
- payload: ::JSON.generate(record.to_storage))
37
+ changed = self.class.where(id: id, payload: payload).update_all(
38
+ digest: record.digest, status: record.status.to_s, sequence: record.sequence,
39
+ payload: ::JSON.generate(record.to_storage)
40
+ )
41
+ raise StaleRecordError, "run #{record.run_id} changed before it could be saved" unless changed == 1
42
+
38
43
  record
39
44
  end
40
45
 
@@ -45,9 +45,9 @@ module RubyLLM
45
45
  include ::ActionController::Live
46
46
  end
47
47
 
48
- # Streams one run and returns the run's record, or nil when the client
49
- # disconnected. The block receives the parsed input and returns the chat
50
- # for it.
48
+ # Streams one run and returns its record. A disconnected transport does
49
+ # not stop its worker from completing and storing the real outcome.
50
+ # The block receives the parsed input and returns the authorized chat.
51
51
  def stream_ag_ui_run(&resolve_chat)
52
52
  if resolve_chat.nil?
53
53
  raise ConfigurationError, 'stream_ag_ui_run needs a block that returns the chat for the input'
@@ -88,15 +88,19 @@ module RubyLLM
88
88
  # The store's redemption is atomic: the first answer record wins, and a
89
89
  # repeat comes back as a replay that must not execute anything.
90
90
  def redeem(planned, now:)
91
- redemption = @store.redeem_interrupt(
92
- scope: @scope, thread_id: @input.thread_id,
93
- interrupt_id: planned.interrupt.id, decision: planned.decision, now: now
94
- )
91
+ redemption = persist_decision(planned, now: now)
95
92
  raise ResumeRejectedError, "interrupt #{planned.interrupt.id} was already answered" if redemption.replayed?
96
93
 
97
94
  redemption.approved? ? @chat.approve(redemption.tool_call_id) : @chat.deny(redemption.tool_call_id)
98
95
  end
99
96
 
97
+ def persist_decision(planned, now:)
98
+ Persistence.write do
99
+ @store.redeem_interrupt(scope: @scope, thread_id: @input.thread_id,
100
+ interrupt_id: planned.interrupt.id, decision: planned.decision, now: now)
101
+ end
102
+ end
103
+
100
104
  # The protocol's resume vocabulary is resolved or cancelled; the interrupt
101
105
  # records the truthful decision, and a cancelled answer denies the call
102
106
  # rather than approving it.
@@ -64,9 +64,9 @@ module RubyLLM
64
64
  end
65
65
 
66
66
  def reserve
67
- @store.reserve(
68
- scope: @scope, thread_id: @input.thread_id, run_id: @input.run_id, digest: @input.digest
69
- )
67
+ Persistence.write do
68
+ @store.reserve(scope: @scope, thread_id: @input.thread_id, run_id: @input.run_id, digest: @input.digest)
69
+ end
70
70
  end
71
71
 
72
72
  # An identity that is already reserved is never executed a second time.
@@ -92,8 +92,12 @@ module RubyLLM
92
92
  @record = finalize { finish }
93
93
  rescue RubyLLM::CancelledError
94
94
  @record = finalize { @emitter.cancel }
95
+ rescue PersistenceError, RunConflictError
96
+ raise
95
97
  rescue StandardError => e
96
98
  @record = finalize { @emitter.fail(e) }
99
+ ensure
100
+ finalize { @mapper&.close }
97
101
  end
98
102
 
99
103
  def start_execution(record, to)
@@ -104,8 +108,8 @@ module RubyLLM
104
108
 
105
109
  def build_emitter(record, to, mapper: build_mapper(record))
106
110
  Emitter.new(
107
- writer: SSEWriter.new(to, logger: @logger), mapper: mapper,
108
- store: @store, record: record, clock: @clock, logger: @logger
111
+ writer: SSEWriter.new(to, logger: @logger), mapper: mapper, store: @store,
112
+ record: record, clock: @clock, logger: @logger
109
113
  )
110
114
  end
111
115
 
@@ -164,8 +168,7 @@ module RubyLLM
164
168
 
165
169
  def run_started_event(record)
166
170
  Protocol.run_started(
167
- thread_id: record.thread_id, run_id: record.run_id,
168
- parent_run_id: @input.parent_run_id, input: @input.to_h
171
+ thread_id: record.thread_id, run_id: record.run_id, parent_run_id: @input.parent_run_id, input: @input.to_h
169
172
  )
170
173
  end
171
174
 
@@ -175,13 +178,9 @@ module RubyLLM
175
178
  end
176
179
  raise ConfigurationError, 'input must be a RubyLLM::AgUI::Input' unless @input.is_a?(Input)
177
180
 
178
- validate_scope!
179
- end
180
-
181
- def validate_scope!
182
181
  return if @scope.is_a?(String) && !@scope.empty?
183
182
 
184
- raise ConfigurationError, 'scope must be a non-empty String built from authenticated identity'
183
+ raise ConfigurationError, Store::Base::SCOPE_MESSAGE
185
184
  end
186
185
  end
187
186
  end
@@ -52,6 +52,10 @@ module RubyLLM
52
52
  # reconnect are ordered by it rather than by wall-clock time.
53
53
  attr_reader :sequence
54
54
 
55
+ # Storage revision, distinct from the event sequence. Stores increment it
56
+ # atomically and reject writes from older snapshots.
57
+ attr_reader :revision
58
+
55
59
  # The host-owned run state kept for a reconnect snapshot, or nil.
56
60
  attr_reader :snapshot
57
61
 
@@ -67,13 +71,14 @@ module RubyLLM
67
71
 
68
72
  # rubocop:disable-next Metrics/ParameterLists -- one keyword per field the record owns
69
73
  def initialize(scope:, thread_id:, run_id:, digest:, created_at:, status: RESERVED, sequence: 0,
70
- snapshot: nil, interrupts: [], failure: nil, updated_at: nil)
74
+ snapshot: nil, interrupts: [], failure: nil, updated_at: nil, revision: 0)
71
75
  @scope = scope
72
76
  @thread_id = thread_id
73
77
  @run_id = run_id
74
78
  @digest = digest
75
79
  @status = assert_status!(status)
76
80
  @sequence = sequence
81
+ @revision = revision
77
82
  @snapshot = snapshot
78
83
  @interrupts = interrupts.freeze
79
84
  @failure = failure
@@ -122,6 +127,11 @@ module RubyLLM
122
127
  with(now: now, sequence: sequence + 1)
123
128
  end
124
129
 
130
+ # Storage bookkeeping; hosts retain the record returned by save.
131
+ def next_revision # :nodoc:
132
+ with(now: updated_at, revision: revision + 1)
133
+ end
134
+
125
135
  # Closes the run with +status+: :succeeded, :interrupted, :cancelled or
126
136
  # :failed, and, for a failure, the sanitized error a reconnect reports.
127
137
  # Re-applying the same terminal status is an idempotent no-op — so the
@@ -158,6 +168,7 @@ module RubyLLM
158
168
  with(now: now, interrupts: replaced)
159
169
  end
160
170
 
171
+ # rubocop:disable-next Metrics/AbcSize -- one entry per serialized record field
161
172
  def to_h
162
173
  {
163
174
  'scope' => scope.to_s,
@@ -166,6 +177,7 @@ module RubyLLM
166
177
  'digest' => digest,
167
178
  'status' => status.to_s,
168
179
  'sequence' => sequence,
180
+ 'revision' => revision,
169
181
  'snapshot' => snapshot,
170
182
  'failure' => failure,
171
183
  'interrupts' => interrupts.map(&:to_h),
@@ -186,7 +198,8 @@ module RubyLLM
186
198
  new(
187
199
  scope: hash.fetch('scope'), thread_id: hash.fetch('threadId'), run_id: hash.fetch('runId'),
188
200
  digest: hash.fetch('digest'), status: hash.fetch('status').to_sym,
189
- sequence: hash.fetch('sequence'), snapshot: hash['snapshot'], failure: hash['failure'],
201
+ sequence: hash.fetch('sequence'), revision: hash.fetch('revision', 0),
202
+ snapshot: hash['snapshot'], failure: hash['failure'],
190
203
  interrupts: interrupts_from_storage(hash['interrupts']),
191
204
  created_at: Interrupt.parse_time(hash.fetch('createdAt')),
192
205
  updated_at: Interrupt.parse_time(hash.fetch('updatedAt'))
@@ -201,10 +214,10 @@ module RubyLLM
201
214
 
202
215
  # rubocop:disable-next Metrics/ParameterLists -- one keyword per field, each defaulted to the record's own
203
216
  def with(now:, status: self.status, sequence: self.sequence, snapshot: self.snapshot,
204
- interrupts: self.interrupts, failure: self.failure)
217
+ interrupts: self.interrupts, failure: self.failure, revision: self.revision)
205
218
  self.class.new(
206
219
  scope: scope, thread_id: thread_id, run_id: run_id, digest: digest, status: status,
207
- sequence: sequence, snapshot: snapshot, interrupts: interrupts, failure: failure,
220
+ sequence: sequence, revision: revision, snapshot: snapshot, interrupts: interrupts, failure: failure,
208
221
  created_at: created_at, updated_at: now
209
222
  )
210
223
  end
@@ -10,7 +10,7 @@ module RubyLLM
10
10
  # buffer frames or choose how a host relays a background job to a client.
11
11
  # What it does own is the ordering and the framing of the events it writes.
12
12
  #
13
- # A dead stream is reported as a StreamError and the writer stays closed.
13
+ # A dead stream is reported as a TransportError and the writer stays closed.
14
14
  # The reason is a transport truncation, not a run outcome: it is never
15
15
  # evidence that the work finished, succeeded or was cancelled.
16
16
  class SSEWriter
@@ -82,7 +82,7 @@ module RubyLLM
82
82
  # Encodes +event+ and writes it, then flushes so the client sees it now.
83
83
  # Returns the encoded frame.
84
84
  def write(event)
85
- raise StreamError, 'The event stream is closed.' if @closed
85
+ raise TransportError, 'The event stream is closed.' if @closed
86
86
 
87
87
  frame = @encoder.encode(event)
88
88
  @stream.write(frame)
@@ -90,12 +90,14 @@ module RubyLLM
90
90
  @events_written += 1
91
91
  @bytes_written += frame.bytesize
92
92
  frame
93
- rescue IOError, SystemCallError => e
93
+ rescue StandardError => e
94
+ raise unless transport_error?(e)
95
+
94
96
  # The client is gone. The class name is enough for the host log; the
95
97
  # message may embed a path or a socket address and is not echoed.
96
98
  @logger&.warn("[ruby_llm_ag_ui] the event stream failed: #{e.class}")
97
99
  @closed = true
98
- raise StreamError, "The event stream failed: #{e.class}"
100
+ raise TransportError, "The event stream failed: #{e.class}"
99
101
  end
100
102
 
101
103
  # Pushes buffered bytes to the client. A no-op for a stream that cannot
@@ -114,6 +116,16 @@ module RubyLLM
114
116
  def closed?
115
117
  @closed
116
118
  end
119
+
120
+ private
121
+
122
+ def transport_error?(error)
123
+ return true if error.is_a?(IOError) || error.is_a?(SystemCallError)
124
+ return true if defined?(::Puma::ConnectionError) && error.is_a?(::Puma::ConnectionError)
125
+
126
+ defined?(::ActionController::Live::ClientDisconnected) &&
127
+ error.is_a?(::ActionController::Live::ClientDisconnected)
128
+ end
117
129
  end
118
130
  end
119
131
  end
@@ -63,11 +63,24 @@ module RubyLLM
63
63
  raise NotImplementedError
64
64
  end
65
65
 
66
- # Persists an updated record and returns it.
66
+ # Atomically persists a record only if its revision is still current.
67
+ # Returns a new record with its revision incremented. Retain this return
68
+ # value for later writes. A stale write raises StaleRecordError.
67
69
  def save(record)
68
70
  raise NotImplementedError
69
71
  end
70
72
 
73
+ # Closes an observed non-terminal run after the host confirms its worker
74
+ # has stopped. Does not cancel a worker, undo effects, release identity,
75
+ # or execute anything. A newer revision or terminal run is refused.
76
+ def recover(record)
77
+ raise RunConflictError, 'only a non-terminal run can be recovered' if record.terminal?
78
+
79
+ failure = { 'code' => 'worker_lost',
80
+ 'message' => 'The worker stopped before recording an outcome. Tool effects may be incomplete.' }
81
+ save(record.finish(RunRecord::FAILED, now: now, failure: failure))
82
+ end
83
+
71
84
  # The interrupt with +id+ anywhere on the thread, or nil.
72
85
  def find_interrupt(scope:, thread_id:, interrupt_id:)
73
86
  raise NotImplementedError
@@ -88,6 +101,16 @@ module RubyLLM
88
101
 
89
102
  private
90
103
 
104
+ def next_record(stored, candidate)
105
+ raise InvalidRunIdentityError, "run #{candidate.run_id} was never reserved" if stored.nil?
106
+ raise RunConflictError, conflict_message(candidate) if stored.digest != candidate.digest
107
+ if stored.revision != candidate.revision || (stored.terminal? && stored.status != candidate.status)
108
+ raise StaleRecordError, "run #{candidate.run_id} changed; reload its record before writing"
109
+ end
110
+
111
+ candidate.next_revision
112
+ end
113
+
91
114
  # The host's scope must be a canonical string. An arbitrary object would
92
115
  # make two requests from the same tenant hash differently, so a retry
93
116
  # would not attach and the run would execute twice.
@@ -55,13 +55,8 @@ module RubyLLM
55
55
  def save(record)
56
56
  key = key_for(record.scope, record.thread_id, record.run_id)
57
57
  @lock.synchronize do
58
- stored = @records[key]
59
- raise InvalidRunIdentityError, "run #{record.run_id} was never reserved" if stored.nil?
60
- raise RunConflictError, conflict_message(record) if stored.digest != record.digest
61
-
62
- @records[key] = record
58
+ @records[key] = next_record(@records[key], record)
63
59
  end
64
- record
65
60
  end
66
61
 
67
62
  def find_interrupt(scope:, thread_id:, interrupt_id:)
@@ -89,7 +84,8 @@ module RubyLLM
89
84
  raise ResumeRejectedError, "interrupt #{interrupt_id} expired" if interrupt.expired?(now)
90
85
 
91
86
  answered = interrupt.decide(decision, now: now)
92
- @records[key_for(scope, thread_id, record.run_id)] = record.replace_interrupt(answered, now: now)
87
+ @records[key_for(scope, thread_id, record.run_id)] =
88
+ record.replace_interrupt(answered, now: now).next_revision
93
89
  Redemption.new(interrupt: answered, replayed: false)
94
90
  end
95
91
 
@@ -3,6 +3,6 @@
3
3
  module RubyLLM
4
4
  module AgUI
5
5
  # The version of the ruby_llm-ag_ui gem, as a string.
6
- VERSION = '0.2.0'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-ag_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Wang
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ag-ui-protocol
@@ -44,6 +44,26 @@ dependencies:
44
44
  - - "<"
45
45
  - !ruby/object:Gem::Version
46
46
  version: '3.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: json
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '3'
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '2.0'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '3'
47
67
  - !ruby/object:Gem::Dependency
48
68
  name: cgi
49
69
  requirement: !ruby/object:Gem::Requirement
@@ -145,7 +165,9 @@ files:
145
165
  - docs/_advanced/client-example.md
146
166
  - docs/_advanced/rails-integration.md
147
167
  - docs/_advanced/security-and-deployment.md
168
+ - docs/_advanced/worker-recovery.md
148
169
  - docs/_core_features/approval-and-resume.md
170
+ - docs/_core_features/cancellation.md
149
171
  - docs/_core_features/input-validation.md
150
172
  - docs/_core_features/reconnects.md
151
173
  - docs/_core_features/streaming-runs.md
@@ -153,7 +175,7 @@ files:
153
175
  - docs/_getting_started/configuration.md
154
176
  - docs/_getting_started/getting-started.md
155
177
  - docs/_getting_started/overview.md
156
- - docs/_getting_started/whats-new-in-0-2.md
178
+ - docs/_getting_started/whats-new-in-0-3.md
157
179
  - docs/_reference/api-reference.md
158
180
  - docs/_reference/compatibility.md
159
181
  - docs/_reference/protocol-coverage.md
@@ -162,6 +184,7 @@ files:
162
184
  - docs/_reference/third-party-notices.md
163
185
  - docs/_reference/upgrading.md
164
186
  - docs/_reference/whats-new-in-0-1.md
187
+ - docs/_reference/whats-new-in-0-2.md
165
188
  - docs/index.md
166
189
  - lib/generators/ruby_llm/ag_ui/install_generator.rb
167
190
  - lib/generators/ruby_llm/ag_ui/templates/create_ruby_llm_ag_ui_runs.rb.tt
@@ -177,6 +200,7 @@ files:
177
200
  - lib/ruby_llm/ag_ui/interrupt.rb
178
201
  - lib/ruby_llm/ag_ui/mapper.rb
179
202
  - lib/ruby_llm/ag_ui/message_parser.rb
203
+ - lib/ruby_llm/ag_ui/persistence.rb
180
204
  - lib/ruby_llm/ag_ui/protocol.rb
181
205
  - lib/ruby_llm/ag_ui/rails/active_record_store.rb
182
206
  - lib/ruby_llm/ag_ui/rails/railtie.rb