samagotchi 0.18.0 → 0.19.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -1
  3. data/README.md +4 -3
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +15 -2
  6. data/docs/cli.md +41 -9
  7. data/docs/configuration.md +8 -4
  8. data/docs/guardrails.md +8 -0
  9. data/docs/memory.md +4 -0
  10. data/docs/sessions.md +6 -4
  11. data/lib/samagotchi/bridge.rb +25 -9
  12. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  13. data/lib/samagotchi/bridge_client.rb +9 -5
  14. data/lib/samagotchi/bundle_command.rb +2 -1
  15. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  16. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/client.rb +58 -10
  20. data/lib/samagotchi/engine.rb +35 -11
  21. data/lib/samagotchi/guardrail_wiring.rb +64 -5
  22. data/lib/samagotchi/guardrails/approval.rb +10 -4
  23. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  24. data/lib/samagotchi/host_registry.rb +22 -4
  25. data/lib/samagotchi/idle_scheduler.rb +1 -1
  26. data/lib/samagotchi/installed_versions.rb +91 -0
  27. data/lib/samagotchi/live_versions.rb +5 -3
  28. data/lib/samagotchi/llm/http.rb +9 -3
  29. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  31. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  32. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  33. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  34. data/lib/samagotchi/model_overlay.rb +39 -0
  35. data/lib/samagotchi/model_profile.rb +6 -2
  36. data/lib/samagotchi/output_formatter.rb +9 -4
  37. data/lib/samagotchi/relay_desk.rb +9 -0
  38. data/lib/samagotchi/served_model.rb +14 -4
  39. data/lib/samagotchi/session_manager.rb +166 -11
  40. data/lib/samagotchi/session_metrics.rb +36 -13
  41. data/lib/samagotchi/sessions_command.rb +43 -4
  42. data/lib/samagotchi/system_prompt.rb +10 -1
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
  44. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  45. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  46. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  47. data/lib/samagotchi/terminal_ui.rb +65 -16
  48. data/lib/samagotchi/tool_declarations.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +21 -5
  50. data/lib/samagotchi/tools/execute.rb +97 -19
  51. data/lib/samagotchi/turn_note.rb +3 -2
  52. data/lib/samagotchi/update_command.rb +33 -12
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +64 -11
  55. data/lib/samagotchi/web/public/app.js +102 -29
  56. data/lib/samagotchi/web/public/data.js +6 -0
  57. data/lib/samagotchi/web/public/format.js +4 -2
  58. data/lib/samagotchi/web/public/index.html +11 -0
  59. data/lib/samagotchi/web/public/question_card.js +12 -0
  60. data/lib/samagotchi/web/public/question_cards.js +17 -11
  61. data/lib/samagotchi/web/public/scope.js +15 -6
  62. data/lib/samagotchi/web/public/session_state.js +4 -0
  63. data/lib/samagotchi/web/public/turn_events.js +107 -3
  64. data/lib/samagotchi/web/public/update_notice.js +87 -14
  65. data/lib/samagotchi/web/server.rb +28 -1
  66. data/lib/samagotchi/web/session_hub.rb +30 -3
  67. data/lib/samagotchi/web/session_summary.rb +5 -0
  68. data/lib/samagotchi/worker.rb +25 -8
  69. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  70. data/lib/samagotchi/worker_sidecar.rb +7 -5
  71. metadata +4 -1
@@ -20,10 +20,69 @@ export function workerGoneText({ turnRunning, stopped = false }) {
20
20
  return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
21
21
  }
22
22
 
23
+ // How long a turn's answer waits for the after_turn hooks' answer_display
24
+ // before the page stops waiting and pops what it has (4.14).
25
+ export const DISPLAY_WAIT_MS = 20000;
26
+
27
+ // The wait between a turn_completed with display_pending and its
28
+ // answer_display. The worker can die in between (a crash, a stop, an idle
29
+ // exit) and never send one, so the wait also ends on the caller's finish()
30
+ // (workerGone, a re-render) and by the timeout: it must never hold the
31
+ // turn's answer bubble forever.
32
+ // @return {{promise, finish, settled: () => Boolean}}
33
+ export function displayWait({
34
+ timeoutMs = DISPLAY_WAIT_MS, setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout,
35
+ } = {}) {
36
+ let settle = null;
37
+ let done = false;
38
+ const promise = new Promise((resolve) => { settle = resolve; });
39
+ const finish = () => {
40
+ if (done) return;
41
+ done = true;
42
+ clearTimeoutImpl(timer);
43
+ settle();
44
+ };
45
+ const timer = setTimeoutImpl(finish, timeoutMs);
46
+ return { promise, finish, settled: () => done };
47
+ }
48
+
23
49
  // This tab's id in the events (`origin.client_id`, turn_enqueued's
