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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +1 -1
- data/docs/_advanced/client-example.md +11 -0
- data/docs/_advanced/rails-integration.md +5 -2
- data/docs/_advanced/worker-recovery.md +104 -0
- data/docs/_core_features/cancellation.md +84 -0
- data/docs/_core_features/reconnects.md +7 -1
- data/docs/_core_features/streaming-runs.md +14 -0
- data/docs/_getting_started/whats-new-in-0-3.md +61 -0
- data/docs/_reference/compatibility.md +47 -5
- data/docs/_reference/release-history.md +2 -1
- data/docs/_reference/releasing.md +10 -0
- data/docs/_reference/upgrading.md +30 -21
- data/docs/{_getting_started → _reference}/whats-new-in-0-2.md +2 -1
- data/lib/ruby_llm/ag_ui/emitter.rb +5 -1
- data/lib/ruby_llm/ag_ui/error.rb +13 -2
- data/lib/ruby_llm/ag_ui/error_sanitizer.rb +1 -0
- data/lib/ruby_llm/ag_ui/persistence.rb +18 -0
- data/lib/ruby_llm/ag_ui/rails/active_record_store.rb +21 -11
- data/lib/ruby_llm/ag_ui/rails/record.rb +8 -3
- data/lib/ruby_llm/ag_ui/rails/streaming.rb +3 -3
- data/lib/ruby_llm/ag_ui/resume.rb +8 -4
- data/lib/ruby_llm/ag_ui/run.rb +11 -12
- data/lib/ruby_llm/ag_ui/run_record.rb +17 -4
- data/lib/ruby_llm/ag_ui/sse_writer.rb +16 -4
- data/lib/ruby_llm/ag_ui/store/base.rb +24 -1
- data/lib/ruby_llm/ag_ui/store/memory_store.rb +3 -7
- data/lib/ruby_llm/ag_ui/version.rb +1 -1
- metadata +27 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd1d89205c88b89562445e5f654f2f2f931753ac4ff4e2ed4434031ad65251d6
|
|
4
|
+
data.tar.gz: ec70b5e27313a50c8596beaa33c2de9f3b8fc46fab5968b309cc8f626b4a70a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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`
|
|
110
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
|
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
|
-
##
|
|
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
|
|
98
|
-
|
|
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.
|
|
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.
|
|
18
|
+
## Move from 0.2 to 0.3
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Drain your old workers, change your Gemfile, and update the dependency:
|
|
21
21
|
|
|
22
22
|
```ruby
|
|
23
|
-
gem 'ruby_llm-ag_ui', '~> 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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).
|
|
@@ -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
|
data/lib/ruby_llm/ag_ui/error.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
54
|
-
|
|
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+
|
|
66
|
-
#
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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+
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
49
|
-
#
|
|
50
|
-
#
|
|
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 =
|
|
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.
|
data/lib/ruby_llm/ag_ui/run.rb
CHANGED
|
@@ -64,9 +64,9 @@ module RubyLLM
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def reserve
|
|
67
|
-
|
|
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
|
-
|
|
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,
|
|
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'),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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)] =
|
|
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
|
|
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.
|
|
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-
|
|
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-
|
|
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
|