samagotchi 0.18.1 → 0.20.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 +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- 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 +24 -5
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- 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 +2 -2
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- 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/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- 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 +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +14 -1
- 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/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- 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 +11 -2
- 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 +7 -1
|
@@ -49,5 +49,44 @@ module Samagotchi
|
|
|
49
49
|
# Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
|
|
50
50
|
# Useful for tooling, tests, and future index suppression.
|
|
51
51
|
OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
|
|
52
|
+
|
|
53
|
+
# The base file name (`tips.md`) an overlay-looking name
|
|
54
|
+
# (`tips.qwen3.md`) would belong to, or nil when it doesn't match the
|
|
55
|
+
# suffix pattern.
|
|
56
|
+
def self.base_file_for(path)
|
|
57
|
+
match = File.basename(path.to_s).match(OVERLAY_SUFFIX_PATTERN)
|
|
58
|
+
return nil unless match
|
|
59
|
+
|
|
60
|
+
stem = File.basename(path.to_s).delete_suffix(match[0])
|
|
61
|
+
stem.empty? ? nil : "#{stem}.md"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether +path+ is a model overlay: its name matches the suffix pattern
|
|
65
|
+
# and its base `<stem>.md` exists in one of +base_dirs+ (default: the
|
|
66
|
+
# file's own dir) or is one of +base_names+. A memory whose name has a
|
|
67
|
+
# dot and a sibling of the same stem reads as an overlay too (a
|
|
68
|
+
# leftover ambiguity).
|
|
69
|
+
def self.overlay_file?(path, base_dirs: [File.dirname(path.to_s)], base_names: [])
|
|
70
|
+
base = base_file_for(path)
|
|
71
|
+
return false unless base
|
|
72
|
+
|
|
73
|
+
base_names.map { |n| File.basename(n.to_s) }.include?(base) ||
|
|
74
|
+
base_dirs.compact.any? { |dir| File.file?(File.join(dir, base)) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# How a bundle's installer, status and uninstaller tell an overlay
|
|
78
|
+
# (they agree, so a line the installer left out isn't "no-index"): the
|
|
79
|
+
# base is one of +bundle_files+ (basenames), or it is nowhere in
|
|
80
|
+
# +target_dir+ (an orphan overlay; it loads once the base exists).
|
|
81
|
+
# A base only in +target_dir+ (an installed memory, maybe the user's
|
|
82
|
+
# own) counts too: the installer warns, so the dot-name ambiguity
|
|
83
|
+
# (notes.v2.md next to a user's notes.md) is visible.
|
|
84
|
+
def self.bundle_overlay?(file_key, bundle_files:, target_dir:)
|
|
85
|
+
base = base_file_for(file_key)
|
|
86
|
+
return false unless base
|
|
87
|
+
|
|
88
|
+
overlay_file?(file_key, base_dirs: [target_dir], base_names: bundle_files) ||
|
|
89
|
+
!File.file?(File.join(target_dir, base))
|
|
90
|
+
end
|
|
52
91
|
end
|
|
53
92
|
end
|
|
@@ -11,6 +11,10 @@ module Samagotchi
|
|
|
11
11
|
# gemma4 — Gemma 4 format
|
|
12
12
|
class ModelProfile
|
|
13
13
|
MODEL_ENV = "SAMAGOTCHI_DEFAULT_MODEL"
|
|
14
|
+
# Set beside MODEL_ENV for a worker spawned by a `chi --model X`: X is
|
|
15
|
+
# that run's default, not one its commands' chi should inherit
|
|
16
|
+
# (SessionManager.spawn_options, Tools::Builtins.parent_env).
|
|
17
|
+
MODEL_FROM_CLI_ENV = "SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI"
|
|
14
18
|
# No model anywhere (--model, default.model in config.yml, the env): a
|
|
15
19
|
# first run before any config. An ArgumentError, as before.
|
|
16
20
|
MissingModel = Class.new(ArgumentError)
|
|
@@ -63,6 +63,15 @@ module Samagotchi
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Whether a relay is open, or settled recently enough that its child
|
|
67
|
+
# may still ask about it: a restart would drop them.
|
|
68
|
+
def active?
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
prune
|
|
71
|
+
!@relays.empty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
66
75
|
private
|
|
67
76
|
|
|
68
77
|
def prune
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
4
6
|
require_relative "version"
|
|
5
7
|
require_relative "config"
|
|
6
8
|
require_relative "context_window"
|
|
@@ -11,6 +13,8 @@ require_relative "model_overlay"
|
|
|
11
13
|
require_relative "thinking"
|
|
12
14
|
require_relative "served_model"
|
|
13
15
|
require_relative "host_registry"
|
|
16
|
+
require_relative "llm/http"
|
|
17
|
+
require_relative "llm/api_key"
|
|
14
18
|
require_relative "tools/memory"
|
|
15
19
|
require_relative "hooks/loader"
|
|
16
20
|
require_relative "memory_bundle/provenance"
|
|
@@ -194,7 +198,9 @@ module Samagotchi
|
|
|
194
198
|
|
|
195
199
|
# chi self's one server call: a single-model llama.cpp answers any name
|
|
196
200
|
# with the model it loaded, and its /props names it (model_alias). One
|
|
197
|
-
# GET with the probe's short timeouts; a remote host isn't asked.
|
|
201
|
+
# GET with the probe's short timeouts; a remote host isn't asked. A local
|
|
202
|
+
# chat host (api: openai) has no /props, so it gets a GET <base>/models
|
|
203
|
+
# with the same timeouts: up/down and the ids it serves.
|
|
198
204
|
def served_model_for(model, env)
|
|
199
205
|
registry = HostRegistry.new(env: env)
|
|
200
206
|
target = registry.resolve(model)
|
|
@@ -202,7 +208,7 @@ module Samagotchi
|
|
|
202
208
|
bare = target.bare_model
|
|
203
209
|
return "-" unless entry
|
|
204
210
|
return "reported per turn (remote host)" if entry.remote?
|
|
205
|
-
return
|
|
211
|
+
return chat_served_model_for(entry, bare, env) if entry.chat?
|
|
206
212
|
|
|
207
213
|
client = registry.client_for(entry)
|
|
208
214
|
props = client.server_props(model: bare)
|
|
@@ -217,6 +223,36 @@ module Samagotchi
|
|
|
217
223
|
"unknown (#{e.class})"
|
|
218
224
|
end
|
|
219
225
|
|
|
226
|
+
# A local chat host's reachability: one GET <base>/models with the
|
|
227
|
+
# native probe's short timeouts (Client's /props probe), no retry. Up
|
|
228
|
+
# with the ids it serves, down when it doesn't answer.
|
|
229
|
+
def chat_served_model_for(entry, bare, env)
|
|
230
|
+
uri = URI("#{entry.openai_base_url}/models")
|
|
231
|
+
request = Net::HTTP::Get.new(uri)
|
|
232
|
+
http = LLM::HTTP.new(label: entry.name, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
|
|
233
|
+
read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
|
|
234
|
+
retry_policy: LLM::HTTP::RetryPolicy.none,
|
|
235
|
+
api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
|
|
236
|
+
response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
|
|
237
|
+
return "down (HTTP #{response.code})" unless response.code.to_s == "200"
|
|
238
|
+
|
|
239
|
+
ids = chat_model_ids(response.body)
|
|
240
|
+
return "up (no models listed)" if ids.empty?
|
|
241
|
+
|
|
242
|
+
note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
|
|
243
|
+
"up: #{ids.join(", ")}#{note}"
|
|
244
|
+
rescue StandardError
|
|
245
|
+
"down (the server didn't answer; is it running?)"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def chat_model_ids(body)
|
|
249
|
+
parsed = JSON.parse(body.to_s)
|
|
250
|
+
data = parsed.is_a?(Hash) ? parsed["data"] : parsed
|
|
251
|
+
Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
|
|
252
|
+
rescue JSON::ParserError
|
|
253
|
+
[]
|
|
254
|
+
end
|
|
255
|
+
|
|
220
256
|
# Offline, so no /props probe: where nothing is configured, a native
|
|
221
257
|
# llama.cpp host's chat template decides at runtime.
|
|
222
258
|
def profile_for(model, env)
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
|
+
require_relative "context_note"
|
|
8
9
|
|
|
9
10
|
require_relative "paths"
|
|
10
11
|
require_relative "project_scope"
|
|
@@ -413,27 +414,55 @@ module Samagotchi
|
|
|
413
414
|
|
|
414
415
|
# Derive and cache the first user-message preview in the session record.
|
|
415
416
|
# Returns true if the cached value was set or updated.
|
|
417
|
+
# A session with only context notes so far previews by its first note
|
|
418
|
+
# ("note: …"), until a message is typed.
|
|
416
419
|
def compute_first_preview!
|
|
417
|
-
|
|
420
|
+
from_note = note_preview
|
|
421
|
+
return false if @first_preview && !@first_preview.empty? && @first_preview != from_note
|
|
418
422
|
|
|
419
423
|
first_user = @messages.find { |m| m[:role].to_s == "user" || m["role"].to_s == "user" }
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
preview = self.class.preview_of(first_user[:content] || first_user["content"])
|
|
423
|
-
return false if preview.empty?
|
|
424
|
+
preview = first_user ? self.class.preview_of(first_user[:content] || first_user["content"]) : from_note.to_s
|
|
425
|
+
return false if preview.empty? || preview == @first_preview
|
|
424
426
|
|
|
425
427
|
@first_preview = preview
|
|
426
428
|
true
|
|
427
429
|
end
|
|
428
430
|
|
|
431
|
+
# The web's annotation labels (annotations.js sourceLabel), each on its
|
|
432
|
+
# own line above a quote.
|
|
433
|
+
QUOTE_LABEL_RE = /\AFrom (?:your thinking|your earlier step|the .{1,80} call|my earlier message):\z/
|
|
434
|
+
|
|
429
435
|
# A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
|
|
436
|
+
# A quoted or annotated message previews by what was typed under the
|
|
437
|
+
# quote; a quote alone by its words, without the "> " markers or label.
|
|
430
438
|
def self.preview_of(text)
|
|
431
|
-
norm = text.to_s.gsub(/\s+/, " ").strip
|
|
439
|
+
norm = preview_words(text.to_s).gsub(/\s+/, " ").strip
|
|
432
440
|
norm.length > 80 ? "#{norm[0, 80]}…" : norm
|
|
433
441
|
end
|
|
434
442
|
|
|
443
|
+
def self.preview_words(text)
|
|
444
|
+
lines = text.lines.map(&:strip)
|
|
445
|
+
quoted = lines.select { |line| line.start_with?(">") }
|
|
446
|
+
return text if quoted.empty?
|
|
447
|
+
|
|
448
|
+
own = lines.reject { |line| line.start_with?(">") || QUOTE_LABEL_RE.match?(line) }.join("\n")
|
|
449
|
+
return own unless own.strip.empty?
|
|
450
|
+
|
|
451
|
+
quoted.map { |line| line.sub(/\A(?:>[ \t]?)+/, "") }.join("\n")
|
|
452
|
+
end
|
|
453
|
+
private_class_method :preview_words
|
|
454
|
+
|
|
435
455
|
private
|
|
436
456
|
|
|
457
|
+
# @return [String, nil] the first context note as a preview
|
|
458
|
+
def note_preview
|
|
459
|
+
note = @messages.find { |m| ContextNote.note?(m) }
|
|
460
|
+
return nil unless note
|
|
461
|
+
|
|
462
|
+
text = self.class.preview_of(ContextNote.text_of(note))
|
|
463
|
+
text.empty? ? nil : self.class.preview_of("note: #{text}")
|
|
464
|
+
end
|
|
465
|
+
|
|
437
466
|
def stringify_message_keys(hash)
|
|
438
467
|
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
439
468
|
end
|
|
@@ -234,6 +234,9 @@ module Samagotchi
|
|
|
234
234
|
rules = @engine.guardrail_rules
|
|
235
235
|
approvals = @engine.guardrail_approvals.entries
|
|
236
236
|
lines = ["guardrails: #{rules.enabled? ? "on" : "off (guardrails.enabled: false; denies still apply)"}"]
|
|
237
|
+
lines << if rules.mode == "strict" then "mode: strict (every rule votes)"
|
|
238
|
+
else "mode: auto (rules marked strict only don't vote; guardrails.mode: strict in config.yml for those)"
|
|
239
|
+
end
|
|
237
240
|
name = @engine.guardrail_model_name
|
|
238
241
|
key = @engine.model_key
|
|
239
242
|
lines << "model: #{name || "none"} — #{Guardrails::ModelSize.describe(name, key)}"
|
|
@@ -253,9 +256,14 @@ module Samagotchi
|
|
|
253
256
|
("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
|
|
254
257
|
("path #{rule.path}" if rule.path),
|
|
255
258
|
("git #{rule.git}" if rule.git),
|
|
256
|
-
("
|
|
259
|
+
("touches #{rule.touches}" if rule.touches),
|
|
260
|
+
("rm #{rule.rm}" if rule.rm),
|
|
261
|
+
("models #{rule.models.join(",")}" if rule.models),
|
|
262
|
+
("modes #{rule.modes.join(",")}" if rule.modes),
|
|
263
|
+
("skip_read_only" if rule.skip_read_only)
|
|
257
264
|
].compact.join(", ")
|
|
258
265
|
off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
|
|
266
|
+
off ||= "(#{rule.modes.join(", ")} only) — " unless rule.for_mode?(rules.mode)
|
|
259
267
|
off ||= "off for this model — " unless rule.for_model?(name, key)
|
|
260
268
|
lines << " #{idx + 1}. #{rule.id}: #{off}#{rule.verdict} (#{match}) — #{rule.reason} [#{rule.source}]"
|
|
261
269
|
end
|
|
@@ -277,7 +285,10 @@ module Samagotchi
|
|
|
277
285
|
|
|
278
286
|
def approval_line(entry)
|
|
279
287
|
what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
|
|
280
|
-
where = entry["scope"] == "session"
|
|
288
|
+
where = if entry["scope"] == "session" then "session #{entry["session_id"].to_s[0, 8]}"
|
|
289
|
+
elsif entry["repo"] then "in repo #{entry["repo"]}"
|
|
290
|
+
else "in #{entry["repo_root"]}"
|
|
291
|
+
end
|
|
281
292
|
rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
|
|
282
293
|
"#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
|
|
283
294
|
end
|
|
@@ -19,7 +19,9 @@ require_relative "bridge_client"
|
|
|
19
19
|
require_relative "worker_sidecar"
|
|
20
20
|
require_relative "log"
|
|
21
21
|
require_relative "log_path"
|
|
22
|
+
require_relative "model_profile"
|
|
22
23
|
require_relative "installed_gem"
|
|
24
|
+
require_relative "installed_versions"
|
|
23
25
|
require_relative "recap_store"
|
|
24
26
|
require_relative "archive_store"
|
|
25
27
|
require_relative "image_store"
|
|
@@ -191,6 +193,7 @@ module Samagotchi
|
|
|
191
193
|
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
192
194
|
# other flags this chi was started with.
|
|
193
195
|
child_env.merge!(Config.cli_env)
|
|
196
|
+
child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
|
|
194
197
|
# And the spawner's debug log, absolute: a relative log.file would
|
|
195
198
|
# otherwise land in the worker's (the session's) directory.
|
|
196
199
|
log_path = begin LogPath.resolve rescue nil end
|
|
@@ -204,10 +207,25 @@ module Samagotchi
|
|
|
204
207
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
205
208
|
# older or future env-exposed one out as well.
|
|
206
209
|
ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
|
|
210
|
+
# A smoke's fake "newest installed" is for this process's notices only.
|
|
211
|
+
child_env[InstalledVersions::ENV_KEY] = nil
|
|
212
|
+
# A gem-mode worker activates the newest installed chi itself: under
|
|
213
|
+
# `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
|
|
214
|
+
# hold it to the bundle's version, and its badge would never clear.
|
|
215
|
+
child_env.merge!(unbundled_env_changes) if InstalledGem.spec
|
|
207
216
|
opts[:env] = child_env unless child_env.empty?
|
|
208
217
|
opts
|
|
209
218
|
end
|
|
210
219
|
|
|
220
|
+
# What Bundler changed in ENV (RUBYOPT, BUNDLE_*), mapped back to the
|
|
221
|
+
# values from before it (nil: unset); {} outside Bundler.
|
|
222
|
+
private_class_method def self.unbundled_env_changes(env = ENV.to_h)
|
|
223
|
+
return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:unbundled_env)
|
|
224
|
+
|
|
225
|
+
clean = ::Bundler.unbundled_env
|
|
226
|
+
(env.keys | clean.keys).each_with_object({}) { |key, changes| changes[key] = clean[key] unless clean[key] == env[key] }
|
|
227
|
+
end
|
|
228
|
+
|
|
211
229
|
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
212
230
|
|
|
213
231
|
# List all sessions, reading status from persisted session.json files.
|
|
@@ -409,6 +427,10 @@ module Samagotchi
|
|
|
409
427
|
# status is turn state, not liveness: a new worker runs no turn yet,
|
|
410
428
|
# and must not find the session stopped (it would exit): neither the
|
|
411
429
|
# marker nor a status field from before it.
|
|
430
|
+
# One this process just spawned may not hold the lock yet (a slow
|
|
431
|
+
# start, deliver_turn's second resume): it would only lose it.
|
|
432
|
+
return session if still_starting?(session.id, state_dir: sd)
|
|
433
|
+
|
|
412
434
|
Session.clear_stopped(session.id, state_dir: sd)
|
|
413
435
|
session.status = Session::STATUS_IDLE
|
|
414
436
|
session.save(state_dir: sd)
|
|
@@ -416,6 +438,36 @@ module Samagotchi
|
|
|
416
438
|
session
|
|
417
439
|
end
|
|
418
440
|
|
|
441
|
+
# How long after a spawn a resume trusts that worker to take the
|
|
442
|
+
# session, while it runs (and the session wasn't stopped since).
|
|
443
|
+
WORKER_START_GRACE = 30.0
|
|
444
|
+
|
|
445
|
+
@spawns = {}
|
|
446
|
+
@spawns_mutex = Mutex.new
|
|
447
|
+
|
|
448
|
+
# A worker this process spawned for the session within
|
|
449
|
+
# WORKER_START_GRACE is still running, and no stop came since.
|
|
450
|
+
private_class_method def self.still_starting?(session_id, state_dir:)
|
|
451
|
+
return false if Session.stopped_marker?(session_id, state_dir: state_dir)
|
|
452
|
+
|
|
453
|
+
at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
|
|
454
|
+
return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
|
|
455
|
+
|
|
456
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
private_class_method def self.note_spawn(session_id, state_dir:, waiter:)
|
|
460
|
+
@spawns_mutex.synchronize do
|
|
461
|
+
@spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
|
|
462
|
+
@spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Forget the spawns #still_starting? remembers (specs).
|
|
467
|
+
def self.forget_spawns
|
|
468
|
+
@spawns_mutex.synchronize { @spawns.clear }
|
|
469
|
+
end
|
|
470
|
+
|
|
419
471
|
# Read any new output files from a session directory.
|
|
420
472
|
# Optionally filters to only files newer than `since_time`.
|
|
421
473
|
def self.read_responses(session_id, since_time: nil, state_dir: nil)
|
|
@@ -423,6 +475,85 @@ module Samagotchi
|
|
|
423
475
|
since_time: since_time)
|
|
424
476
|
end
|
|
425
477
|
|
|
478
|
+
# A restart that can't happen now. reason: :not_running (no live worker:
|
|
479
|
+
# the next prompt starts one on the newest chi anyway), :unsupported (a
|
|
480
|
+
# worker from before restarts, running +version+: stop it instead), a
|
|
481
|
+
# WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
|
|
482
|
+
# or :failed (the worker answered something else).
|
|
483
|
+
class RestartRefused < StandardError
|
|
484
|
+
attr_reader :session_id, :reason, :version
|
|
485
|
+
|
|
486
|
+
def initialize(session_id, reason, version: nil)
|
|
487
|
+
@session_id = session_id
|
|
488
|
+
@reason = reason.to_sym
|
|
489
|
+
@version = version
|
|
490
|
+
super(RestartRefused.words(session_id, @reason, version))
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
HOLDS = {
|
|
494
|
+
starting: "its worker is still starting",
|
|
495
|
+
turn_running: "a turn is running",
|
|
496
|
+
input_queued: "a prompt is queued",
|
|
497
|
+
continue_offered: "the step-limit question waits for an answer",
|
|
498
|
+
question_pending: "a question or approval waits for an answer",
|
|
499
|
+
reminders: "it has reminders, which a restart would drop",
|
|
500
|
+
command_running: "a /btw (or another command) is still running",
|
|
501
|
+
relays_open: "a delegate's approval relayed here is open or just answered",
|
|
502
|
+
background_tasks: "background tasks it started are still running"
|
|
503
|
+
}.freeze
|
|
504
|
+
|
|
505
|
+
# What the CLI and the web say.
|
|
506
|
+
def self.words(session_id, reason, version = nil)
|
|
507
|
+
case reason
|
|
508
|
+
when :not_running
|
|
509
|
+
"session #{session_id} has no running worker; the next prompt starts one on the newest chi"
|
|
510
|
+
when :unsupported
|
|
511
|
+
"session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
|
|
512
|
+
"chi sessions stop #{session_id}, then any prompt starts it on the newest chi"
|
|
513
|
+
when :failed then "session #{session_id}'s worker didn't take the restart"
|
|
514
|
+
else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# What a restart did: the worker's version before and after (nil when
|
|
520
|
+
# the new worker hadn't published its Bridge within the wait).
|
|
521
|
+
Restarted = Struct.new(:session_id, :from_version, :version, keyword_init: true)
|
|
522
|
+
|
|
523
|
+
RESTART_WAIT = 10.0
|
|
524
|
+
|
|
525
|
+
# Hand a session to a new worker on the newest chi installed: ask its
|
|
526
|
+
# worker (POST /exit restart: true), which leaves only when nothing would
|
|
527
|
+
# be lost and starts its successor (Worker#exit_request), then wait up to
|
|
528
|
+
# +wait+ seconds for the successor's Bridge. Web tabs and attached
|
|
529
|
+
# terminals reconnect to it.
|
|
530
|
+
# @return [Restarted]
|
|
531
|
+
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
532
|
+
# @raise [RestartRefused]
|
|
533
|
+
# @raise [ArgumentError] no such session
|
|
534
|
+
def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: "cli:restart")
|
|
535
|
+
sd = state_dir || Session.default_state_dir
|
|
536
|
+
Session.load(session_id, state_dir: sd) # ArgumentError for none
|
|
537
|
+
owner = refuse_tui!(session_id, state_dir: sd)
|
|
538
|
+
dir = Session.session_dir(session_id, state_dir: sd)
|
|
539
|
+
sidecar = owner&.worker? && WorkerSidecar.live(dir, unlink: false)
|
|
540
|
+
raise RestartRefused.new(session_id, :not_running) unless sidecar
|
|
541
|
+
unless sidecar.features.include?("restart")
|
|
542
|
+
raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
reply = BridgeClient.new(session_id: session_id, port: sidecar.port).request_exit(client_id: client_id, restart: true)
|
|
546
|
+
raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
|
|
547
|
+
raise RestartRefused.new(session_id, :failed) unless reply.status == 200
|
|
548
|
+
|
|
549
|
+
Log.info(:worker, "restart", sid: session_id, from: sidecar.version, by: client_id)
|
|
550
|
+
fresh = BridgeClient.poll(wait, interval: 0.1) do
|
|
551
|
+
found = WorkerSidecar.live(dir, unlink: false)
|
|
552
|
+
found if found && found.started_at != sidecar.started_at
|
|
553
|
+
end
|
|
554
|
+
Restarted.new(session_id: session_id, from_version: sidecar.version, version: fresh&.version)
|
|
555
|
+
end
|
|
556
|
+
|
|
426
557
|
# Stop a session by sending TERM to its process.
|
|
427
558
|
# With +wait+ (seconds), also wait for the owner to let go of the session,
|
|
428
559
|
# so a resume right after spawns a fresh worker instead of finding the
|
|
@@ -638,7 +769,7 @@ module Samagotchi
|
|
|
638
769
|
# @param poll_interval [Numeric, nil] seconds between the loop's fallback
|
|
639
770
|
# ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
|
|
640
771
|
# A stopped session's worker exits 0 and a crashed one 1, there.
|
|
641
|
-
# @return [Symbol] :idle_exit or :
|
|
772
|
+
# @return [Symbol] :idle_exit, :exit_requested or :restart
|
|
642
773
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
643
774
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
644
775
|
# A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
|
|
@@ -652,7 +783,10 @@ module Samagotchi
|
|
|
652
783
|
# Kept in a class ivar so the lock's File lives as long as the worker.
|
|
653
784
|
@owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
|
|
654
785
|
exit(0) unless @owner_lock
|
|
655
|
-
|
|
786
|
+
# Set by the boot string when the newest installed chi didn't load.
|
|
787
|
+
fallback = ENV.delete(BOOT_FALLBACK_ENV)
|
|
788
|
+
Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
|
|
789
|
+
Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
|
|
656
790
|
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
657
791
|
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
|
|
658
792
|
result = begin
|
|
@@ -672,7 +806,10 @@ module Samagotchi
|
|
|
672
806
|
# Only after the release: a writer that still saw this worker as the
|
|
673
807
|
# owner may have queued input since the last check. Either it finds no
|
|
674
808
|
# owner after its write and wakes one, or this finds its input.
|
|
675
|
-
|
|
809
|
+
# A restart starts its successor here, on the newest chi installed
|
|
810
|
+
# (worker_command), once this one's lock is free.
|
|
811
|
+
if result == :restart ||
|
|
812
|
+
(%i[idle_exit exit_requested].include?(result) && !SessionInbox.find_new_input_files(session_dir).empty?)
|
|
676
813
|
resume_session(session_id, state_dir: sd)
|
|
677
814
|
elsif worker.discard?
|
|
678
815
|
discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
|
|
@@ -707,16 +844,31 @@ module Samagotchi
|
|
|
707
844
|
session.save(state_dir: state_dir)
|
|
708
845
|
end
|
|
709
846
|
|
|
710
|
-
# The worker's command line
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
847
|
+
# The worker's command line. Run from an installed gem, the worker
|
|
848
|
+
# activates the newest installed samagotchi (`>= 0.a`, prereleases
|
|
849
|
+
# count), whoever spawns it: an old chi web, chi send or --attach then
|
|
850
|
+
# starts new workers on the newest chi rather than on its own. Activating
|
|
851
|
+
# the gem also makes its dependencies resolve as its gemspec pins them
|
|
852
|
+
# (reline ~> 0.6.3). Should that fail (a broken newest install), the
|
|
853
|
+
# worker falls back to the spawner's own version and logs why.
|
|
854
|
+
#
|
|
855
|
+
# A source checkout (bin/chi, bundle exec; InstalledGem.spec is nil
|
|
856
|
+
# there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
|
|
857
|
+
#
|
|
858
|
+
# CROSS-VERSION CONTRACT: this boot string, run_session_loop(id,
|
|
859
|
+
# state_dir:), the env keys spawn_options sets and the input/ file
|
|
860
|
+
# format are read by a NEWER chi than the one writing them. Change them
|
|
861
|
+
# only additively (spec/worker_contract_spec.rb).
|
|
862
|
+
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
863
|
+
|
|
715
864
|
def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
|
|
716
865
|
boot = "require 'samagotchi/session_manager'; " \
|
|
717
866
|
"Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
|
|
718
|
-
|
|
719
|
-
|
|
867
|
+
return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
|
|
868
|
+
|
|
869
|
+
activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
|
|
870
|
+
"ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
|
|
871
|
+
[RbConfig.ruby, "-e", "#{activate}; #{boot}"]
|
|
720
872
|
end
|
|
721
873
|
|
|
722
874
|
private_class_method def self.spawn_worker_for_session(session, state_dir:)
|
|
@@ -729,6 +881,8 @@ module Samagotchi
|
|
|
729
881
|
env = opts.delete(:env)
|
|
730
882
|
command = worker_command(session.id, state_dir: state_dir)
|
|
731
883
|
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
884
|
+
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
885
|
+
note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
|
|
732
886
|
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
733
887
|
pid
|
|
734
888
|
end
|
|
@@ -831,7 +985,8 @@ module Samagotchi
|
|
|
831
985
|
raise OwnedByTUI, session_id
|
|
832
986
|
end
|
|
833
987
|
# A worker that idle-exited since the resume never reads it either:
|
|
834
|
-
# wake a new one. (The exiting worker also looks for input it left
|
|
988
|
+
# wake a new one. (The exiting worker also looks for input it left;
|
|
989
|
+
# one the first resume spawned that is still starting is left be.)
|
|
835
990
|
manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
|
|
836
991
|
{ status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
|
|
837
992
|
end
|