24
- // client_id): new per page load, like the TUI's `tui:<pid>`.
25
- export function newClientId() {
26
- return `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
50
+ // client_id): kept in sessionStorage per tab, so a reload mid-turn still
51
+ // recognizes this tab's own prompt (4.03), which a fresh random id every
52
+ // load labelled "web". Falls back to a fresh id when sessionStorage is
53
+ // unavailable (a private window that refuses it) or holds nonsense.
54
+ export const CLIENT_ID_KEY = "chi_client_id";
55
+ export function newClientId(storage = safeSessionStorage()) {
56
+ const kept = readClientId(storage);
57
+ if (kept) return kept;
58
+ const id = `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
59
+ writeClientId(storage, id);
60
+ return id;
61
+ }
62
+
63
+ function safeSessionStorage() {
64
+ try {
65
+ return globalThis.sessionStorage || null;
66
+ } catch (_) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function readClientId(storage) {
72
+ try {
73
+ const value = storage?.getItem(CLIENT_ID_KEY);
74
+ return typeof value === "string" && /^web:/.test(value) ? value : null;
75
+ } catch (_) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeClientId(storage, id) {
81
+ try {
82
+ storage?.setItem(CLIENT_ID_KEY, id);
83
+ } catch (_) {
84
+ // Full or refused storage: the id stays for this page only.
85
+ }
27
86
  }
28
87
 
29
88
  export function isOwn(clientId, myId) {
@@ -190,6 +249,17 @@ export function startPageReply(text, { images = 0 } = {}) {
190
249
  return images ? null : webLocalReply(text);
191
250
  }
192
251
 
252
+ // The start page's reply to a first message that needs no session at all
253
+ // (2.29): its own command's reply (startPageReply), or the hint for a
254
+ // command word no command answers (a typo like /modle) — the hint the page
255
+ // shows in a session too, as a command bubble. Decided before the session
256
+ // is created, so a first /stats or /modle leaves none behind. A null here
257
+ // means the message goes to a session (a prompt, or anything with images).
258
+ export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
259
+ if (images) return null;
260
+ return startPageReply(text) || unknownCommandHint(text, commands);
261
+ }
262
+
193
263
  // The continue card's answers, as the terminal's continue prompt reads them.
194
264
  export function continueLine(choice, reason = "") {
195
265
  const why = String(reason || "").trim();
@@ -257,6 +327,40 @@ export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
257
327
  return restoreAction(event, { myId, sentIds, composerEmpty: true });
258
328
  }
259
329
 
330
+ // The /turn this kept entry was for failed: no ack comes, so nothing would
331
+ // take it back. The whole map goes: this tab sends one turn at a time, so
332
+ // every entry waits for an ack that is now never coming (an older one is
333
+ // left over from a send that failed the same way).
334
+ export function dropEarlyRestores(early) {
335
+ const count = early.size;
336
+ early.clear();
337
+ return count;
338
+ }
339
+
340
+ // The composer's text with a restored prompt put back into it: the restored
341
+ // text alone when nothing is typed, else the typed text with the restored
342
+ // prompt under it (the user typed between the restore and the ack; their
343
+ // text must not be replaced).
344
+ export function restoreInto(refill, current) {
345
+ const restored = String(refill ?? "");
346
+ const typed = String(current ?? "");
347
+ if (!restored) return typed;
348
+ if (!typed.trim()) return restored;
349
+ return `${typed.replace(/\n+$/, "")}\n${restored}`;
350
+ }
351
+
352
+ // What the composer holds once a /turn ack lands (4.13): the composer still
353
+ // carries the text that was just sent, so that text goes (it is the history's
354
+ // now) — but text typed since the send stays, and a prompt restored before
355
+ // this ack (restoreOnAck's refill) is put back without replacing it.
356
+ // @param sent the composer's text when the turn was sent
357
+ // @param current the composer's text as the ack lands
358
+ // @param refill the prompt an early restore handed back, if any
359
+ export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
360
+ const kept = String(current ?? "") === String(sent ?? "") ? "" : current;
361
+ return restoreInto(refill, kept);
362
+ }
363
+
260
364
  // What an event does to the prompt bubbles, as a list of ops for app.js:
261
365
  // add — a bubble for a prompt this tab hasn't shown (state queued /
262
366
  // started / steered, labelled by its sender)
@@ -1,15 +1,88 @@
1
- // An open tab keeps its JS across a `chi web` restart (its event streams
2
- // just reconnect), so after an upgrade old page code talks to a new server.
3
- // The page knows the version that served it (body data-version); each
4
- // GET /api/events snapshot names the one serving now.
5
-
6
- // The toast's text when the chi web serving now is another version than
7
- // the one that served this page, else null: also when either side doesn't
8
- // say (an older chi web, a page from before data-version), and when the
9
- // toast was already shown for this version (+shown+: the reconnects'
10
- // snapshots carry it again).
11
- export function updateNotice(loaded, served, shown = null) {
12
- const known = (v) => typeof v === "string" && v !== "";
13
- if (!known(loaded) || !known(served) || loaded === served || served === shown) return null;
14
- return `chi was updated to ${served}`;
1
+ // What the page says about chi versions. An open tab keeps its JS across a
2
+ // `chi web` restart (its event streams just reconnect), so after an upgrade
3
+ // old page code talks to a new server: the page knows the version that
4
+ // served it (body data-version), and each GET /api/events snapshot names
5
+ // the one serving now and the newest installed (`chi` frames when that
6
+ // changes).
7
+
8
+ const known = (v) => typeof v === "string" && v !== "";
9
+
10
+ // Gem::Version's order, near enough: segments of digits or letters, a
11
+ // missing one counts as 0, and letters (a prerelease: 0.19.0.pre1) sort
12
+ // below any number. <0, 0 or >0.
13
+ export function compareVersions(a, b) {
14
+ const parts = (v) => (v.match(/[0-9]+|[a-z]+/gi) || []).map((p) => (/^[0-9]+$/.test(p) ? Number(p) : p));
15
+ const x = parts(a);
16
+ const y = parts(b);
17
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
18
+ const p = i < x.length ? x[i] : 0;
19
+ const q = i < y.length ? y[i] : 0;
20
+ if (p === q) continue;
21
+ if (typeof p === "number" && typeof q === "number") return p - q;
22
+ if (typeof p === "number") return 1;
23
+ if (typeof q === "number") return -1;
24
+ return p < q ? -1 : 1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ // Whether +a+ is a newer version than +b+ (false when either is unknown).
30
+ export function newerVersion(a, b) {
31
+ return known(a) && known(b) && compareVersions(a, b) > 0;
32
+ }
33
+
34
+ // The toast for the versions as the page knows them, or null:
35
+ // - loaded ≠ served: chi web restarted on another version; reload the page;
36
+ // - installed newer than served: chi web runs an older chi than installed;
37
+ // only its terminal can restart it.
38
+ // Each has a key; one in +shown+ (a Set: what this tab already showed, as
39
+ // every reconnect's snapshot says it again) is not shown twice.
40
+ // @return {{key, text, reload: boolean} | null}
41
+ export function versionNotice({ loaded, served, installed, shown = new Set() } = {}) {
42
+ let notice = null;
43
+ if (known(loaded) && known(served) && loaded !== served) {
44
+ notice = { key: `reload:${served}`, text: `chi was updated to ${served}`, reload: true };
45
+ } else if (newerVersion(installed, served)) {
46
+ notice = {
47
+ key: `installed:${installed}`,
48
+ text: `chi ${installed} is installed; this chi web runs ${served}. ` +
49
+ "Restart it: Ctrl-C in its terminal, then chi web",
50
+ reload: false,
51
+ };
52
+ }
53
+ return notice && !shown.has(notice.key) ? notice : null;
54
+ }
55
+
56
+ // The info bar's badge for the open session's worker when it runs an older
57
+ // chi than the newest installed (or than chi web, while that is unknown),
58
+ // else null. A worker with the "restart" feature offers Restart; an older
59
+ // one can only be stopped (any message then starts it on the newest chi).
60
+ // @return {{text, title, newest, restart: boolean} | null}
61
+ export function workerBadge({ workerVersion, features, installed, served, sessionId = "" } = {}) {
62
+ const newest = known(installed) ? installed : served;
63
+ if (!newerVersion(newest, workerVersion)) return null;
64
+ if (Array.isArray(features) && features.includes("restart")) {
65
+ return {
66
+ text: `chi ${workerVersion}`,
67
+ title: `This session's worker runs chi ${workerVersion}; chi ${newest} is installed. ` +
68
+ `Restart hands the session to a new worker on chi ${newest}.`,
69
+ newest,
70
+ restart: true,
71
+ };
72
+ }
73
+ const stop = `chi sessions stop ${String(sessionId).slice(0, 8)}`;
74
+ return {
75
+ text: `chi ${workerVersion} · ${stop}`,
76
+ title: `This session's worker runs chi ${workerVersion}, which can't restart itself; chi ${newest} is installed. ` +
77
+ `Stop it (${stop}), then any message starts it on chi ${newest}.`,
78
+ newest,
79
+ restart: false,
80
+ };
81
+ }
82
+
83
+ // What the Restart confirm asks.
84
+ export function restartConfirmText(workerVersion, newest) {
85
+ return `Restart this session's worker on chi ${newest} (it runs ${workerVersion})?\n\n` +
86
+ "The conversation stays, and this page and attached terminals move to the new worker. " +
87
+ "What lives only in the worker goes: kept /btw answers and plugins' in-memory state.";
15
88
  }
@@ -28,12 +28,16 @@ module Samagotchi
28
28
  class Server
29
29
  # WEBrick logs every exception out of its request loop as an ERROR with
30
30
  # a backtrace, among them a browser dropping a kept-alive connection
31
- # (sock.eof? raising ECONNRESET): harmless, so not logged.
31
+ # (sock.eof? raising ECONNRESET): harmless, so not logged. Nor is the
32
+ # line it logs after answering a body-less POST (no Content-Length),
33
+ # when it tries to drain a body nobody read: the response already went.
32
34
  class Log < WEBrick::Log
33
35
  DROPPED = [Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED].freeze
36
+ DROPPED_LINES = ["HTTPRequest#fixup: WEBrick::HTTPStatus::LengthRequired occurred."].freeze
34
37
 
35
38
  def error(msg)
36
39
  return if DROPPED.any? { |klass| msg.is_a?(klass) }
40
+ return if msg.is_a?(String) && DROPPED_LINES.include?(msg)
37
41
 
38
42
  super
39
43
  end
@@ -125,6 +129,8 @@ module Samagotchi
125
129
  annotate_presets: annotate_presets, hub: hub, lan: lan_option)
126
130
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
127
131
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
132
+ # Subscribed before the first scan, which makes the first check.
133
+ hub.subscribe(method(:on_hub_event))
128
134
  hub.start
129
135
  # Said once the port is bound: a second chi web racing for it gets
130
136
  # the in-use line instead.
@@ -162,6 +168,27 @@ module Samagotchi
162
168
 
163
169
  class LanListenError < StandardError; end
164
170
 
171
+ # The hub's `chi` event: a newer chi installed than this chi web runs
172
+ # is said once in its terminal (the page says it too).
173
+ def self.on_hub_event(event)
174
+ return unless event.type == "chi"
175
+
176
+ line = installed_line(event.data[:installed])
177
+ return unless line
178
+
179
+ Samagotchi::Log.info(:web, "newer_installed", installed: event.data[:installed], version: Samagotchi::VERSION)
180
+ puts line
181
+ $stdout.flush
182
+ end
183
+
184
+ # nil unless +installed+ is newer than this chi web.
185
+ def self.installed_line(installed, running = Samagotchi::VERSION)
186
+ return nil unless InstalledVersions.newer?(installed, running)
187
+
188
+ "chi #{installed} is installed; this chi web runs #{running}. Restart it: Ctrl-C here, then chi web " \
189
+ "(new sessions already run #{installed})"
190
+ end
191
+
165
192
  # The LAN address's listener, next to the loopback one WEBrick made.
166
193
  # The loopback socket is bound by now: on a failure it is closed
167
194
  # here, as WEBrick never starts.
@@ -9,6 +9,8 @@ require_relative "../session_manager"
9
9
  require_relative "../bridge_client"
10
10
  require_relative "../worker_sidecar"
11
11
  require_relative "../log"
12
+ require_relative "../installed_versions"
13
+ require_relative "../version"
12
14
  require_relative "session_summary"
13
15
 
14
16
  module Samagotchi
@@ -37,7 +39,9 @@ module Samagotchi
37
39
  FULL_PROBE_INTERVAL = 10.0
38
40
 
39
41
  # What a subscriber gets: `session` with {session: summary} for an
40
- # upsert, `session_gone` with {id:} for a removal. seq is monotonic per
42
+ # upsert, `session_gone` with {id:} for a removal, `chi` with
43
+ # {version:, installed:} when the newest installed chi changed (checked
44
+ # with the full probe; #installed has it for a new tab). seq is monotonic per
41
45
  # hub, for the log's sake: there is no replay.
42
46
  Event = Struct.new(:type, :seq, :data, keyword_init: true)
43
47
 
@@ -68,9 +72,12 @@ module Samagotchi
68
72
  # @param manager [#session_owner, #retention_sweep_if_due] SessionManager,
69
73
  # or a stand-in
70
74
  # @param now [#call] a monotonic clock in seconds (specs drive it)
75
+ # @param installed_versions [#newest] what is installed (InstalledVersions)
71
76
  def initialize(state_dir:, manager: SessionManager, now: nil, scan_interval: SCAN_INTERVAL,
72
- full_probe_interval: FULL_PROBE_INTERVAL)
77
+ full_probe_interval: FULL_PROBE_INTERVAL, installed_versions: InstalledVersions.new)
73
78
  @state_dir = state_dir
79
+ @installed_versions = installed_versions
80
+ @installed = nil
74
81
  @manager = manager
75
82
  @now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
76
83
  @scan_interval = scan_interval
@@ -120,11 +127,19 @@ module Samagotchi
120
127
  full = full_probe_due?
121
128
  scan_files
122
129
  @sessions.each_key { |id| refresh(id, probe: full) }
123
- sweep if full
130
+ if full
131
+ sweep
132
+ check_installed
133
+ end
124
134
  end
125
135
  nil
126
136
  end
127
137
 
138
+ # The newest installed chi as of the last full probe (nil: unknown).
139
+ def installed
140
+ @monitor.synchronize { @installed }
141
+ end
142
+
128
143
  # Rescan one session now (its file, its folder, its owner), for the
129
144
  # page's own actions: no waiting for the next tick.
130
145
  def touch(id)
@@ -202,6 +217,18 @@ module Samagotchi
202
217
  nil
203
218
  end
204
219
 
220
+ # A change (the first look too, unless it finds nothing) is a `chi`
221
+ # event: chi web's terminal line and the open tabs hear it.
222
+ def check_installed
223
+ newest = @installed_versions.newest
224
+ return if newest == @installed
225
+
226
+ @installed = newest
227
+ emit("chi", version: VERSION, installed: newest)
228
+ rescue StandardError => e
229
+ Log.warn(:web, "installed_check_failed", error: e.class.name, msg: e.message)
230
+ end
231
+
205
232
  # The state dir's files: a new id is parsed and emitted, an id gone
206
233
  # from disk is dropped, a changed file is re-parsed. Skipped when the
207
234
  # dir's mtime says nothing was renamed in or unlinked since.
@@ -30,6 +30,7 @@ module Samagotchi
30
30
  def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
31
31
  used = Array(session.used_memory_names)
32
32
  up = bridge_up?(session_dir, owner)
33
+ sidecar = up ? WorkerSidecar.read(session_dir) : nil
33
34
  {
34
35
  id: session.id,
35
36
  status: status,
@@ -59,6 +60,10 @@ module Samagotchi
59
60
  # A worker is reachable: its Bridge sidecar is there and the
60
61
  # owner lock is held. A sidecar a dead worker left is not up.
61
62
  bridge_up: up,
63
+ # The chi the live worker runs and what it can do (its sidecar;
64
+ # nil and [] without one): the page's stale-worker badge.
65
+ worker_version: sidecar&.version,
66
+ worker_features: sidecar ? sidecar.features : [],
62
67
  # For the tab's notifications (notify.js): an open question, the
63
68
  # running turn's open card with actions, and how the last turn
64
69
  # ended. A question saved by a worker that died is not open, nor a
@@ -16,6 +16,7 @@ require_relative "continue_offer"
16
16
  require_relative "iteration_limit"
17
17
  require_relative "session_commands"
18
18
  require_relative "model_profile"
19
+ require_relative "tools/task_runtime"
19
20
 
20
21
  module Samagotchi
21
22
  # The loop of a background session worker, once it owns the session (see
@@ -85,6 +86,8 @@ module Samagotchi
85
86
  @exit_requested_by = nil
86
87
  # Set as it leaves: nothing happened in the session (#discard?).
87
88
  @discard = false
89
+ # The exit asked for is a restart (POST /exit restart: true).
90
+ @exit_restart = false
88
91
  end
89
92
 
90
93
  # Whether the session was empty as the worker left it, so the caller
@@ -96,7 +99,8 @@ module Samagotchi
96
99
  attr_reader :default_model
97
100
 
98
101
  # @return [Symbol] :idle_exit, :exit_requested when a client asked it
99
- # to exit (Bridge POST /exit), :stopped when the session was stopped
102
+ # to exit (Bridge POST /exit), :restart when it asked for a restart
103
+ # (the caller starts a new worker), :stopped when the session was stopped
100
104
  # (chi stop), :crashed when the loop raised (the session is marked
101
105
  # errored); SessionManager.run_session_loop turns it into the exit
102
106
  def run
@@ -154,7 +158,8 @@ module Samagotchi
154
158
  engine: @engine, bridge: @bridge,
155
159
  timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
156
160
  input_pending: -> { !@command_queue.empty? || !SessionInbox.find_new_input_files(@session_dir).empty? },
157
- awaiting_continue: -> { @turn_flow.awaiting_continue? }
161
+ awaiting_continue: -> { @turn_flow.awaiting_continue? },
162
+ running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) }
158
163
  )
