samagotchi 0.103.0 → 0.105.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +97 -21
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +15 -1
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +22 -1
- data/docs/internals/participants.md +167 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/internals/working-set.md +4 -3
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +89 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bridge.rb +17 -10
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/config.rb +1 -1
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +52 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +9 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/steer.rb +83 -10
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +303 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/index.html +6 -5
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +1 -1
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +44 -11
- data/lib/samagotchi/worker_inbound.rb +5 -2
- metadata +12 -1
data/docs/testing.md
CHANGED
|
@@ -22,6 +22,9 @@ with two rules only, `no-undef` (a name used but never imported or declared: a d
|
|
|
22
22
|
style pass: add a rule only for a bug class it would have caught. It runs in about 2 s, in CI's `npm test` job and in
|
|
23
23
|
`rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
|
|
24
24
|
global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
|
|
25
|
+
It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
|
|
26
|
+
Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); a pause that
|
|
27
|
+
is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`.
|
|
25
28
|
|
|
26
29
|
CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
|
|
27
30
|
(`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
|
|
@@ -66,11 +69,89 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
|
|
|
66
69
|
`repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
|
|
67
70
|
`CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
## Waiting and time
|
|
73
|
+
|
|
74
|
+
A spec waits *for* something and never sleeps to wait: a fixed pause is too short on a loaded runner (a parallel
|
|
75
|
+
run, CI) and too long on a fast one. The contract a new spec follows:
|
|
76
|
+
|
|
77
|
+
1. **Never `sleep` to wait.** Wait for the condition: `wait_for("what") { condition }`, or
|
|
78
|
+
`eventually { expect(…) }` for a value. A `sleep` that *is* the behaviour (slowness, holding a connection,
|
|
79
|
+
separating mtimes) carries `# sleep-ok: why` on its line or the line above (for a call over several lines, its
|
|
80
|
+
first line). A bounded best-effort `wait_until`
|
|
81
|
+
whose timeout is fine ("at most 0.5 s, fine to cut short") carries `# wait-ok: why`; it must not become
|
|
82
|
+
`wait_for`, which would raise where the code moves on. A `Timeout.timeout` is a wait too (use `pop!`, `join!` or
|
|
83
|
+
`wait_for`); one that only guards a call that returns by itself against a hang carries `# timeout-ok: why`.
|
|
84
|
+
2. **Time logic takes seams.** Intervals, graces and deadlines in lib take `clock:` / `sleeper:` / `interval:`
|
|
85
|
+
constructor arguments, and the unit spec moves the clock itself (`SessionAccess::Remote` is the model;
|
|
86
|
+
`IdleScheduler.new(interval:)`). Real time is used only in one smoke example per class. Two sessions or inbox
|
|
87
|
+
files that must sort apart get distinct times, not a pause: `Session.new_session(now:)`, `Session#save(now:)` and
|
|
88
|
+
`SessionInbox.write_input` / `write_note` / `write_output(now:)` take `now: next_stamp` (spec/support/stamps.rb:
|
|
89
|
+
a millisecond later on each call, starting a second in the past).
|
|
90
|
+
3. **What a spec starts, it stops and waits for** before teardown: `join!(thread, what)`, `with_task_dir { … }`
|
|
91
|
+
(spec/support/task_dirs.rb: a tasks' folder of its own, its tasks stopped and their exit-status recorders joined
|
|
92
|
+
with `TaskRuntime.quiesce!`), a `stop` that joins (`IdleScheduler#stop`). Never only `Thread#kill`.
|
|
93
|
+
4. **"Nothing happens" is shown by counting passes** (the worker's loop rounds, spec/support/worker_loop_rounds.rb;
|
|
94
|
+
a class's ticks), not by a pause.
|
|
95
|
+
5. **A wall-clock bound** only as "not the full N s", with at least 5× headroom measured on the slow (x86) laptop.
|
|
96
|
+
|
|
97
|
+
The helpers are the bundle test kit's (`Samagotchi::Testing::Waiting`, [plugins.md](plugins.md#testing-a-bundle-experimental)),
|
|
98
|
+
so core and bundle specs share one copy; `SpecWaiting` (spec/support/waiting.rb) includes them in every example
|
|
99
|
+
group and also answers them as module calls (`SpecWaiting.pop!`, for a stubbed lib thread):
|
|
100
|
+
|
|
101
|
+
- `wait_for(what, timeout: 5, interval: 0.01, seen: nil) { … }` returns the block's first truthy value, or raises
|
|
102
|
+
`Waiting::TimedOut`. `seen:` (a callable) shows what the condition last saw, since a falsy block value tells
|
|
103
|
+
nothing.
|
|
104
|
+
- `join!(thread, what, timeout: 5)` and `pop!(queue, what, timeout: 5)`: `Thread#join` and `Queue#pop` with the
|
|
105
|
+
same deadline and message. A gate a stubbed lib thread waits on takes `timeout: 30`: it only ends a hang.
|
|
106
|
+
- `wait_until(timeout:) { … }` returns the block's last value (falsy at the deadline): for a value the example
|
|
107
|
+
checks after, or a `wait-ok` wait.
|
|
108
|
+
- `eventually(timeout: 5) { expect(…) }` (spec only) retries until the expectations hold, then re-raises the last
|
|
109
|
+
failure prefixed `still failing after 5 s:`, so the matcher's own expected/got is in the message.
|
|
110
|
+
|
|
111
|
+
One formatter builds the failure text of `wait_for`, `join!` (which adds "to finish") and `pop!`:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
timed out after 5 s waiting for task 20261023-0a1b to end
|
|
115
|
+
timed out after 10.0 s (5 s × SPEC_TIME_SCALE 2) waiting for task 20261023-0a1b to end; last saw: "running"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**`SPEC_TIME_SCALE`** (default 1, at least 1; CI's `rspec`, `rspec-macos` and `bundles` jobs set 2) multiplies
|
|
119
|
+
every helper deadline, explicit `timeout:` values included: a positive wait only gets more headroom, and a passing
|
|
120
|
+
run is no slower. Set it locally to reproduce a CI timing (`SPEC_TIME_SCALE=2 bundle exec rspec FILE`). It does not
|
|
121
|
+
scale the lib's own timeouts a spec passes in (`wait: 0.05`, `grace: 0.1`): those are the code's deadlines, and a
|
|
122
|
+
flaky one gets a `clock:` seam instead. A helper's own spec pins the scale (stub `Waiting.time_scale`). Keep
|
|
123
|
+
explicit helper timeouts at 30 or below: 60 s on CI, under the per-example limit.
|
|
124
|
+
|
|
71
125
|
Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integration`, `macos_build`, `warmup` and
|
|
72
126
|
`recap` examples are exempt), so a hang fails its example instead of the run. `SPEC_EXAMPLE_TIMEOUT=N` changes the
|
|
73
|
-
limit, `0` turns it off (a debugger session).
|
|
127
|
+
limit, `0` turns it off (a debugger session). The limit is not scaled.
|
|
128
|
+
|
|
129
|
+
**The cops.** `Samagotchi/SpecSleep` (an untagged `sleep` in a spec) and `Samagotchi/SilentWait` (a `wait_until`
|
|
130
|
+
whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, or an untagged
|
|
131
|
+
`Timeout.timeout`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
|
|
132
|
+
`eventually`, `join!` and `pop!` as an example's expectation (`RSpec/NoExpectationExample`), so a wait can be an
|
|
133
|
+
example's last line. What the cops can't see, `script/affected-specs` hints at (`timing:`, below).
|
|
134
|
+
|
|
135
|
+
### Flakes
|
|
136
|
+
|
|
137
|
+
When `parallel_rspec` fails in CI's `rspec` or `rspec-macos` job, the next step (`script/rerun_failures.rb`) reruns
|
|
138
|
+
only the failed examples, once, with plain `rspec`. It reads their ids from `tmp/parallel_failures.log`
|
|
139
|
+
(`.rspec_parallel`'s `FailuresLogger`) and writes the job summary: **Flaky: passed on rerun** or **Failed twice**,
|
|
140
|
+
with the example ids, the Ruby version and the job. It skips the rerun when no ids were logged (a load error) or more
|
|
141
|
+
than 20 failed (a real breakage). The job fails either way: a flake is a bug to fix, not a pass.
|
|
142
|
+
|
|
143
|
+
Every flake is classified into one of the four patterns, each with its own fix:
|
|
144
|
+
|
|
145
|
+
1. **A settle sleep**: the spec paused for "long enough". Wait for the condition (`wait_for`, `eventually`).
|
|
146
|
+
2. **A live loop racing the spec's own drive of it** (a worker loop, a scheduler tick the spec also calls). Count
|
|
147
|
+
passes, or stop the loop before driving it by hand.
|
|
148
|
+
3. **Teardown before background work stops**: a thread, task or process still running when the example's `after`
|
|
149
|
+
removes its folder. Stop it and wait for it (`join!`, `with_task_dir`, a joining `stop`).
|
|
150
|
+
4. **A lib deadline on the real clock** (`wait: 0.05`, `grace: 0.1`) too short on a loaded runner. Give the class a
|
|
151
|
+
`clock:` / `sleeper:` seam and move the clock in the spec.
|
|
152
|
+
|
|
153
|
+
A second flake of the same pattern at a new site means that pattern needs a seam in lib there (for example an `on_*:`
|
|
154
|
+
callback the spec can wait on), not another local fix in the spec.
|
|
74
155
|
|
|
75
156
|
## Bundle specs: the test kit
|
|
76
157
|
|
|
@@ -140,7 +221,11 @@ words the REPL prints, pick it by a rule), then the other name matches (a spec n
|
|
|
140
221
|
`toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
|
|
141
222
|
`spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
|
|
142
223
|
The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" (with `npm run lint` too when a frontend module changed) and a
|
|
143
|
-
hint for a hub class (named in too many specs to list) go to stderr
|
|
224
|
+
hint for a hub class (named in too many specs to list) go to stderr, and so do the `timing:` hints, from the lines a
|
|
225
|
+
changed spec adds (`git diff -U0` against the base; an untracked spec, all its lines): a `Thread.new`, `.start`,
|
|
226
|
+
`spawn` or `fork` with no `join!`, `wait_for`, `with_task_dir` or `Process.wait` added in the same file, and a
|
|
227
|
+
sub-second `be < 0.x` bound ([Waiting and time](#waiting-and-time), rules 3 and 5). Nothing printed means nothing
|
|
228
|
+
matched.
|
|
144
229
|
|
|
145
230
|
It finds a change's own specs, not the regressions it causes elsewhere. So before committing, run the full suite
|
|
146
231
|
(`bundle exec parallel_rspec -n 8 spec lib/samagotchi/bundles/*/spec`), unless your task says the coordinator runs it; the coordinator runs it at merge
|
|
@@ -236,13 +236,14 @@ module Samagotchi
|
|
|
236
236
|
case type
|
|
237
237
|
when :command_queued
|
|
238
238
|
# A card's action shows as its card, never as a line (the UIs skip it live).
|
|
239
|
-
@queued_commands << event.slice(:command_id, :client_id, :line) if event[:waits] && !event[:card]
|
|
239
|
+
@queued_commands << event.slice(:command_id, :client_id, :line, :author) if event[:waits] && !event[:card]
|
|
240
240
|
when :command_ran
|
|
241
241
|
@queued_commands.reject! { |entry| entry[:command_id] == event[:command_id] } if event[:queued]
|
|
242
242
|
when :turn_enqueued
|
|
243
243
|
queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
|
|
244
244
|
queued[:images] = event[:images] if event[:images]
|
|
245
245
|
queued[:delivery] = event[:delivery] if event[:delivery]
|
|
246
|
+
queued[:author] = event[:author] if event[:author]
|
|
246
247
|
@queued << queued
|
|
247
248
|
# Taken back before it ran (Bridge#handle_dequeue_turn).
|
|
248
249
|
when :turn_dequeued
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -322,11 +322,12 @@ module Samagotchi
|
|
|
322
322
|
# @param after_file [String, nil] the input file it came in (its
|
|
323
323
|
# basename): it runs once that file is taken (Worker#on_command)
|
|
324
324
|
# @return [String] its command_id
|
|
325
|
-
def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil)
|
|
325
|
+
def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil, author: nil)
|
|
326
326
|
# A card's action (the step-limit question's answer): the UIs leave
|
|
327
327
|
# its line out. +enqueued_id+: a file-delivered command's input, whose
|
|
328
|
-
# sender's wait its command_ran settles.
|
|
329
|
-
command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card
|
|
328
|
+
# sender's wait its command_ran settles. +author+: its input's.
|
|
329
|
+
command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card, author: author)
|
|
330
|
+
.with(enqueued_id: enqueued_id)
|
|
330
331
|
@engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
|
|
331
332
|
command.command_id
|
|
332
333
|
end
|
|
@@ -792,10 +793,11 @@ module Samagotchi
|
|
|
792
793
|
|
|
793
794
|
# Queues +line+ (a session command) for the worker and announces its
|
|
794
795
|
# :command_queued, unless +deadline+ has passed. Returns [headers,
|
|
795
|
-
# status, body]: 202 with its command_id, or 408.
|
|
796
|
-
|
|
796
|
+
# status, body]: 202 with its command_id, or 408. +author+: a POST
|
|
797
|
+
# /turn's (a /word sent as a message).
|
|
798
|
+
def queue_command_request(session_id, line, client_id:, deadline: nil, card: false, author: nil)
|
|
797
799
|
# A card's action: the UIs leave its line out (the card is the echo).
|
|
798
|
-
command = QueuedCommand.mint(line, client_id: client_id, card: card)
|
|
800
|
+
command = QueuedCommand.mint(line, client_id: client_id, card: card, author: author)
|
|
799
801
|
queued = @engine.synchronize_events do
|
|
800
802
|
raise IntakeClosed if stopped?
|
|
801
803
|
next false if expired?("command_expired", deadline, sid: session_id, client_id: command.client_id)
|
|
@@ -892,6 +894,10 @@ module Samagotchi
|
|
|
892
894
|
client_id = fetched(parsed, "client_id")
|
|
893
895
|
# --no-interrupt: the turn runs with the raised iteration limit.
|
|
894
896
|
no_interrupt = fetched(parsed, "no_interrupt") == true
|
|
897
|
+
# Who wrote it when it isn't the owner (chi web stamps it from the
|
|
898
|
+
# request's principal; the Bridge is loopback and trusts its caller).
|
|
899
|
+
author = fetched(parsed, "author")
|
|
900
|
+
author = nil unless author.is_a?(String) && !author.empty?
|
|
895
901
|
if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
|
|
896
902
|
return [{ "Allow" => "POST" }, 400,
|
|
897
903
|
{ error: "missing_fields", detail: "session_id and prompt are required" }]
|
|
@@ -916,7 +922,7 @@ module Samagotchi
|
|
|
916
922
|
# TUI; an unknown /word stays a prompt. Not with images: those are
|
|
917
923
|
# for the model.
|
|
918
924
|
if images.empty? && @on_command && command_registry.command?(prompt.to_s)
|
|
919
|
-
return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
|
|
925
|
+
return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
|
|
920
926
|
end
|
|
921
927
|
|
|
922
928
|
enqueued_id = SecureRandom.uuid
|
|
@@ -935,12 +941,13 @@ module Samagotchi
|
|
|
935
941
|
|
|
936
942
|
queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
|
|
937
943
|
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
938
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery).tap do |ok|
|
|
944
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author).tap do |ok|
|
|
939
945
|
next unless ok
|
|
940
946
|
|
|
941
947
|
enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
|
|
942
948
|
enqueued_event[:images] = images unless images.empty?
|
|
943
949
|
enqueued_event[:delivery] = Delivery::QUEUE if queued_behind_turn
|
|
950
|
+
enqueued_event[:author] = author if author
|
|
944
951
|
@engine.announce(enqueued_event)
|
|
945
952
|
@on_input&.call
|
|
946
953
|
end
|
|
@@ -1082,9 +1089,9 @@ module Samagotchi
|
|
|
1082
1089
|
|
|
1083
1090
|
# Write a turn into this session's input dir, reusing the file IPC the
|
|
1084
1091
|
# worker polls. Never calls run_turn across the boundary.
|
|
1085
|
-
def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil)
|
|
1092
|
+
def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil)
|
|
1086
1093
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1087
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery)
|
|
1094
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
|
|
1088
1095
|
end
|
|
1089
1096
|
|
|
1090
1097
|
# ── HTTP plumbing ────────────────────────────────────────────────────────
|
|
@@ -31,8 +31,9 @@ module Samagotchi
|
|
|
31
31
|
# WebBridgeClient), where the client stays the same object across
|
|
32
32
|
# workers: +epoch_watch+ (a frame whose id has another epoch than the
|
|
33
33
|
# cursor's is a new worker: worker_changed first, the client's key
|
|
34
|
-
# updated), +announce_retries+ (stream_retrying once a reconnect fails
|
|
35
|
-
#
|
|
34
|
+
# updated), +announce_retries+ (stream_retrying once a reconnect fails
|
|
35
|
+
# and rediscovery neither moved nor ended the stream, stream_resumed
|
|
36
|
+
# once a stream answers again), +idle_timeout+ (no byte, not even
|
|
36
37
|
# the Bridge's 15 s ping, for that long: a half-open connection,
|
|
37
38
|
# reconnected with the cursor).
|
|
38
39
|
class EventStream
|
|
@@ -125,7 +126,8 @@ module Samagotchi
|
|
|
125
126
|
next
|
|
126
127
|
end
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
# Rediscovery first: a stopped session or a new worker is no
|
|
130
|
+
# retry to announce (a stop prints its line alone).
|
|
129
131
|
case rediscover_worker
|
|
130
132
|
when :moved
|
|
131
133
|
failures = 0
|
|
@@ -133,6 +135,7 @@ module Samagotchi
|
|
|
133
135
|
next
|
|
134
136
|
when :gone then return finish(@reason || "unreachable")
|
|
135
137
|
end
|
|
138
|
+
announce_retry
|
|
136
139
|
|
|
137
140
|
failures += 1
|
|
138
141
|
return finish(@reason || "unreachable") if failures > @reconnect_delays.size
|
|
@@ -153,6 +156,9 @@ module Samagotchi
|
|
|
153
156
|
return :unknown_session if status == 404
|
|
154
157
|
return :unreachable unless status == 200
|
|
155
158
|
|
|
159
|
+
# Back as soon as the stream answers: a replay may bring nothing
|
|
160
|
+
# (the session was quiet), and the next frame could be minutes away.
|
|
161
|
+
announce_resumed
|
|
156
162
|
yielded = false
|
|
157
163
|
parser = SSEParser.new
|
|
158
164
|
loop do
|
|
@@ -163,7 +169,6 @@ module Samagotchi
|
|
|
163
169
|
event = parse(frame[:data])
|
|
164
170
|
next unless event
|
|
165
171
|
|
|
166
|
-
announce_resumed
|
|
167
172
|
watch_epoch(frame[:id]) if @epoch_watch
|
|
168
173
|
@last_event_id = frame[:id] if frame[:id]
|
|
169
174
|
yielded = true
|
|
@@ -165,9 +165,12 @@ module Samagotchi
|
|
|
165
165
|
# @param no_interrupt [Boolean] run the turn with the raised iteration limit
|
|
166
166
|
# @param images [Array<Hash>] refs ({file:, name:}) to images already in
|
|
167
167
|
# the session's images/
|
|
168
|
-
|
|
168
|
+
# @param author [String, nil] who wrote it when it isn't the session's
|
|
169
|
+
# owner; sent only when given (SessionManager.deliver_turn)
|
|
170
|
+
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil)
|
|
169
171
|
body = { session_id: @session_id, prompt: prompt, client_id: client_id }
|
|
170
172
|
body[:no_interrupt] = true if no_interrupt
|
|
173
|
+
body[:author] = author if author
|
|
171
174
|
body[:images] = images if images && !images.empty?
|
|
172
175
|
body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
|
|
173
176
|
body[:deadline] = deadline
|
|
@@ -2,12 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
# How the follow-up commands chi prints (ParentReport's chi answer and
|
|
5
|
-
# chi send --wait, a stop's chi --resume
|
|
6
|
-
# when pasted: on this machine plain
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# no remote form: #attach and #resume are nil there, and #open_it and
|
|
10
|
-
# #shows name the remote's chi web instead.
|
|
5
|
+
# chi send --wait, a stop's chi --resume, the attached TUI's re-attach
|
|
6
|
+
# lines) are spelled so that they work when pasted: on this machine plain
|
|
7
|
+
# chi, against a remote (chi --remote NAME, CHI_REMOTE=NAME alike) chi
|
|
8
|
+
# --remote NAME. A remote's chi web is named where the page is meant.
|
|
11
9
|
# @!attribute remote [String, nil] the remote's name; nil: this machine
|
|
12
10
|
ChiInvocation = Data.define(:remote) do
|
|
13
11
|
def remote? = !remote.nil?
|
|
@@ -18,19 +16,18 @@ module Samagotchi
|
|
|
18
16
|
# +words+ after #chi: command("answer ID …").
|
|
19
17
|
def command(words) = "#{chi} #{words}"
|
|
20
18
|
|
|
21
|
-
# chi --attach ID
|
|
22
|
-
def attach(id) = ("
|
|
19
|
+
# chi --attach ID (chi --remote NAME --attach ID).
|
|
20
|
+
def attach(id) = command("--attach #{id}")
|
|
23
21
|
|
|
24
|
-
# chi --resume ID
|
|
25
|
-
def resume(id) = ("
|
|
22
|
+
# chi --resume ID (chi --remote NAME --resume ID).
|
|
23
|
+
def resume(id) = command("--resume #{id}")
|
|
26
24
|
|
|
27
|
-
# What shows the session ("… shows it"):
|
|
28
|
-
|
|
29
|
-
def shows(id) = attach(id) || web
|
|
25
|
+
# What shows the session ("… shows it"): its attach.
|
|
26
|
+
def shows(id) = attach(id)
|
|
30
27
|
|
|
31
|
-
# Where the user opens it ("open it: …"):
|
|
32
|
-
#
|
|
33
|
-
def open_it(id) =
|
|
28
|
+
# Where the user opens it ("open it: …"): its attach, or the web (the
|
|
29
|
+
# remote's chi web).
|
|
30
|
+
def open_it(id) = "#{attach(id)} or #{remote? ? web : "the web"}"
|
|
34
31
|
|
|
35
32
|
# The remote's chi web, by name.
|
|
36
33
|
def web = "chi web on #{remote}"
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -306,7 +306,7 @@ module Samagotchi
|
|
|
306
306
|
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
|
|
307
307
|
# The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
|
|
308
308
|
# footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
|
|
309
|
-
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default:
|
|
309
|
+
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
|
|
310
310
|
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
311
311
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
|
|
312
312
|
# What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
|
|
@@ -116,13 +116,20 @@ module Samagotchi
|
|
|
116
116
|
# last status line's bucket is where rises count from)
|
|
117
117
|
# @param llm_context [LLMContextStrategy::Resolved, nil] the turn's
|
|
118
118
|
# strategy (its budget)
|
|
119
|
-
|
|
119
|
+
# @param counted_chars_per_token [Float, nil] the last turn's
|
|
120
|
+
# #counted_chars_per_token: the turn's estimate before its first count
|
|
121
|
+
# uses it instead of the configured ratio, so the value doesn't dip at
|
|
122
|
+
# a turn's start (the chat loop's chars leave out the tool schemas the
|
|
123
|
+
# server counts)
|
|
124
|
+
def initialize(conversation: [], llm_context: nil, counted_chars_per_token: nil)
|
|
120
125
|
@budget = llm_context&.budget_tokens
|
|
121
126
|
@forget = llm_context&.forget? || false
|
|
122
127
|
@offered = self.class.last_line(conversation).to_s.include?(OFFER_TOOL)
|
|
123
128
|
@enabled = self.class.enabled?
|
|
124
129
|
chars_per_token = Config.get("context.chars_per_token").to_f
|
|
125
130
|
@chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
|
|
131
|
+
@estimate_chars_per_token = counted_chars_per_token.to_f.positive? ? counted_chars_per_token.to_f : @chars_per_token
|
|
132
|
+
@counted_chars_per_token = nil
|
|
126
133
|
parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
|
|
127
134
|
.select { |value| value.between?(1, 99) }.uniq.sort
|
|
128
135
|
@thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
|
|
@@ -137,6 +144,10 @@ module Samagotchi
|
|
|
137
144
|
# @return [Hash, nil] the status line's value ({est_pct:, bucket:});
|
|
138
145
|
# nil with context.status off or before the first request
|
|
139
146
|
attr_reader :display
|
|
147
|
+
# @return [Float, nil] the chars per token of the last request the
|
|
148
|
+
# server counted (its prompt chars over its prompt tokens, images
|
|
149
|
+
# aside), for the next turn's tracker; nil before a count
|
|
150
|
+
attr_reader :counted_chars_per_token
|
|
140
151
|
|
|
141
152
|
def enabled? = @enabled
|
|
142
153
|
|
|
@@ -212,6 +223,8 @@ module Samagotchi
|
|
|
212
223
|
|
|
213
224
|
@counted = { chars: prompt_chars, image_tokens: image_tokens }
|
|
214
225
|
@edited = false
|
|
226
|
+
text_tokens = usage[:prompt_tokens].to_i - image_tokens.to_i
|
|
227
|
+
@counted_chars_per_token = prompt_chars.to_f / text_tokens if prompt_chars.positive? && text_tokens.positive?
|
|
215
228
|
value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
|
|
216
229
|
@display = value if value
|
|
217
230
|
end
|
|
@@ -243,7 +256,7 @@ module Samagotchi
|
|
|
243
256
|
source = "server"
|
|
244
257
|
else
|
|
245
258
|
window_tokens = counted_against(window.tokens)
|
|
246
|
-
used_tokens = (prompt_chars / @
|
|
259
|
+
used_tokens = (prompt_chars / @estimate_chars_per_token).ceil + image_tokens
|
|
247
260
|
source = "estimate"
|
|
248
261
|
end
|
|
249
262
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -85,6 +85,7 @@ require_relative "guardrail_wiring"
|
|
|
85
85
|
require_relative "plugin_tasks"
|
|
86
86
|
require_relative "prompt_warmup"
|
|
87
87
|
require_relative "working_set"
|
|
88
|
+
require_relative "session_participants"
|
|
88
89
|
|
|
89
90
|
module Samagotchi
|
|
90
91
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -218,6 +219,11 @@ module Samagotchi
|
|
|
218
219
|
Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
|
|
219
220
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
220
221
|
@prompt_builder = build_system_prompt(memories, appended_prompt_files)
|
|
222
|
+
# The session's participants (participants.json), read at each turn's
|
|
223
|
+
# start: their names for the messages they wrote.
|
|
224
|
+
@participants = SessionParticipants.new(state_dir: -> { session_state_dir })
|
|
225
|
+
# Whether the system prompt carries the participants line.
|
|
226
|
+
@participants_line = false
|
|
221
227
|
@session = nil
|
|
222
228
|
@session_observer = SessionObserver.new
|
|
223
229
|
@metrics = SessionMetrics.new
|
|
@@ -414,7 +420,8 @@ module Samagotchi
|
|
|
414
420
|
SystemPrompt.new(profile: -> { profile }, tools: -> { @tools }, session: -> { @session },
|
|
415
421
|
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
416
422
|
model_notes: -> { model_notes }, memories: memories,
|
|
417
|
-
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files
|
|
423
|
+
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files,
|
|
424
|
+
participants: -> { @participants_line })
|
|
418
425
|
end
|
|
419
426
|
private :build_system_prompt
|
|
420
427
|
|
|
@@ -1462,9 +1469,35 @@ module Samagotchi
|
|
|
1462
1469
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1463
1470
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
1464
1471
|
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
1472
|
+
author: Steer.sender_author(origin.is_a?(Hash) ? origin[:author] : nil),
|
|
1465
1473
|
**stop_facts(event))
|
|
1466
1474
|
end
|
|
1467
1475
|
|
|
1476
|
+
# Reads the session's participants at a turn's start; the first to join
|
|
1477
|
+
# and the last to leave rebuild the system prompt (its participants
|
|
1478
|
+
# line), once each: a turn in between sends the same prefix.
|
|
1479
|
+
def refresh_participants!(session)
|
|
1480
|
+
active = @participants.refresh!(session).active?
|
|
1481
|
+
return if active == @participants_line
|
|
1482
|
+
|
|
1483
|
+
@participants_line = active
|
|
1484
|
+
@prompt_builder.reset!
|
|
1485
|
+
end
|
|
1486
|
+
private :refresh_participants!
|
|
1487
|
+
|
|
1488
|
+
# The author fields a message +author+ wrote carries (Steer.author_fields:
|
|
1489
|
+
# author:, and author_name: from participants.json), {} for the owner.
|
|
1490
|
+
# The worker's drain names a participant's merged lines with it, and
|
|
1491
|
+
# its running save a participant's prompt.
|
|
1492
|
+
# @param origin [Hash, String, nil] a turn's origin, or an author
|
|
1493
|
+
# @param session [Session, nil] the session it is for (the turn's)
|
|
1494
|
+
def author_fields(origin, session: @session)
|
|
1495
|
+
author = origin.is_a?(Hash) ? origin[:author] : origin
|
|
1496
|
+
return {} unless Steer.participant_author?(author)
|
|
1497
|
+
|
|
1498
|
+
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1468
1501
|
# Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
|
|
1469
1502
|
# parent agent's wait (ReplyWait): a provider error's kind and whether
|
|
1470
1503
|
# a retry may help, a cancel's reason and the hook that stopped it.
|
|
@@ -1858,6 +1891,11 @@ module Samagotchi
|
|
|
1858
1891
|
@recap&.write_now(on_start: on_start)
|
|
1859
1892
|
end
|
|
1860
1893
|
|
|
1894
|
+
# The longest #write_recap_now waits (recap.timeout), nil when no recap
|
|
1895
|
+
# is set up.
|
|
1896
|
+
# @return [Float, nil]
|
|
1897
|
+
def recap_timeout = @recap&.timeout
|
|
1898
|
+
|
|
1861
1899
|
# Ask for a recap now (/recap), without waiting for the idle window.
|
|
1862
1900
|
# @return [Symbol] IdleRecap#request_now's answer, or :off
|
|
1863
1901
|
def request_recap
|
|
@@ -1887,9 +1925,12 @@ module Samagotchi
|
|
|
1887
1925
|
self
|
|
1888
1926
|
end
|
|
1889
1927
|
|
|
1890
|
-
# Stop the shared idle scheduler (TerminalUI calls this when the REPL
|
|
1891
|
-
|
|
1892
|
-
|
|
1928
|
+
# Stop the shared idle scheduler (TerminalUI calls this when the REPL
|
|
1929
|
+
# exits). +join+: wait for a tick under way (IdleScheduler#stop); a
|
|
1930
|
+
# caller holding #synchronize_events passes false, since a recap's emit
|
|
1931
|
+
# in that tick waits for the same lock.
|
|
1932
|
+
def stop_idle(join: true)
|
|
1933
|
+
@idle_scheduler&.stop(join: join)
|
|
1893
1934
|
self
|
|
1894
1935
|
end
|
|
1895
1936
|
|
|
@@ -1947,9 +1988,11 @@ module Samagotchi
|
|
|
1947
1988
|
# run it resumes started (ModelResult#run_start, kept in the offer by
|
|
1948
1989
|
# TurnFlow), so it keeps that run's reasoning; nil starts cold (a
|
|
1949
1990
|
# model change while the offer stood, every other turn).
|
|
1950
|
-
# @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:}
|
|
1951
|
-
# when
|
|
1952
|
-
#
|
|
1991
|
+
# @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:},
|
|
1992
|
+
# and author: when someone other than the session's owner wrote it, from
|
|
1993
|
+
# its input file; absent for the owner); when given, the turn's boundary
|
|
1994
|
+
# events (:turn_started, :turn_completed, :turn_canceled, :turn_failed)
|
|
1995
|
+
# carry it as `origin:`, and GuardrailWiring#begin_turn gets it
|
|
1953
1996
|
# @param images [Array<Hash>] the prompt's images: {path:} (a file on this
|
|
1954
1997
|
# machine; in-process and attached-TUI callers only) or {file:, name:}
|
|
1955
1998
|
# (already in the session's images/, e.g. a web upload). They are
|
|
@@ -2093,6 +2136,7 @@ module Samagotchi
|
|
|
2093
2136
|
# prompt built next declares, unchanged until the next turn's start.
|
|
2094
2137
|
apply_staged_tools!
|
|
2095
2138
|
apply_toolkit!(session, turn.on_event)
|
|
2139
|
+
refresh_participants!(session)
|
|
2096
2140
|
# A Stop during the wait above: the turn ends here,
|
|
2097
2141
|
# before the hooks run or a reminder is used up.
|
|
2098
2142
|
return false if turn.controller.cancelled?
|
|
@@ -2221,7 +2265,7 @@ module Samagotchi
|
|
|
2221
2265
|
end
|
|
2222
2266
|
|
|
2223
2267
|
unless turn.continue
|
|
2224
|
-
user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
|
|
2268
|
+
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin) }
|
|
2225
2269
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2226
2270
|
turn.messages << user_message
|
|
2227
2271
|
session.last_prompt = turn.prompt
|
|
@@ -83,12 +83,18 @@ module Samagotchi
|
|
|
83
83
|
@protected = nil
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
# A turn starts: its origin
|
|
86
|
+
# A turn starts: its origin (Engine#run_turn's: {client_id:,
|
|
87
|
+
# enqueued_id:, author:}, each optional), and the git facts read afresh.
|
|
87
88
|
def begin_turn(origin)
|
|
88
89
|
@origin = origin
|
|
89
90
|
@git = Guardrails::GitInfo.new
|
|
90
91
|
end
|
|
91
92
|
|
|
93
|
+
# Who wrote the running turn when it isn't the session's owner (its
|
|
94
|
+
# origin's author); nil for the owner's turns and every other origin.
|
|
95
|
+
# @return [String, nil]
|
|
96
|
+
def turn_author = @origin.is_a?(Hash) ? @origin[:author] : nil
|
|
97
|
+
|
|
92
98
|
# The gate every tool call asks first.
|
|
93
99
|
# @return [Guardrails::Gate]
|
|
94
100
|
def gate
|
|
@@ -254,7 +254,7 @@ module Samagotchi
|
|
|
254
254
|
end
|
|
255
255
|
|
|
256
256
|
# @return [Array(Integer, Integer)] the recap length range, e.g. [2, 4]
|
|
257
|
-
attr_reader :generation, :inactivity, :min_user_turns, :sentences
|
|
257
|
+
attr_reader :generation, :inactivity, :min_user_turns, :sentences, :timeout
|
|
258
258
|
|
|
259
259
|
# @param callable [#call] true while a continue offer waits for an
|
|
260
260
|
# answer (the Worker's or the REPL's TurnFlow); read at each attempt
|
|
@@ -18,23 +18,37 @@ module Samagotchi
|
|
|
18
18
|
# raises is logged and isolated — the remaining jobs keep polling.
|
|
19
19
|
class IdleScheduler
|
|
20
20
|
POLL_INTERVAL_SECONDS = 0.5
|
|
21
|
+
# How long #stop waits for the thread to finish its tick before it
|
|
22
|
+
# kills it.
|
|
23
|
+
STOP_JOIN_SECONDS = 1.0
|
|
24
|
+
THREAD_NAME = "idle_scheduler"
|
|
21
25
|
|
|
22
|
-
attr_reader :engine, :jobs
|
|
26
|
+
attr_reader :engine, :jobs, :interval
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
# +interval+: the pause between ticks (a spec passes a long one, so its
|
|
29
|
+
# own #tick is the only one after the first).
|
|
30
|
+
def initialize(engine:, jobs: [], interval: POLL_INTERVAL_SECONDS)
|
|
25
31
|
raise ArgumentError, "IdleScheduler requires an engine" unless engine
|
|
26
32
|
|
|
27
33
|
@engine = engine
|
|
28
34
|
@jobs = jobs.compact.freeze
|
|
35
|
+
@interval = interval
|
|
36
|
+
# The loop's pause: a pop with the interval as its timeout, which
|
|
37
|
+
# #stop ends at once by pushing.
|
|
38
|
+
@wake = Queue.new
|
|
29
39
|
@thread = nil
|
|
40
|
+
# The thread a stop(join: false) left to finish its tick.
|
|
41
|
+
@unjoined = nil
|
|
30
42
|
@stopped = false
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
# Spawn the polling thread (idempotent; no-op when there are no jobs).
|
|
46
|
+
# A stopped scheduler doesn't start again.
|
|
34
47
|
def start
|
|
35
48
|
return self if running? || @jobs.empty?
|
|
36
49
|
|
|
37
50
|
@thread = Thread.new { run_loop }
|
|
51
|
+
@thread.name = THREAD_NAME
|
|
38
52
|
self
|
|
39
53
|
end
|
|
40
54
|
|
|
@@ -42,11 +56,24 @@ module Samagotchi
|
|
|
42
56
|
!@thread.nil? && @thread.alive? && !@stopped
|
|
43
57
|
end
|
|
44
58
|
|
|
45
|
-
# Stop the polling thread (idempotent)
|
|
46
|
-
|
|
59
|
+
# Stop the polling thread (idempotent): it ticks no more and its pause
|
|
60
|
+
# ends at once. +join+: wait up to STOP_JOIN_SECONDS for a tick under
|
|
61
|
+
# way to finish, then kill the thread. A caller holding a lock a tick
|
|
62
|
+
# may take (Worker#leave_idle holds the event log, which a recap's emit
|
|
63
|
+
# takes) passes join: false: the thread finishes its tick and exits by
|
|
64
|
+
# itself, and a later joining stop (Engine#shutdown, the worker's
|
|
65
|
+
# teardown) still joins it. From the scheduler's own thread (a job's
|
|
66
|
+
# tick) it never joins.
|
|
67
|
+
def stop(join: true)
|
|
47
68
|
@stopped = true
|
|
48
|
-
@
|
|
69
|
+
@wake << :stop
|
|
70
|
+
thread = @thread || @unjoined
|
|
49
71
|
@thread = nil
|
|
72
|
+
@unjoined = join ? nil : thread
|
|
73
|
+
return if thread.nil? || !join || thread == Thread.current
|
|
74
|
+
|
|
75
|
+
thread.kill unless thread.join(STOP_JOIN_SECONDS)
|
|
76
|
+
nil
|
|
50
77
|
end
|
|
51
78
|
|
|
52
79
|
# Swap the job +old+ for +new+ (Engine#session= rebuilding the recap):
|
|
@@ -78,11 +105,9 @@ module Samagotchi
|
|
|
78
105
|
private
|
|
79
106
|
|
|
80
107
|
def run_loop
|
|
81
|
-
|
|
82
|
-
break if @stopped
|
|
83
|
-
|
|
108
|
+
until @stopped
|
|
84
109
|
tick
|
|
85
|
-
|
|
110
|
+
@wake.pop(timeout: @interval)
|
|
86
111
|
end
|
|
87
112
|
rescue StandardError => e
|
|
88
113
|
Log.exception(:idle, "scheduler_crashed", e, echo: "[IdleScheduler] scheduler thread crashed: #{e.class}: #{e.message}")
|