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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. 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 NO_PROPS if entry.chat?
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)
@@ -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
- return false if @first_preview && !@first_preview.empty?
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
- return false unless first_user
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
- ("models #{rule.models.join(",")}" if rule.models)
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" ? "session #{entry["session_id"].to_s[0, 8]}" : "in #{entry["repo_root"]}"
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 :exit_requested
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
- Log.info(:worker, "start", cwd: Dir.pwd)
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
- if %i[idle_exit exit_requested].include?(result) && !SessionInbox.find_new_input_files(session_dir).empty?
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: this chi's lib/ and ruby. Run from an
711
- # installed gem, the worker activates that gem first, so its dependencies
712
- # resolve as the gemspec pins them (reline ~> 0.6.3) rather than to the
713
- # newest installed version. A source checkout (bin/chi, bundle exec)
714
- # keeps the plain -I lib.
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
- boot = "gem 'samagotchi', '= #{gem_spec.version}'; #{boot}" if gem_spec
719
- [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot]
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