159
164
 
160
165
  begin
@@ -188,7 +193,7 @@ module Samagotchi
188
193
  if input_files.empty?
189
194
  next if run_due_reminders
190
195
  return left(:idle_exit) if @idle_exit.due? && leave_idle
191
- return left(:exit_requested) if @exit_requested && leave_on_request
196
+ return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
192
197
 
193
198
  @waker.wait(@poll_interval)
194
199
  next
@@ -581,17 +586,20 @@ module Samagotchi
581
586
 
582
587
  # Bridge POST /exit, on a Bridge thread with the event log held.
583
588
  # +delete+: the session is deleted after (/exit --delete), so no recap.
589
+ # +restart+: a new worker takes over (on the newest chi installed), so
590
+ # WorkerIdleExit#hold_for_restart's rules, and no recap or discard.
584
591
  # @return [Symbol, nil] what keeps the worker up, nil when it will leave
585
- def exit_request(client_id, delete: false)
592
+ def exit_request(client_id, delete: false, restart: false)
586
593
  # The Bridge serves before the idle-exit policy exists.
587
594
  return :starting unless @idle_exit
588
595
 
589
- hold = @idle_exit.hold_for_request(requester: client_id)
596
+ hold = restart ? @idle_exit.hold_for_restart : @idle_exit.hold_for_request(requester: client_id)
590
597
  return hold if hold
