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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -1
- data/README.md +4 -3
- data/bin/chi +5 -2
- data/docs/architecture.md +15 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +8 -0
- data/docs/memory.md +4 -0
- data/docs/sessions.md +6 -4
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +2 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/engine.rb +35 -11
- data/lib/samagotchi/guardrail_wiring.rb +64 -5
- data/lib/samagotchi/guardrails/approval.rb +10 -4
- data/lib/samagotchi/guardrails/approvals.rb +60 -12
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/memory_bundle/builder.rb +24 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +33 -0
- data/lib/samagotchi/memory_bundle/status.rb +5 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +6 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/terminal_ui.rb +65 -16
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +97 -19
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +64 -11
- data/lib/samagotchi/web/public/app.js +102 -29
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +4 -2
- data/lib/samagotchi/web/public/index.html +11 -0
- data/lib/samagotchi/web/public/question_card.js +12 -0
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/turn_events.js +107 -3
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +5 -0
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- 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):
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
2
|
-
// just reconnect), so after an upgrade
|
|
3
|
-
//
|
|
4
|
-
// GET /api/events snapshot names
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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), :
|
|
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 =
|
|
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
|
|
12
|
-
# it reads (SessionInbox::INPUT_FORMAT)
|
|
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.
|
|
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
|