robot_lab-cyborg 0.2.7
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 +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +94 -0
- data/LICENSE.txt +21 -0
- data/README.md +138 -0
- data/Rakefile +132 -0
- data/docs/api_reference.md +301 -0
- data/docs/custom_channels.md +123 -0
- data/docs/getting_started.md +156 -0
- data/docs/how_it_works.md +211 -0
- data/docs/index.md +57 -0
- data/examples/01_human_in_the_network.rb +61 -0
- data/examples/02_terminal_mentions.rb +95 -0
- data/examples/03_robot_interviews_cyborg.rb +125 -0
- data/examples/04_presence_and_availability.rb +70 -0
- data/examples/05_listening_and_duplex.rb +53 -0
- data/lib/robot_lab/cyborg/channel.rb +174 -0
- data/lib/robot_lab/cyborg/conversation.rb +97 -0
- data/lib/robot_lab/cyborg/interviewer.rb +314 -0
- data/lib/robot_lab/cyborg/version.rb +9 -0
- data/lib/robot_lab/cyborg.rb +511 -0
- data/mkdocs.yml +118 -0
- data/sig/robot_lab/cyborg.rbs +36 -0
- metadata +90 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# How It Works
|
|
2
|
+
|
|
3
|
+
## The Means and the Process: Channel and Interviewer
|
|
4
|
+
|
|
5
|
+
Reaching the human is deliberately split into two small, separately-testable concerns:
|
|
6
|
+
|
|
7
|
+
- **`Channel`** is the *means* — a dumb, bidirectional pipe. It knows how to `deliver` a message **out** to the human and how to `receive` a message the human sends **in**. It has no concept of a "question" or an "answer" — just messages crossing a boundary.
|
|
8
|
+
- **`Interviewer`** is the *process* — it conducts the interaction over whatever channel is injected. It tracks which questions are outstanding, decides which inbound message answers which question, and surfaces everything else as unsolicited human *initiative*.
|
|
9
|
+
|
|
10
|
+
This split is why `$stdin`/`$stdout` live **only** inside `Channel::Terminal` — nothing else in the gem assumes a terminal. Swapping in a Slack, email, or web-form channel changes nothing about how `Interviewer`, `Cyborg`, or `Conversation` work. See [Building Custom Channels](custom_channels.md) to write one.
|
|
11
|
+
|
|
12
|
+
### `ChannelMessage`
|
|
13
|
+
|
|
14
|
+
The one data shape that crosses the boundary in both directions:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
ChannelMessage = Data.define(:id, :content, :in_reply_to, :sender, :kind, :at)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Field | Meaning |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `id` | the question id this message carries (outbound) or answers (inbound, when the channel can correlate) |
|
|
23
|
+
| `content` | the human-readable text |
|
|
24
|
+
| `in_reply_to` | set by the channel when it can tie an inbound answer to a specific question (a Slack thread, an email `In-Reply-To`) |
|
|
25
|
+
| `sender` | who the message is from/for, for display |
|
|
26
|
+
| `kind` | `:question` \| `:answer` \| `:message` \| `:notice` |
|
|
27
|
+
| `at` | `Time.now` unless given |
|
|
28
|
+
|
|
29
|
+
`kind == :question` (checked via the `question?` predicate) is what tells `Channel::Terminal` to print a `> ` prompt after the line; other kinds (inbound peer messages, notices) print without one, so unsolicited traffic doesn't spam the input cursor.
|
|
30
|
+
|
|
31
|
+
### Built-in Channels
|
|
32
|
+
|
|
33
|
+
**`Channel::Terminal`** — the human at a keyboard, and the default when no `channel:` is given.
|
|
34
|
+
|
|
35
|
+
- `input:`/`output:` are injectable (default `$stdin`/`$stdout`); inject `StringIO` in tests.
|
|
36
|
+
- `deliver` is synchronized with a `Mutex` so a question and an inbound peer message never interleave on screen.
|
|
37
|
+
- `receive(timeout:)` uses `IO.wait_readable` on a real IO so timeouts, shutdown, and question expiry all work; a non-selectable stream (a `StringIO` in tests) returns immediately from `#gets`, which is equally responsive.
|
|
38
|
+
- A `nil` line from `#gets` (EOF) is treated as "nothing arrived" and propagates as `nil`.
|
|
39
|
+
- `correlates?` is `false` — a bare terminal carries no correlation, so `in_reply_to` always stays `nil` on what it produces.
|
|
40
|
+
|
|
41
|
+
**`Channel::Scripted`** — canned answers for tests, automation, and replay.
|
|
42
|
+
|
|
43
|
+
- Constructed with an array (or single value) of answers, consumed in order.
|
|
44
|
+
- `correlates?` is `true` — each delivered question is paired with the *next* scripted answer **at delivery time** and stamped with that question's id, so correlation is exact and deterministic.
|
|
45
|
+
- Every question shown is recorded in `#asked`, so tests can assert on what the human saw: `cyborg.channel.asked`.
|
|
46
|
+
- When the script runs dry, a delivered question simply goes unanswered — modeling a human who never replies — and the `Interviewer`'s timeout (if any) takes over. Supply enough answers, or an `ask_timeout`, for any code path that asks more questions than you scripted.
|
|
47
|
+
|
|
48
|
+
## Asking Asynchronously
|
|
49
|
+
|
|
50
|
+
Asking is **always** asynchronous, whatever the channel — a human's answer may be the very next message, may arrive several messages later (after they say other things first), or may never come at all. So `Interviewer#ask` never blocks: it delivers the question and returns a `Question`, a one-shot future, immediately.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
question = interviewer.ask("Deploy now?") # returns instantly
|
|
54
|
+
# ... do other things ...
|
|
55
|
+
answer = question.answer(timeout: 30) # blocks here, not before
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`Interviewer#ask_and_wait` (and `Cyborg#ask`) combine both steps into one call for the common synchronous case — this is the boundary a pipeline step or a bus task handler actually needs: block *this* thread, but don't block the channel's consumer thread.
|
|
59
|
+
|
|
60
|
+
`Cyborg#ask_async` exposes the non-blocking primitive directly, returning the pending `Question` so the caller decides when (and whether) to wait — the primitive a durable/suspendable human step would need to persist (see [Roadmap: Durable Human Steps](#roadmap-durable-human-steps)).
|
|
61
|
+
|
|
62
|
+
### The Background Consumer
|
|
63
|
+
|
|
64
|
+
`Interviewer` lazily starts a single background thread (`ensure_consumer`) the first time a question is outstanding, or when `#listen` is called. It polls `channel.receive(timeout: 0.05)` in a loop and, for each inbound message, either resolves the question it answers or calls the `on_initiative` handler.
|
|
65
|
+
|
|
66
|
+
The consumer is deliberately hard to kill:
|
|
67
|
+
|
|
68
|
+
- **A handler or channel error never wedges it.** Any `StandardError` raised while dispatching a message (in a channel read, in `on_initiative`, in interpreting an answer) is caught, recorded in `Interviewer#last_error`, and the loop keeps running. Before this, a throwing handler used to kill the consumer thread and hang every future `ask` — this was fixed as part of a design review (see `CHANGELOG.md`, item *A1*).
|
|
69
|
+
- **It stops itself once idle**, unless `#listen` has been called: no questions outstanding and not in always-on mode → the loop exits and `@running` resets, so the next `#ask` (or `#listen`) restarts it cleanly. Both the start and stop paths flip `@running` under the same mutex, so a restart can never race a shutdown.
|
|
70
|
+
- **`#close`** stops it for good and closes the channel — call this when a `Cyborg`/`Interviewer` is done and its channel resources (a socket, a file handle) need releasing.
|
|
71
|
+
|
|
72
|
+
### Correlation vs. Serialization
|
|
73
|
+
|
|
74
|
+
Whether more than one question can be outstanding at once depends entirely on the channel:
|
|
75
|
+
|
|
76
|
+
- A channel that **can** tie an inbound answer back to its question (`correlates? == true` — Slack threads, email `In-Reply-To`, `Channel::Scripted`) lets any number of questions be outstanding concurrently. Each is delivered immediately; `Interviewer#dispatch` resolves whichever one an inbound message's `in_reply_to` names.
|
|
77
|
+
- A channel that **cannot** (`correlates? == false` — a bare terminal) gets **serialized**: only one question is ever "on the wire" at a time (`@active_id`). Additional `ask` calls queue in `@pending` and are delivered one at a time as the active question resolves or expires (`pump_pending`/`release_active`). An inbound message with no `in_reply_to` resolves whichever question is currently active. This makes mis-attribution — a fast second answer accidentally resolving the first question — impossible.
|
|
78
|
+
|
|
79
|
+
An inbound message that names no outstanding question (an unknown/expired id, or an answer arriving when nothing is active) matches nothing and is routed to `on_initiative` instead — the human speaking as a peer, unprompted.
|
|
80
|
+
|
|
81
|
+
### Interpreting an Answer
|
|
82
|
+
|
|
83
|
+
Before resolving a `Question`, the `Interviewer` runs the raw text through `interpret`:
|
|
84
|
+
|
|
85
|
+
- An empty answer falls back to the question's `default`, when one was given.
|
|
86
|
+
- If the question had `choices:`, a bare number is mapped to the corresponding choice (1-indexed) — out-of-range numbers, and any non-numeric text, pass through unchanged as free text.
|
|
87
|
+
- The result is always coerced to a `String`, so a genuine empty answer (`""`) is never confused with a timeout (`nil`).
|
|
88
|
+
|
|
89
|
+
### Timeouts and Expiry
|
|
90
|
+
|
|
91
|
+
`Question#answer(timeout:)` blocks on an internal `Thread::Queue` (the mailbox `Interviewer#dispatch` pushes into). If nothing arrives within `timeout`:
|
|
92
|
+
|
|
93
|
+
1. The question calls back into `Interviewer#expire(self)`, which removes it from the outstanding set (so a later, unrelated answer can never claim it) and, on a serialized channel, advances the queue to the next pending question.
|
|
94
|
+
2. `#answer` returns the question's `default` (`nil` if none was given).
|
|
95
|
+
|
|
96
|
+
`nil` from `#answer` is therefore unambiguous: it means "no answer, no default" — never "the human answered with an empty string."
|
|
97
|
+
|
|
98
|
+
## Cyborg: The Human Peer
|
|
99
|
+
|
|
100
|
+
### Network Member and Bus Contracts
|
|
101
|
+
|
|
102
|
+
`Cyborg` satisfies the same two contracts a `Robot` does, so a network (or another peer) never has to know which one it's talking to:
|
|
103
|
+
|
|
104
|
+
- **The `SimpleFlow` step contract** — `#call(result)`. The network invokes this when a pipeline reaches the human's task; `Cyborg` extracts the incoming message and shared memory from the pipeline `Result` (`extract_run_context`), calls its own `#run`, and threads a `RobotResult` back into the pipeline the same way a robot's step would (`result.with_context(name, robot_result).continue(robot_result)`). Any exception is caught and turned into an error-shaped `RobotResult` rather than raising through the pipeline.
|
|
105
|
+
- **The `#run` contract** — `run(message = nil, network_memory: nil, memory: nil, **)`. This is what makes a `Cyborg` a valid `delegate`/`spawn` target anywhere a `Robot` is: it asks the human the given message (via `#ask`) and wraps the answer in a `RobotResult`, so `result.reply` works identically regardless of which kind of peer produced it.
|
|
106
|
+
|
|
107
|
+
`Cyborg` also **includes `RobotLab::Robot::BusMessaging` verbatim** — the same module `Robot` uses for `send_message`/`send_reply`/`on_message`/`spawn`/bus-poller wiring. This is why a robot and a human are wire-compatible peers on the same `TypedBus`: there is only one implementation of "how a member talks on the bus," shared by both.
|
|
108
|
+
|
|
109
|
+
**Caveat:** `Cyborg#initialize` wires its own inbound handler — `@message_handler = method(:handle_incoming)` — to get its default human-in-the-loop behavior (surface the task, ask, auto-reply). `BusMessaging#on_message`, `#respond_to_tasks`, and `#serve` all *replace* `@message_handler` wholesale. Calling any of them on a `Cyborg` overwrites `handle_incoming` and disables the built-in ask/auto-reply flow — those three methods are meant for `Robot`s opting *into* task-serving behavior a `Cyborg` already has by default. If you need custom bus-message handling on a `Cyborg`, build it around `#on_task`/`#on_human` instead (see below), not `#on_message`.
|
|
110
|
+
|
|
111
|
+
### The Inbound Task Lifecycle
|
|
112
|
+
|
|
113
|
+
`handle_incoming(message)` — the private method wired as `@message_handler` — is what runs on the bus poller's drain thread for every message addressed to this peer:
|
|
114
|
+
|
|
115
|
+
1. The message is recorded in `#inbox` (thread-safe, for later inspection).
|
|
116
|
+
2. If it's a **reply** to something this peer sent earlier, it's shown to the human via the channel (`kind: :message`) and handling stops there — replies don't get re-asked as tasks.
|
|
117
|
+
3. Otherwise it's a **fresh task**, handled by `respond_to_task`.
|
|
118
|
+
|
|
119
|
+
`respond_to_task` never blocks the poller:
|
|
120
|
+
|
|
121
|
+
- If the peer is `!available?` (offline), it declines immediately — a reply saying `"(name is unavailable)"` is sent back at once (if `auto_reply` and a bus are configured) rather than waiting on a human who won't answer.
|
|
122
|
+
- Otherwise, it spawns a **new thread** that asks the human (`ask(task_prompt(message))`), sends the answer back as a reply once it arrives (again, if `auto_reply` and a bus are configured, and only if there is an answer), and invokes the `#on_task` callback with the original message and the answer. Any error in that thread is caught and logged with `warn`, not raised — a slow or crashing human-answer thread can never take down the bus poller.
|
|
123
|
+
|
|
124
|
+
This is the fix for a real deadlock: before it, an inbound task ran synchronously on the poller thread, so a slow (or absent) human blocked delivery of every other message to every other peer sharing that poller group.
|
|
125
|
+
|
|
126
|
+
### Duplex: Talking Back to the Human
|
|
127
|
+
|
|
128
|
+
Two directions, both automatic in the common case:
|
|
129
|
+
|
|
130
|
+
- **Network → human**: any inbound bus message or reply is shown on the channel via `deliver_to_human`, best-effort (a channel error here is swallowed — a broken display must never break message intake). Call `Cyborg#tell(text, kind: :notice)` yourself to push an unprompted line (a status update, a warning) the same way.
|
|
131
|
+
- **Human → network**: an inbound channel message that answers no outstanding question is *initiative* — the human speaking unprompted. `Interviewer#on_initiative` routes it to `Cyborg#handle_human_initiative`, which in turn calls the `#on_human` callback you register. `Conversation` (below) is the built-in way to turn that initiative into addressed bus tasks; you can also register `#on_human` directly for custom routing.
|
|
132
|
+
|
|
133
|
+
### Always-On Listening
|
|
134
|
+
|
|
135
|
+
By default, the `Interviewer`'s consumer thread only runs while a question is outstanding — there's nothing to read otherwise. `Cyborg#listen` (and `Conversation#start`, which calls it) keeps the consumer alive with *no* question pending, so the human can address the network unprompted at any time; their input arrives via `#on_human`. `#unlisten` (and `Conversation#stop`) turn this back off; the consumer then winds down once nothing is outstanding, same as normal.
|
|
136
|
+
|
|
137
|
+
### Presence and Availability
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
cyborg.online! # taking work now (default)
|
|
141
|
+
cyborg.away! # present but slow — still asked, use a generous timeout
|
|
142
|
+
cyborg.offline! # not taking work — inbound tasks declined immediately
|
|
143
|
+
cyborg.available? # => false only when :offline
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Presence is plain in-memory state (`@presence`, guarded by a mutex) — it doesn't itself change *how* a question is asked, only what `respond_to_task` does with an inbound bus task before asking at all. A network or dispatcher can check `available?` before routing work to a specific human, to pick another peer or escalate instead of tasking someone who will just decline (or, if `:away`, take a long time).
|
|
147
|
+
|
|
148
|
+
### Typed and Validated Answers
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
cyborg.ask("Deploy?") # raw text (or the default)
|
|
152
|
+
cyborg.ask_int("How many replicas?") # re-asks until a parseable Integer
|
|
153
|
+
cyborg.ask_confirm("Proceed?") # => true / false / nil
|
|
154
|
+
cyborg.ask("Pick one", choices: %w[a b c]) # numbered choices; a bare digit maps to one
|
|
155
|
+
cyborg.ask("Env?", validate: ->(a) { %w[dev prod].include?(a) ? a : nil }, retries: 2)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`#ask`'s `validate:` contract: return the (optionally coerced) value when the answer is acceptable, or `nil` to reject it. On rejection, `Cyborg` tells the human why (`"Sorry, I couldn't use ..."`) and re-asks, up to `retries` extra attempts, after which it falls back to `default`. `ask_int`/`ask_confirm` are just `ask` with a pre-built `validate:`.
|
|
159
|
+
|
|
160
|
+
### Delegation
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
result = cyborg.delegate(to: some_robot_or_cyborg, task: "Summarize this") # blocks, returns RobotResult
|
|
164
|
+
future = cyborg.delegate(to: some_robot_or_cyborg, task: "Summarize this", async: true) # returns a DelegationFuture
|
|
165
|
+
future.value # blocks here instead; raises DelegationFuture::DelegationTimeout if value(timeout:) expires
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`delegate` works against **any** target that responds to `#run` — a `Robot` or another `Cyborg` — because both satisfy the same `#run` contract. The synchronous form calls `to.run(task, **)` directly and stamps `delegated_by` on the result. The async form spawns a `Thread`, resolves or rejects a `RobotLab::DelegationFuture` from it, and returns the future immediately — so a human (or a robot) can fan a task out to several peers in parallel and collect results later.
|
|
169
|
+
|
|
170
|
+
`assign(to:, task:)` is the fire-and-forget bus counterpart — an alias for `BusMessaging#send_message`, named for how a human hands off work; correlate its reply later via `cyborg.outbox[message.key]`.
|
|
171
|
+
|
|
172
|
+
### Shared Memory
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
cyborg.remember(:decision, "ship it") # writes to the active memory
|
|
176
|
+
cyborg.recall(:sentiment, wait: 30) # reads from it, optionally blocking for up to 30s
|
|
177
|
+
cyborg.attach_memory(some_network_memory) # explicit target (a network run does this for you)
|
|
178
|
+
cyborg.detach_memory # back to this peer's own standalone memory
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
"The active memory" is the network's shared memory while attached, and this peer's own standalone `memory:` otherwise (`current_memory`, mutex-guarded). `#run` calls `attach_memory` automatically whenever a `network_memory:` is passed in — which is how a network run wires it up without you doing so by hand. Detaching explicitly matters when a peer might otherwise keep writing to a finished network's memory after that run has ended.
|
|
182
|
+
|
|
183
|
+
`remember`/`recall` additionally set/restore the memory's `current_writer` around the call (`with_writer`, a no-op for memory implementations that don't track one) — a no-op for memories that don't track a writer, and otherwise how downstream consumers know a given memory write came from this human rather than a robot.
|
|
184
|
+
|
|
185
|
+
## Conversation: Multi-Peer Chat by @mention
|
|
186
|
+
|
|
187
|
+
`Conversation` turns a listening `Cyborg` into an interactive participant among several named peers:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
you = RobotLab::Cyborg.new(name: "you", bus: bus)
|
|
191
|
+
chat = you.converse(peers: %w[analyst scribe])
|
|
192
|
+
# human types: "hey @analyst and @scribe: status?" -> both are tasked
|
|
193
|
+
# human types: "status?" -> broadcasts to both (no mention = everyone)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`Cyborg#converse(peers:)` is sugar for `Conversation.new(cyborg: self, peers: peers).start`. `#start` registers an `#on_human` handler that routes each unprompted line and calls `#listen`; `#stop` calls `#unlisten`.
|
|
197
|
+
|
|
198
|
+
Routing rules (`Conversation#route`):
|
|
199
|
+
|
|
200
|
+
- Every `@name` mention in the line (via the `MENTION = /@(\w+)/` pattern, in order of first appearance, deduplicated) addresses that peer.
|
|
201
|
+
- A line with **no** mention broadcasts to **every** known peer.
|
|
202
|
+
- A line whose mentions are *all* unknown peers is **not sent** — `Conversation` tells the human which name(s) weren't recognized, and (if there were no other, valid mentions) which peers *are* known.
|
|
203
|
+
- A line mixing known and unknown mentions still sends to the known ones, after reporting the unknown ones.
|
|
204
|
+
|
|
205
|
+
Replies come back automatically — `Cyborg`'s duplex delivers inbound bus traffic to the channel regardless of how the outbound task was sent, so `Conversation` only has to handle the human → network direction; it never polls for or renders replies itself.
|
|
206
|
+
|
|
207
|
+
`add_peer(name)` grows the addressable roster after construction (e.g. as new robots join a running session).
|
|
208
|
+
|
|
209
|
+
## Roadmap: Durable Human Steps
|
|
210
|
+
|
|
211
|
+
A human step currently holds a thread while it waits (`ask`/`ask_and_wait` block; the underlying `Question#answer` sits on a blocking `Thread::Queue#pop`). `ask_async` returns the pending `Question` without blocking — the primitive a durable integration would need to persist across a process restart. The intended path, per the gem's `CHANGELOG.md` and `README.md`, is to store a pending decision through **`robot_lab-durable`** (and `robot_lab-to`'s `DecisionManager`) so a human decision survives a restart without pinning a thread for the duration. Per-peer cryptographic identity/attribution (signed events), building on the existing per-message `sender`/`from`, is a complementary but separate direction. Neither is implemented in this gem as of this writing — treat this section as intent, not a shipped feature.
|
data/docs/index.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# robot_lab-cyborg
|
|
2
|
+
|
|
3
|
+
A [RobotLab](https://github.com/MadBomber/robot_lab) extension gem that puts a **human** into the network as a peer worker.
|
|
4
|
+
|
|
5
|
+
Robots on a RobotLab network are LLM-backed workers. A **`Cyborg`** is a *human*-backed worker that sits at exactly the same level as the robots: it registers as a network task, speaks on the same [TypedBus](https://github.com/MadBomber/typed_bus) channels, reads and writes the same shared memory, **receives tasking** (as a pipeline step and as bus messages), and **issues tasking** to the other members — humans and robots alike.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "robot_lab"
|
|
9
|
+
require "robot_lab/cyborg"
|
|
10
|
+
|
|
11
|
+
dewayne = RobotLab::Cyborg.new(name: "dewayne")
|
|
12
|
+
|
|
13
|
+
network = RobotLab.create_network(name: "release") do
|
|
14
|
+
task :draft, writer_robot, depends_on: :none
|
|
15
|
+
task :approve, dewayne, depends_on: [:draft] # the human signs off
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
network.run(message: "Draft the release notes")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A `Cyborg` reuses `RobotLab::Robot::BusMessaging` verbatim, so its bus behavior is byte-for-byte identical to a robot's. It deliberately does **not** subclass `Robot`: a human needs no LLM, no model, and no API key. The human *is* the "model," reached across an injectable **`Channel`** (the *means* — terminal today, Slack/email/web later) by an **`Interviewer`** (the *process* that conducts the asynchronous ask-and-answer).
|
|
22
|
+
|
|
23
|
+
## Navigation
|
|
24
|
+
|
|
25
|
+
- [Getting Started](getting_started.md) — installation, creating a Cyborg, pipeline steps, bus messaging, running the bundled examples
|
|
26
|
+
- [How It Works](how_it_works.md) — the Channel/Interviewer split, correlation vs. serialization, the consumer thread, the duplex channel, presence, memory, the network-member contract, delegation
|
|
27
|
+
- [API Reference](api_reference.md) — every public class and method, grouped by concern
|
|
28
|
+
- [Building Custom Channels](custom_channels.md) — bridging a Cyborg to Slack, email, a web form, or any other transport
|
|
29
|
+
|
|
30
|
+
## At a Glance
|
|
31
|
+
|
|
32
|
+
| | |
|
|
33
|
+
|---|---|
|
|
34
|
+
| **Core class** | `RobotLab::Cyborg` — a human peer worker |
|
|
35
|
+
| **Bus behavior** | Identical to `Robot`'s — reuses `RobotLab::Robot::BusMessaging` |
|
|
36
|
+
| **Reaches the human via** | An injectable `Channel` (`Terminal` by default, `Scripted` for tests, or your own) |
|
|
37
|
+
| **Conducts the interaction via** | `Interviewer` — async ask/answer, question correlation, serialization on dumb channels |
|
|
38
|
+
| **Network roles** | Pipeline step (`task :name, cyborg`), bus peer (`assign`/`delegate`), shared-memory participant (`remember`/`recall`) |
|
|
39
|
+
| **Presence** | `online!` / `away!` / `offline!` / `available?` |
|
|
40
|
+
| **Multi-peer chat** | `Conversation` — `@mention` addressing, no-mention broadcast |
|
|
41
|
+
| **Typed answers** | `ask`, `ask_int`, `ask_confirm`, `ask(validate:, retries:)` |
|
|
42
|
+
| **Dependency** | `robot_lab ~> 0.2, >= 0.2.6` |
|
|
43
|
+
|
|
44
|
+
## Why a Cyborg, Not Just a Human-Shaped Robot?
|
|
45
|
+
|
|
46
|
+
A `Robot` is built around an LLM chat: model, provider, tokens, a `ruby_llm` chat object. None of that applies to a human. Rather than force a human through `Robot`'s LLM-shaped constructor and stub out everything that doesn't apply, `Cyborg` is a separate, smaller class that:
|
|
47
|
+
|
|
48
|
+
- Includes only the one piece of `Robot` it genuinely needs and shares byte-for-byte — `BusMessaging` (send/receive over `TypedBus`, spawn, bus poller wiring).
|
|
49
|
+
- Replaces "call the model" with "ask a human," via an injectable `Channel` + `Interviewer` pair instead of a `ruby_llm` chat.
|
|
50
|
+
- Still satisfies the same *network member* contract (`call`/`run`) so it is a drop-in peer anywhere a `Robot` is expected — a pipeline `task`, a `delegate` target, a `spawn`-style bus peer.
|
|
51
|
+
|
|
52
|
+
## Links
|
|
53
|
+
|
|
54
|
+
- [RobotLab Core](https://github.com/MadBomber/robot_lab)
|
|
55
|
+
- [RubyGems](https://rubygems.org/gems/robot_lab-cyborg)
|
|
56
|
+
- [GitHub](https://github.com/MadBomber/robot_lab-cyborg)
|
|
57
|
+
- [Changelog](https://github.com/MadBomber/robot_lab-cyborg/blob/main/CHANGELOG.md)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Human-in-the-network demo.
|
|
5
|
+
#
|
|
6
|
+
# A Cyborg is a human peer worker. It reaches its human across an injectable
|
|
7
|
+
# Channel (the *means*: terminal now, Slack/email/web later). This example
|
|
8
|
+
# injects a Scripted channel so it runs end-to-end without a live human — omit
|
|
9
|
+
# `channel:` to get the default terminal channel and answer the prompts yourself.
|
|
10
|
+
#
|
|
11
|
+
# ruby examples/01_human_in_the_network.rb
|
|
12
|
+
|
|
13
|
+
# Prefer the local robot_lab checkout (with the latest fixes) over any installed gem.
|
|
14
|
+
core_lib = File.expand_path("../../robot_lab/lib", __dir__)
|
|
15
|
+
$LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
|
|
16
|
+
|
|
17
|
+
require "robot_lab"
|
|
18
|
+
require_relative "../lib/robot_lab/cyborg"
|
|
19
|
+
|
|
20
|
+
Scripted = RobotLab::Cyborg::Channel::Scripted
|
|
21
|
+
|
|
22
|
+
# --- 1. A human as a pipeline step ------------------------------------------
|
|
23
|
+
# The human is interchangeable with a robot: it registers as a network task and
|
|
24
|
+
# its answer flows downstream like any RobotResult.
|
|
25
|
+
|
|
26
|
+
approver = RobotLab::Cyborg.new(name: "approver", channel: Scripted.new(["approved: ship it"]))
|
|
27
|
+
|
|
28
|
+
network = RobotLab.create_network(name: "release") do
|
|
29
|
+
task :approve, approver, depends_on: :none
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = network.run(message: "Approve the v1.0 release notes?")
|
|
33
|
+
puts "Pipeline result : #{result.value.reply}"
|
|
34
|
+
puts "Human was asked : #{approver.channel.asked.first}"
|
|
35
|
+
|
|
36
|
+
# --- 2. Two peers messaging over a shared bus -------------------------------
|
|
37
|
+
# A robot would normally be on this bus too; here both peers are humans to keep
|
|
38
|
+
# the example key-free. The message plumbing is identical either way.
|
|
39
|
+
|
|
40
|
+
bus = TypedBus::MessageBus.new
|
|
41
|
+
lead = RobotLab::Cyborg.new(name: "lead", bus: bus, channel: Scripted.new([]))
|
|
42
|
+
# oncall just needs to exist on the bus to receive the task and reply; it is
|
|
43
|
+
# addressed by name (:oncall), so no local reference is kept.
|
|
44
|
+
RobotLab::Cyborg.new(name: "oncall", bus: bus, channel: Scripted.new(["yes, go ahead"]))
|
|
45
|
+
|
|
46
|
+
msg = lead.assign(to: :oncall, task: "Safe to deploy right now?")
|
|
47
|
+
|
|
48
|
+
# Wait for the async round-trip (task out, human answer, reply back).
|
|
49
|
+
50.times do
|
|
50
|
+
break if lead.outbox[msg.key][:status] == :replied
|
|
51
|
+
|
|
52
|
+
sleep 0.02
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
reply = lead.outbox[msg.key][:replies].first
|
|
56
|
+
puts "\nlead asked oncall : Safe to deploy right now?"
|
|
57
|
+
puts "oncall replied : #{reply&.content}"
|
|
58
|
+
|
|
59
|
+
# --- 3. Shared memory --------------------------------------------------------
|
|
60
|
+
lead.remember(:decision, "deploy at 15:00")
|
|
61
|
+
puts "\nShared memory :decision => #{lead.recall(:decision)}"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Addressing peers by @mention, driven by the library Conversation.
|
|
5
|
+
#
|
|
6
|
+
# A live human (a Cyborg on the terminal channel) shares a bus with several
|
|
7
|
+
# peers. Address them by mentioning them — "@name" — anywhere in a message, as
|
|
8
|
+
# many as you like; the whole message is fanned out to everyone you mention. A
|
|
9
|
+
# message with NO mention broadcasts to every peer.
|
|
10
|
+
#
|
|
11
|
+
# The addressing/fan-out/broadcast logic lives in RobotLab::Cyborg::Conversation
|
|
12
|
+
# now, not here. Replies come back to your terminal on their own: a Cyborg
|
|
13
|
+
# delivers inbound bus traffic to its channel (the output half of the duplex),
|
|
14
|
+
# so this demo never has to poll or render replies.
|
|
15
|
+
#
|
|
16
|
+
# @assistant is a real LLM robot (Ollama) that cooperates via #serve — the
|
|
17
|
+
# one-call symmetric responder. @analyst and @scribe are key-free canned peers.
|
|
18
|
+
# Set OLLAMA_API_BASE/OLLAMA_MODEL, or run without Ollama and just use the canned
|
|
19
|
+
# peers (@assistant will simply not answer).
|
|
20
|
+
#
|
|
21
|
+
# ruby examples/02_terminal_mentions.rb
|
|
22
|
+
# # then type: @analyst and @scribe: status?
|
|
23
|
+
# # @assistant one-line haiku about deploys
|
|
24
|
+
# # @quit
|
|
25
|
+
|
|
26
|
+
require "logger"
|
|
27
|
+
|
|
28
|
+
# Prefer the local robot_lab checkout (with serve/respond_to_tasks) over any
|
|
29
|
+
# installed gem, so the demo runs from a fresh monorepo checkout.
|
|
30
|
+
core_lib = File.expand_path("../../robot_lab/lib", __dir__)
|
|
31
|
+
$LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
|
|
32
|
+
|
|
33
|
+
require "robot_lab"
|
|
34
|
+
require_relative "../lib/robot_lab/cyborg"
|
|
35
|
+
|
|
36
|
+
Cyborg = RobotLab::Cyborg
|
|
37
|
+
Channel = RobotLab::Cyborg::Channel
|
|
38
|
+
|
|
39
|
+
RubyLLM.configure do |c|
|
|
40
|
+
c.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
|
|
41
|
+
c.logger = Logger.new(File::NULL)
|
|
42
|
+
end
|
|
43
|
+
RobotLab.configure { |c| c.logger = Logger.new(File::NULL) }
|
|
44
|
+
|
|
45
|
+
# A canned automated peer: a Channel whose "human" is a block.
|
|
46
|
+
class Autobot < Channel
|
|
47
|
+
def initialize(&reply)
|
|
48
|
+
@reply = reply
|
|
49
|
+
@inbox = Thread::Queue.new
|
|
50
|
+
super()
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def correlates? = true
|
|
54
|
+
def deliver(message) = message.question? ? @inbox.push(reply_to(message)) : message
|
|
55
|
+
def receive(timeout: nil) = timeout ? @inbox.pop(timeout: timeout) : @inbox.pop
|
|
56
|
+
def close = @inbox.close
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def reply_to(message)
|
|
61
|
+
Cyborg::ChannelMessage.new(content: @reply.call(message.content), in_reply_to: message.id, kind: :answer)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
bus = TypedBus::MessageBus.new
|
|
66
|
+
|
|
67
|
+
# The live human. Its terminal labels inbound lines by their sender.
|
|
68
|
+
you = Cyborg.new(name: "you", bus: bus, channel: Channel::Terminal.new(name: "network"))
|
|
69
|
+
|
|
70
|
+
# A real LLM robot that serves bus tasks — the symmetric counterpart to how the
|
|
71
|
+
# Cyborg answers its human. One call, no hand-wired on_message.
|
|
72
|
+
RobotLab.build(name: "assistant", bus: bus, provider: "ollama",
|
|
73
|
+
model: ENV.fetch("OLLAMA_MODEL", "qwen3.6"),
|
|
74
|
+
system_prompt: "You are a concise teammate. Answer in 1-2 sentences.").serve
|
|
75
|
+
|
|
76
|
+
# Two key-free canned peers.
|
|
77
|
+
Cyborg.new(name: "analyst", bus: bus, channel: Autobot.new { |_q| "error rate is 0.2% over the last hour." })
|
|
78
|
+
Cyborg.new(name: "scribe", bus: bus, channel: Autobot.new { |_q| "noted — logged to the record." })
|
|
79
|
+
|
|
80
|
+
chat = Cyborg::Conversation.new(cyborg: you, peers: %w[assistant analyst scribe])
|
|
81
|
+
|
|
82
|
+
you.tell("Mention peers with @name (one or more, anywhere); no mention broadcasts to all. " \
|
|
83
|
+
"Known: @assistant, @analyst, @scribe. (@quit to exit)")
|
|
84
|
+
|
|
85
|
+
# The human drives the read loop; replies arrive on their own via the channel.
|
|
86
|
+
loop do
|
|
87
|
+
message = you.channel.receive
|
|
88
|
+
break if message.nil?
|
|
89
|
+
|
|
90
|
+
line = message.content.strip
|
|
91
|
+
next if line.empty?
|
|
92
|
+
break if line == "@quit"
|
|
93
|
+
|
|
94
|
+
chat.route(line)
|
|
95
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# A robot interviews a cyborg (the Interviewer, the other way round).
|
|
5
|
+
#
|
|
6
|
+
# Examples 1 and 2 had the human drive. Here the *robot* drives: an LLM-backed
|
|
7
|
+
# RobotLab robot (ProfileBot, on a local Ollama model) conducts an interview to
|
|
8
|
+
# learn as much as the human is willing to share, then classifies them across
|
|
9
|
+
# several categories. Each question the robot invents is delegated to the Cyborg,
|
|
10
|
+
# whose Interviewer conducts it on the terminal and hands the answer back — so
|
|
11
|
+
# the robot never touches $stdin/$stdout; the Cyborg's Interviewer is the whole
|
|
12
|
+
# bridge between the robot's intent and the person at the keyboard.
|
|
13
|
+
#
|
|
14
|
+
# You can answer each question, type "skip" to pass on one, or "done" to end the
|
|
15
|
+
# interview early. The robot builds the profile from whatever you chose to share.
|
|
16
|
+
#
|
|
17
|
+
# Requires a running Ollama with the model pulled: ollama pull qwen3.6
|
|
18
|
+
# Override with OLLAMA_MODEL / OLLAMA_API_BASE if yours differ.
|
|
19
|
+
#
|
|
20
|
+
# ruby examples/03_robot_interviews_cyborg.rb
|
|
21
|
+
|
|
22
|
+
require "logger"
|
|
23
|
+
# Prefer the local robot_lab checkout (with the latest fixes) over any installed gem.
|
|
24
|
+
core_lib = File.expand_path("../../robot_lab/lib", __dir__)
|
|
25
|
+
$LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
|
|
26
|
+
|
|
27
|
+
require "robot_lab"
|
|
28
|
+
require_relative "../lib/robot_lab/cyborg"
|
|
29
|
+
|
|
30
|
+
Cyborg = RobotLab::Cyborg
|
|
31
|
+
Channel = RobotLab::Cyborg::Channel
|
|
32
|
+
|
|
33
|
+
OLLAMA_API_BASE = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
|
|
34
|
+
OLLAMA_MODEL = ENV.fetch("OLLAMA_MODEL", "qwen3.6")
|
|
35
|
+
MAX_QUESTIONS = Integer(ENV.fetch("MAX_QUESTIONS", "5"))
|
|
36
|
+
|
|
37
|
+
ENDING_WORDS = %w[done stop quit exit].freeze
|
|
38
|
+
SKIPPING_WORDS = %w[skip pass].freeze
|
|
39
|
+
|
|
40
|
+
RubyLLM.configure do |c|
|
|
41
|
+
c.ollama_api_base = OLLAMA_API_BASE
|
|
42
|
+
c.logger = Logger.new(File::NULL)
|
|
43
|
+
end
|
|
44
|
+
RobotLab.configure { |c| c.logger = Logger.new(File::NULL) }
|
|
45
|
+
|
|
46
|
+
# The interviewer robot. Its system prompt fixes the categories it is building
|
|
47
|
+
# toward and keeps it to one question at a time so each turn maps cleanly onto a
|
|
48
|
+
# single Interviewer ask.
|
|
49
|
+
robot = RobotLab.build(
|
|
50
|
+
name: "ProfileBot",
|
|
51
|
+
provider: "ollama",
|
|
52
|
+
model: OLLAMA_MODEL,
|
|
53
|
+
system_prompt: <<~PROMPT
|
|
54
|
+
You are ProfileBot, a warm, concise interviewer. Your goal is to learn as much
|
|
55
|
+
as the person is willing to share so you can later classify them across these
|
|
56
|
+
categories: technical proficiency, professional role, communication style,
|
|
57
|
+
interests, and decision-making style.
|
|
58
|
+
|
|
59
|
+
Rules:
|
|
60
|
+
- Ask exactly ONE short, friendly question per turn.
|
|
61
|
+
- Output only the question itself — no preamble, numbering, or commentary.
|
|
62
|
+
- Build on what they have already told you; do not repeat a topic.
|
|
63
|
+
- Do not classify or summarize until you are explicitly asked to.
|
|
64
|
+
PROMPT
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# The human peer. Its terminal shows each question under a "[ProfileBot]" label,
|
|
68
|
+
# since in this demo everything the human hears comes from the robot.
|
|
69
|
+
you = Cyborg.new(name: "you", channel: Channel::Terminal.new(name: "ProfileBot"), ask_timeout: 300)
|
|
70
|
+
|
|
71
|
+
# Ask the human one question by delegating it to the Cyborg. The Cyborg's
|
|
72
|
+
# Interviewer delivers it over the terminal channel and returns the answer.
|
|
73
|
+
def interview_turn(robot, human, question)
|
|
74
|
+
robot.delegate(to: human, task: question).reply.to_s.strip
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
you.tell("Hi! I'd like to ask you a few questions.")
|
|
78
|
+
|
|
79
|
+
# A short *typed* intake first — the Cyborg validates and re-asks on bad input.
|
|
80
|
+
unless you.ask_confirm("Ready to begin?")
|
|
81
|
+
you.tell("No problem — maybe another time.")
|
|
82
|
+
exit
|
|
83
|
+
end
|
|
84
|
+
years = you.ask_int("Roughly how many years have you worked in your field?")
|
|
85
|
+
you.tell("Thanks. Answer freely from here, say \"skip\" to pass, or \"done\" to finish.")
|
|
86
|
+
|
|
87
|
+
# Seed the robot with the structured intake so it shows up in the profile.
|
|
88
|
+
robot.run(%(Context: they have about #{years || "an unstated number of"} years of experience. Acknowledge in one word.))
|
|
89
|
+
|
|
90
|
+
question = robot.run("Ask your first question.").reply.to_s.strip
|
|
91
|
+
answers_given = 0
|
|
92
|
+
|
|
93
|
+
until answers_given >= MAX_QUESTIONS
|
|
94
|
+
answer = interview_turn(robot, you, question)
|
|
95
|
+
break if ENDING_WORDS.include?(answer.downcase)
|
|
96
|
+
|
|
97
|
+
answers_given += 1
|
|
98
|
+
feedback = if answer.empty? || SKIPPING_WORDS.include?(answer.downcase)
|
|
99
|
+
"They preferred to skip that. Ask a different, lighter question."
|
|
100
|
+
elsif answers_given >= MAX_QUESTIONS
|
|
101
|
+
%(They answered: "#{answer}". That was the final question — reply with just "Thanks!")
|
|
102
|
+
else
|
|
103
|
+
%(They answered: "#{answer}". Ask your next question.)
|
|
104
|
+
end
|
|
105
|
+
# Feeding the answer back both records it in the robot's memory and produces
|
|
106
|
+
# the next question (the last one is discarded once the quota is reached).
|
|
107
|
+
question = robot.run(feedback).reply.to_s.strip
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
puts "\n …ProfileBot is building your profile…"
|
|
111
|
+
profile = robot.run(<<~PROMPT).reply.to_s.strip
|
|
112
|
+
The interview is complete. Using only what they told you, build their profile.
|
|
113
|
+
For each category give your classification and a one-line reason grounded in
|
|
114
|
+
their answers; write "insufficient information" where they did not reveal enough.
|
|
115
|
+
|
|
116
|
+
Technical proficiency:
|
|
117
|
+
Professional role:
|
|
118
|
+
Communication style:
|
|
119
|
+
Interests:
|
|
120
|
+
Decision-making style:
|
|
121
|
+
|
|
122
|
+
End with a one-sentence overall summary.
|
|
123
|
+
PROMPT
|
|
124
|
+
|
|
125
|
+
you.tell("Here is the profile I built from what you shared:\n\n#{profile}")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Presence & availability: routing work to a human who is actually there.
|
|
5
|
+
#
|
|
6
|
+
# A human peer publishes a presence — :online, :away, or :offline — that the
|
|
7
|
+
# network can use to route around or escalate for someone who isn't available,
|
|
8
|
+
# instead of blocking on a person who will never answer.
|
|
9
|
+
#
|
|
10
|
+
# :online takes work now
|
|
11
|
+
# :away still asked, but the caller should use a bounded timeout
|
|
12
|
+
# :offline declines inbound bus tasks immediately
|
|
13
|
+
#
|
|
14
|
+
# Key-free: the "humans" here are scripted, so the demo runs deterministically.
|
|
15
|
+
#
|
|
16
|
+
# ruby examples/04_presence_and_availability.rb
|
|
17
|
+
|
|
18
|
+
# Prefer the local robot_lab checkout (with the latest fixes) over any installed gem.
|
|
19
|
+
core_lib = File.expand_path("../../robot_lab/lib", __dir__)
|
|
20
|
+
$LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
|
|
21
|
+
|
|
22
|
+
require "robot_lab"
|
|
23
|
+
require_relative "../lib/robot_lab/cyborg"
|
|
24
|
+
|
|
25
|
+
Cyborg = RobotLab::Cyborg
|
|
26
|
+
Scripted = RobotLab::Cyborg::Channel::Scripted
|
|
27
|
+
|
|
28
|
+
bus = TypedBus::MessageBus.new
|
|
29
|
+
|
|
30
|
+
dispatcher = Cyborg.new(name: "dispatcher", bus: bus, channel: Scripted.new)
|
|
31
|
+
alice = Cyborg.new(name: "alice", bus: bus, channel: Scripted.new(["Approved — ship it.", "Signed off."])) # online
|
|
32
|
+
bob = Cyborg.new(name: "bob", bus: bus, channel: Scripted.new(["(bob should never be asked)"])) # offline
|
|
33
|
+
sam = Cyborg.new(name: "sam", bus: bus, channel: Scripted.new, ask_timeout: 0.4) # away, never answers
|
|
34
|
+
|
|
35
|
+
bob.offline!
|
|
36
|
+
sam.away!
|
|
37
|
+
|
|
38
|
+
# Wait briefly for a reply to a sent task; return its text or nil.
|
|
39
|
+
def await_reply(peer, key, timeout: 3)
|
|
40
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
41
|
+
loop do
|
|
42
|
+
entry = peer.outbox[key]
|
|
43
|
+
return entry[:replies].first&.content if entry && entry[:status] == :replied
|
|
44
|
+
return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
45
|
+
|
|
46
|
+
sleep 0.02
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ask_over_bus(dispatcher, name, task)
|
|
51
|
+
message = dispatcher.assign(to: name, task: task)
|
|
52
|
+
await_reply(dispatcher, message.key)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
puts "Presence of each peer:"
|
|
56
|
+
[alice, bob, sam].each { |p| puts " #{p.name.ljust(6)} #{p.presence} (available? #{p.available?})" }
|
|
57
|
+
|
|
58
|
+
puts "\n1) Route to the first *available* peer among [bob, alice]:"
|
|
59
|
+
approver = [bob, alice].find(&:available?)
|
|
60
|
+
puts " picked @#{approver.name} (bob is offline) -> asked over the bus"
|
|
61
|
+
puts " @#{approver.name} says: #{ask_over_bus(dispatcher, approver.name, 'Approve the deploy?')}"
|
|
62
|
+
|
|
63
|
+
puts "\n2) Ask @bob anyway (offline) — declined immediately, no hanging:"
|
|
64
|
+
puts " @bob replies: #{ask_over_bus(dispatcher, 'bob', 'Approve the deploy?')}"
|
|
65
|
+
|
|
66
|
+
puts "\n3) Ask @sam (away) with a 0.4s bound — times out, so escalate:"
|
|
67
|
+
if ask_over_bus(dispatcher, "sam", "Quick sign-off?").nil?
|
|
68
|
+
puts " @sam did not answer in time -> escalating to @alice"
|
|
69
|
+
puts " @alice says: #{ask_over_bus(dispatcher, 'alice', 'Quick sign-off?')}"
|
|
70
|
+
end
|