591
598
 
592
599
  @exit_requested = true
593
600
  @exit_requested_by = client_id
594
- @exit_deletes = delete
601
+ @exit_deletes = delete && !restart
602
+ @exit_restart = restart
595
603
  @waker.wake
596
604
  nil
597
605
  end
@@ -604,16 +612,21 @@ module Samagotchi
604
612
  # @return [Boolean] false when something came in since the request
605
613
  def leave_on_request
606
614
  @engine.synchronize_events do
607
- hold = @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
615
+ hold = if @exit_restart
616
+ @idle_exit.hold_for_restart
617
+ else
618
+ @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
619
+ end
608
620
  if hold
609
621
  @exit_requested = nil
622
+ @exit_restart = false
610
623
  Log.info(:worker, "exit_held", reason: hold)
611
624
  next false
612
625
  end
613
626
 
614
627
  @bridge&.stop
615
628
  @engine.stop_idle
616
- Log.info(:worker, "exit_requested", by: @exit_requested_by || "a client")
629
+ Log.info(:worker, @exit_restart ? "restart_requested" : "exit_requested", by: @exit_requested_by || "a client")
617
630
  true
618
631
  end
619
632
  end
@@ -623,7 +636,11 @@ module Samagotchi
623
636
  # stopped by now, and nothing waits on this (the TUI has detached). A
624
637
  # `chi send` meanwhile is picked up after the exit (run_session_loop);
625
638
  # a `chi --attach` finds no Bridge until then.
639
+ # A restart keeps even an empty session (a new worker takes it over at
640
+ # once) and leaves the recap to the conversation's next real pause.
626
641
  def left(result)
642
+ return result if result == :restart
643
+
627
644
  @discard = empty_session?
628
645
  write_recap_on_leave unless @discard || (result == :exit_requested && @exit_deletes)
629
646
  result
@@ -18,7 +18,8 @@ module Samagotchi
18
18
  #
19
19
  # A client can also ask the worker to exit now (`/exit` in the attached
20
20
  # TUI, Bridge POST /exit): #hold_for_request applies the same rules without
21
- # the timeout and leaves out the asking client's own stream.
21
+ # the timeout and leaves out the asking client's own stream. A restart
22
+ # (POST /exit restart: true) has its own rules, #hold_for_restart.
22
23
  class WorkerIdleExit
23
24
  # @param engine [Engine] #turn_running?, #last_activity_at, #reminder_store
24
25
  # @param bridge [Bridge, nil] #open_streams, #last_client_activity_at; nil
@@ -26,14 +27,17 @@ module Samagotchi
26
27
  # @param timeout_minutes [Numeric, nil] 0 or nil: never exit
27
28
  # @param input_pending [#call] true while input files wait
28
29
  # @param awaiting_continue [#call] true while a continue offer waits
30
+ # @param running_tasks [#call] the background tasks this conversation
31
+ # started that still run (Tools::TaskRuntime.running_created_in)
29
32
  # @param clock [#call] monotonic seconds, the Engine's and Bridge's clock
30
33
  def initialize(engine:, bridge:, timeout_minutes:, input_pending:, awaiting_continue: -> { false },
31
- clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
34
+ running_tasks: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
32
35
  @engine = engine
33
36
  @bridge = bridge
34
37
  @timeout = timeout_minutes.to_f * 60
35
38
  @input_pending = input_pending
36
39
  @awaiting_continue = awaiting_continue
40
+ @running_tasks = running_tasks
37
41
  @clock = clock
38
42
  @started_at = clock.call
39
43
  end
@@ -78,6 +82,31 @@ module Samagotchi
78
82
  nil
79
83
  end
80
84
 
85
+ # What keeps the worker from restarting now (a client asked: POST /exit
86
+ # restart: true), or nil. A restart drops what lives only in this
87
+ # process, so whatever would be lost or cut holds it: a question or
88
+ # approval waiting (a turn's), a /btw (or another anytime command)
89
+ # running, a delegate's approval relay this worker still answers for,
90
+ # background tasks this conversation started (their output and end are
91
+ # this worker's to report). Clients' streams don't hold: a web tab
92
+ # reconnects on the new worker's bridge_up, an attached terminal finds
93
+ # the new worker's Bridge.
94
+ # @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
95
+ # :question_pending, :reminders, :command_running, :relays_open or
96
+ # :background_tasks
97
+ def hold_for_restart
98
+ return :turn_running if @engine.turn_running?
99
+ return :input_queued if @input_pending.call
100
+ return :continue_offered if @awaiting_continue.call
101
+ return :question_pending if @engine.pending_question
102
+ return :reminders if @engine.reminder_store&.any?
103
+ return :command_running if @engine.anytime_running?
104
+ return :relays_open if @engine.relay_desk.active?
105
+ return :background_tasks if @running_tasks.call.any?
106
+
107
+ nil
108
+ end
109
+
81
110
  # @return [Float] seconds since the latest activity of any kind
82
111
  def idle_seconds
83
112
  last = [@started_at, @engine.last_activity_at, @bridge&.last_client_activity_at].compact.max
@@ -8,11 +8,12 @@ require_relative "atomic_file"
8
8
  module Samagotchi
9
9
  # A worker's bridge.json in its session directory: how clients reach its
10
10
  # Bridge (port, bind), when it started (a new worker behind the same
11
- # "worker" owner), the chi it runs (chi update) and the input-file format
12
- # it reads (SessionInbox::INPUT_FORMAT). The Bridge writes it on start
11
+ # "worker" owner), the chi it runs (chi update, the web's stale-worker
12
+ # badge), the input-file format it reads (SessionInbox::INPUT_FORMAT) and
13
+ # what else it can do (Bridge::FEATURES). The Bridge writes it on start
13
14
  # and removes it on stop; one a dead worker left stays until a client's
14
15
  # probe finds its port closed (.live with unlink: true).
15
- class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format)
16
+ class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features)
16
17
  FILE = "bridge.json"
17
18
  # Seconds a liveness probe waits for the port to take a connect.
18
19
  PROBE_TIMEOUT = 0.2
@@ -27,7 +28,8 @@ module Samagotchi
27
28
 
28
29
  port = data["port"]
29
30
  new(port: port.respond_to?(:to_i) ? port.to_i : 0, bind: data["bind"], session_id: data["session_id"],
30
- started_at: data["started_at"], version: data["version"], input_format: data["input_format"])
31
+ started_at: data["started_at"], version: data["version"], input_format: data["input_format"],
32
+ features: Array(data["features"]).map(&:to_s))
31
33
  rescue JSON::ParserError, SystemCallError
32
34
  nil
33
35
  end
@@ -61,7 +63,7 @@ module Samagotchi
61
63
  # @return [Integer, nil] .live's port
62
64
  def self.live_port(session_dir, unlink:, host: "127.0.0.1") = live(session_dir, unlink: unlink, host: host)&.port
63
65
 
64
- def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil) = super
66
+ def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: []) = super
65
67
 
66
68
  # Written whole or not at all; input_format only when it names one.
67
69
  def write(session_dir)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -252,6 +252,7 @@ files:
252
252
  - lib/samagotchi/idle_scheduler.rb
253
253
  - lib/samagotchi/image_store.rb
254
254
  - lib/samagotchi/installed_gem.rb
255
+ - lib/samagotchi/installed_versions.rb
255
256
  - lib/samagotchi/iteration_limit.rb
256
257
  - lib/samagotchi/kernel_loop.rb
257
258
  - lib/samagotchi/launch_mode.rb
@@ -352,6 +353,7 @@ files:
352
353
  - lib/samagotchi/terminal_ui/input_support.rb
353
354
  - lib/samagotchi/terminal_ui/line_reader.rb
354
355
  - lib/samagotchi/terminal_ui/live_region.rb
356
+ - lib/samagotchi/terminal_ui/one_shot_sink.rb
355
357
  - lib/samagotchi/terminal_ui/plain_surface.rb
356
358
  - lib/samagotchi/terminal_ui/question_prompt.rb
357
359
  - lib/samagotchi/terminal_ui/question_slot.rb
@@ -362,6 +364,7 @@ files:
362
364
  - lib/samagotchi/terminal_ui/status_row.rb
363
365
  - lib/samagotchi/terminal_ui/surface.rb
364
366
  - lib/samagotchi/terminal_ui/thinking_line.rb
367
+ - lib/samagotchi/terminal_ui/version_lines.rb
365
368
  - lib/samagotchi/text_diff.rb
366
369
  - lib/samagotchi/thinking.rb
367
370
  - lib/samagotchi/thinking_ticker.rb