samagotchi 0.91.0 → 0.93.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -15,6 +15,9 @@
15
15
  # default: 5.00 USD cap for a session with none set; absent/0/off: none
16
16
  # child: 2.00 for delegate children (ctx.delegate?); absent: default
17
17
  # warn_at: 0.8 the share of the cap that warns; 0/off: no warning
18
+ # A cap reads as a number (3, .5, 1e1, "$2" as text too); one that doesn't
19
+ # (cap: five) warns the user once and the next key is used, so a typo is
20
+ # never a silent "no cap".
18
21
  #
19
22
  # State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
20
23
  # written by /cap and by Continue; "cap": null (or false) is off for the
@@ -32,12 +35,15 @@ class Plugin
32
35
  # spends more, Stop denies; under hard a parent may only Stop.
33
36
  EFFECTS = { "Continue" => :spend, "Stop" => :deny }.freeze
34
37
  WARN_AT = 0.8
38
+ # A cap as text: 3, 2.5, .5, 1e1, -1 (a negative is none), after a "$".
39
+ NUMBER = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?\z/i
35
40
 
36
41
  def initialize(_settings)
37
42
  @mutex = Mutex.new
38
43
  @resolved = nil # {session:, cap:, step:}
39
44
  @warned = nil # [session id, cap] the share warning was given for
40
45
  @unknown_warned = nil # the session id the "can't be enforced" notice was given for
46
+ @unread_warned = nil # the session id the "isn't a number" notice was given for
41
47
  end
42
48
 
43
49
  def register(chi)
@@ -50,13 +56,39 @@ class Plugin
50
56
  end
51
57
  end
52
58
 
53
- # The cap in USD (a positive Float) or nil (none, 0, off, false).
59
+ # The cap in USD (a positive Float) or nil (none, 0, off, false, or a
60
+ # value that isn't a number: .readable? tells them apart).
54
61
  def self.amount(value)
62
+ number = number(value)
63
+ number&.positive? ? number : nil
64
+ end
65
+
66
+ # +value+ as a finite Float (a number, or text NUMBER reads after a "$"),
67
+ # else nil. "5." gets its 0 first (.float_text: Ruby 3.3's Float wants
68
+ # one); 1e400 (Infinity) is no cap anyone reaches.
69
+ def self.number(value)
55
70
  number = case value
56
71
  when Numeric then value.to_f
57
- when String then value.strip.delete_prefix("$").then { |s| s.match?(/\A\d+(\.\d+)?\z/) ? s.to_f : nil }
72
+ when String
73
+ text = value.strip.delete_prefix("$")
74
+ Float(float_text(text)) if text.match?(NUMBER)
58
75
  end
59
- number&.positive? ? number : nil
76
+ number if number&.finite?
77
+ end
78
+
79
+ # A NUMBER as Float reads it on every Ruby: a bare point ("5.", "5.e1")
80
+ # gets a 0 after it.
81
+ def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
82
+
83
+ # Whether +value+ reads as a cap or as none: a number (text too), off,
84
+ # false or nil; not "five", true or a list.
85
+ def self.readable?(value)
86
+ case value
87
+ when false, nil then true
88
+ when Numeric then value.finite?
89
+ when String then value.strip.casecmp?("off") || !number(value).nil?
90
+ else false
91
+ end
60
92
  end
61
93
 
62
94
  # Cents, or up to four decimals under $0.10, so a small cap (/cap 0.003)
@@ -71,6 +103,7 @@ class Plugin
71
103
 
72
104
  def check(ctx)
73
105
  resolved = resolve(ctx)
106
+ unreadable(ctx, resolved)
74
107
  cap = resolved[:cap]
75
108
  return unless cap
76
109
 
@@ -101,6 +134,16 @@ class Plugin
101
134
  level: :warn)
102
135
  end
103
136
 
137
+ # Once per session: the settings whose cap didn't read, and what is used.
138
+ def unreadable(ctx, resolved)
139
+ bad = resolved[:unreadable]
140
+ return if bad.nil? || bad.empty? || !first_time?(:@unread_warned, resolved[:session])
141
+
142
+ named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
143
+ used = resolved[:cap] ? "using #{resolved[:key]}: (#{money(resolved[:cap])})" : "no cap"
144
+ ctx.notify("cost-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{used}", level: :warn)
145
+ end
146
+
104
147
  def at_cap(ctx, resolved, cost)
105
148
  cap = resolved[:cap]
106
149
  raised = raised_cap(cap, resolved[:step] || cap, cost.total)
@@ -148,12 +191,13 @@ class Plugin
148
191
  save(ctx, session, "cap" => nil)
149
192
  return "cost-cap: no cap for this session"
150
193
  end
151
- value = arg.match?(/\A\$?\d+(\.\d+)?\z/) ? self.class.amount(arg) : nil
194
+ value = self.class.amount(arg)
152
195
  return USAGE unless value
153
196
 
154
197
  save(ctx, session, "cap" => value, "step" => value)
155
198
  "cost-cap: cap set to #{money(value)} (#{money(ctx.cost.total)} spent)"
156
199
  rescue StandardError => e
200
+ ctx.log.warn(:cap_command_failed, error: "#{e.class}: #{e.message}")
157
201
  "cost-cap: #{e.message}"
158
202
  end
159
203
 
@@ -194,16 +238,17 @@ class Plugin
194
238
  nil
195
239
  end
196
240
 
241
+ # The first of cap:, child: (a delegate's) and default: that is set and
242
+ # reads (.readable?); the ones set that don't read are kept for the
243
+ # notice (#unreadable).
197
244
  def configured(ctx)
198
245
  settings = ctx.settings
199
- return { cap: nil, step: nil } unless %w[cap default child].any? { |key| settings.key?(key) }
200
-
201
- key = if settings.key?("cap") then "cap"
202
- elsif settings.key?("child") && ctx.delegate? then "child"
203
- else "default"
204
- end
205
- cap = self.class.amount(settings[key])
206
- { cap: cap, step: cap }
246
+ keys = %w[cap child default].select { |key| settings.key?(key) }
247
+ keys.delete("child") if keys.include?("child") && !ctx.delegate?
248
+ bad = keys.reject { |key| self.class.readable?(settings[key]) }
249
+ key = (keys - bad).first
250
+ cap = key && self.class.amount(settings[key])
251
+ { cap: cap, step: cap, key: key, unreadable: bad.to_h { |name| [name, settings[name]] } }
207
252
  end
208
253
 
209
254
  def warn_share(ctx)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.91.0
3
+ version: 0.93.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installed_gem"
4
+ require_relative "installed_versions"
5
+
6
+ module Samagotchi
7
+ # What a chi process another chi starts must not take from its spawner's
8
+ # environment, in one place: a session's worker (SessionManager's
9
+ # spawn_options) and `chi bundle try`'s sandboxed child
10
+ # (BundleCommand::Try::Sandbox) both scrub these. Each adds its own policy
11
+ # on top (the worker passes the CLI settings and the log file; try drops
12
+ # the user's config sections too).
13
+ module ChildEnv
14
+ # The spawner's guardrails settings: never a child's (docs/guardrails.md).
15
+ GUARDRAILS_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
16
+ # A worker's own markers (SessionManager::WORKER_PROCESS_ENV, ...).
17
+ WORKER_PREFIX = "SAMAGOTCHI_WORKER_"
18
+ # The markers a session's execute and tasks export
19
+ # (Tools::Builtins.parent_env): that session's, not a child chi's.
20
+ SESSION_MARKERS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
21
+ SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
22
+
23
+ module_function
24
+
25
+ # The keys of +env+ a child chi must not inherit: the guardrails
26
+ # settings, the session markers and a smoke's fake "newest installed"
27
+ # (InstalledVersions::ENV_KEY, for the spawner's notices only).
28
+ # @return [Array<String>]
29
+ def spawner_keys(env = ENV)
30
+ env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY]
31
+ end
32
+
33
+ # #spawner_keys mapped to nil: Process.spawn unsets a key mapped to nil.
34
+ # @return [Hash{String=>nil}]
35
+ def unset(env = ENV) = spawner_keys(env).to_h { |key| [key, nil] }
36
+
37
+ # +env+ without #spawner_keys (a copy).
38
+ # @param env [Hash]
39
+ # @return [Hash]
40
+ def scrub(env) = env.except(*spawner_keys(env))
41
+
42
+ # In gem mode, what Bundler changed in +env+ mapped back
43
+ # (InstalledGem.unbundled_env_changes): a child activates the installed
44
+ # gem itself rather than inherit this app's bundle. {} in a checkout.
45
+ # @return [Hash{String=>String, nil}]
46
+ def unbundled(env = ENV.to_h)
47
+ InstalledGem.spec ? InstalledGem.unbundled_env_changes(env) : {}
48
+ end
49
+ end
50
+ end
@@ -194,12 +194,10 @@ module Samagotchi
194
194
  def commit
195
195
  @taken.each_value do |report|
196
196
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
197
- next cursor unless cursor.report_claim&.fetch("id", nil) == report.claim_id
197
+ next cursor unless cursor.report_claim&.id == report.claim_id
198
198
 
199
- before = report.before.with(report_claim: nil)
200
- after = report.after.with(report_claim: nil)
201
- current = cursor.with(report_claim: nil)
202
- current == before ? after : current
199
+ current = cursor.without_claim
200
+ current == report.before.without_claim ? report.after.without_claim : current
203
201
  end
204
202
  delete_rings(report.rings)
205
203
  rescue StandardError => e
@@ -212,7 +210,7 @@ module Samagotchi
212
210
  def release
213
211
  @taken.each_value do |report|
214
212
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
215
- cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
213
+ cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
216
214
  end
217
215
  end
218
216
  @taken = {}
@@ -234,11 +232,11 @@ module Samagotchi
234
232
  return report.with(relay: nil) unless relay
235
233
 
236
234
  question = report.relay
237
- what = question.header.to_s.empty? ? question.hook.to_s : question.header.to_s
235
+ label = Tools::DelegateRelay.label(question)
238
236
  line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
239
- Tools::DelegateRelay.line(what, "answered on the child", hook: true)
237
+ Tools::DelegateRelay.line(label, "answered on the child")
240
238
  elsif relay.relay_desk.settled?(report.child_id, question.id)
241
- Tools::DelegateRelay.line(what, "answered on a relay already; it waits for the user on the child", hook: true)
239
+ Tools::DelegateRelay.line(label, "answered on a relay already; it waits for the user on the child")
242
240
  else
243
241
  outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
244
242
  return hold_back(report) if outcome.stopped
@@ -286,7 +284,7 @@ module Samagotchi
286
284
  def release_report(report)
287
285
  @taken.delete(report.child_id)
288
286
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
289
- cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
287
+ cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
290
288
  end
291
289
  nil
292
290
  end
@@ -330,11 +328,11 @@ module Samagotchi
330
328
  timeout: 0, turn: before.turn)
331
329
  return nil unless reportable?(wait)
332
330
 
333
- claim_id = before.report_claim&.fetch("id", nil) || SecureRandom.hex(16)
334
- after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
335
- turn: wait.status == :waiting_for_answer ? before.turn : nil, report_claim: nil)
331
+ claim_id = before.report_claim&.id || SecureRandom.hex(16)
332
+ after = before.without_claim.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
333
+ turn: wait.status == :waiting_for_answer ? before.turn : nil)
336
334
  .with_baseline(ReplyWait.baseline_of(wait.session))
337
- claim = { "id" => claim_id, "file" => wait.file, "cursor" => after.to_json_hash }
335
+ claim = Tools::ReportClaim.new(id: claim_id, file: wait.file, cursor: after)
338
336
  return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
339
337
  state_dir: @state_dir)
340
338
 
@@ -46,8 +46,26 @@ module Samagotchi
46
46
  # (#interactive?): a web tab, an attached chi TUI.
47
47
  INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
48
48
 
49
+ # A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
50
+ # runs turns for on its own, by prefix; system:reminder is "reminder",
51
+ # anything else "client".
52
+ ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
53
+ CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
54
+
49
55
  module_function
50
56
 
57
+ # What sent a turn: "delegate" (a delegating parent), "delegate_report"
58
+ # (a child's report), "context" (a context wake), "task" (a task finish
59
+ # wake), "reminder", else "client" (a person, chi send, a plugin, nil).
60
+ # @return [String]
61
+ def origin(client_id)
62
+ id = client_id.to_s
63
+ return "reminder" if id == REMINDER
64
+
65
+ ORIGIN_PREFIXES.each { |prefix, origin| return origin if id.start_with?(prefix) }
66
+ "client"
67
+ end
68
+
51
69
  # Whether +client_id+ is a web tab or a chi TUI, which see a command's
52
70
  # reply and can send the line it asks for (CacheConfirm); chi send, a
53
71
  # delegate, a plugin, chi itself and nil are not.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Config
5
+ # Tells a worker or chi web when config.yml changes a setting it only
6
+ # reads at its start (Config::Entry#lifecycle: worker_start or web_start,
7
+ # and process_start): "server.port changed — applies after a worker
8
+ # restart". ConfigFile.watch_restart_settings! adds a Watcher;
9
+ # ConfigFile.read_yaml reports each new read of the file (#notify) or a
10
+ # file that doesn't parse (#unreadable), which tells nothing and warns
11
+ # once, instead of every start-only key reading as changed.
12
+ #
13
+ # Thread-safe: the watchers list sits behind one mutex; callbacks run
14
+ # outside it.
15
+ module RestartWatch
16
+ @mutex = Mutex.new
17
+ @watchers = []
18
+
19
+ # One process's start-only settings and their start values.
20
+ class Watcher
21
+ LIFECYCLES = {
22
+ worker: %i[worker_start process_start],
23
+ web: %i[web_start process_start]
24
+ }.freeze
25
+
26
+ attr_reader :path
27
+
28
+ # @param data [Hash, nil] config.yml as the process read it at start
29
+ # @param file_key [Array, nil] that read's [mtime, size]
30
+ def initialize(process:, path:, data:, file_key:, on_change:)
31
+ lifecycles = LIFECYCLES.fetch(process) { raise ArgumentError, "unknown config process #{process.inspect}" }
32
+ raise ArgumentError, "a config change callback is required" unless on_change
33
+
34
+ @process = process
35
+ @path = path
36
+ @entries = Config::ENTRIES.select { |entry| entry.config_exposed? && lifecycles.include?(entry.lifecycle) }
37
+ @values = @entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] }
38
+ @noticed = {}
39
+ @file_key = file_key
40
+ @on_change = on_change
41
+ end
42
+
43
+ # [callback, message] for each start-only setting the file read now
44
+ # (+data+, under +file_key+) changes: once per key per new value,
45
+ # none for a value back at its start value. Empty for a read already
46
+ # seen. Caller holds RestartWatch's mutex.
47
+ def changes(file_key, data)
48
+ return [] unless seen!(file_key)
49
+
50
+ @entries.filter_map do |entry|
51
+ value = Config.resolve(entry.key, file_data: data, env: {})
52
+ if @values[entry.key] == value
53
+ @noticed.delete(entry.key)
54
+ next
55
+ end
56
+ # Already told for this value: a later edit of another key.
57
+ next if @noticed.key?(entry.key) && @noticed[entry.key] == value
58
+
59
+ @noticed[entry.key] = value
60
+ [@on_change, "#{entry.key} changed — applies after #{restart}"]
61
+ end
62
+ end
63
+
64
+ # Notes +file_key+ as read; false when it already was. Caller holds
65
+ # RestartWatch's mutex.
66
+ def seen!(file_key)
67
+ return false if @file_key == file_key
68
+
69
+ @file_key = file_key
70
+ true
71
+ end
72
+
73
+ private
74
+
75
+ def restart = @process == :worker ? "a worker restart" : "restarting chi web"
76
+ end
77
+
78
+ module_function
79
+
80
+ # Adds a Watcher for +process+ (:worker or :web).
81
+ # @return [Proc] unsubscribes
82
+ def watch(process:, path:, data:, file_key:, &on_change)
83
+ watcher = Watcher.new(process: process, path: path, data: data, file_key: file_key, on_change: on_change)
84
+ @mutex.synchronize { @watchers << watcher }
85
+ -> { @mutex.synchronize { @watchers.delete(watcher) } }
86
+ end
87
+
88
+ # Tells each watcher of +path+ which of its start-only settings the file
89
+ # read now (+data+, under +file_key+; nil for a missing file) changes.
90
+ def notify(path, file_key, data)
91
+ notices = @mutex.synchronize do
92
+ @watchers.select { |watcher| watcher.path == path }.flat_map { |watcher| watcher.changes(file_key, data) }
93
+ end
94
+ notices.each { |callback, message| callback.call(message) }
95
+ end
96
+
97
+ # The file at +path+ (under +file_key+) doesn't parse as a mapping
98
+ # (+problem+ says why): no watcher is told anything (its settings stay
99
+ # compared with the next read that parses), and a process watching it
100
+ # warns once per problem.
101
+ def unreadable(path, file_key, problem)
102
+ watched = @mutex.synchronize do
103
+ @watchers.select { |watcher| watcher.path == path }.each { |watcher| watcher.seen!(file_key) }.any?
104
+ end
105
+ return unless watched
106
+
107
+ ConfigFile.warn_once("config: #{path} doesn't parse (#{problem}); " \
108
+ "start-only settings are checked again when it does")
109
+ end
110
+ end
111
+ end
112
+ end
@@ -10,6 +10,7 @@ require_relative "model_ref"
10
10
  require_relative "paths"
11
11
  require_relative "llm_context_strategy"
12
12
  require_relative "host_model"
13
+ require_relative "config/restart_watch"
13
14
 
14
15
  module Samagotchi
15
16
  # Parses the thinking: levels of host and model entries (it needs Config).
@@ -556,10 +557,6 @@ module Samagotchi
556
557
  resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
557
558
  end
558
559
 
559
- def set_cli_overrides(overrides)
560
- reload!(cli_overrides: overrides)
561
- end
562
-
563
560
  # Problems with the keys of a parsed config.yml, one message per key the
564
561
  # code doesn't read (with a "did you mean" when a known key is close).
565
562
  # Every config-exposed entry in ENTRIES is known as written; names under
@@ -750,7 +747,7 @@ module Samagotchi
750
747
  cache = @yaml_cache ||= {}
751
748
  unless File.file?(path)
752
749
  cache.delete(path)
753
- notify_restart_settings(path, nil, nil)
750
+ Config::RestartWatch.notify(path, nil, nil)
754
751
  return nil
755
752
  end
756
753
  stat = File.stat(path)
@@ -758,75 +755,37 @@ module Samagotchi
758
755
  hit = cache[path]
759
756
  return hit[1] if hit && hit[0] == key
760
757
 
758
+ problem = nil
761
759
  data = begin
762
760
  YAML.safe_load_file(path, permitted_classes: [], aliases: false)
763
- rescue StandardError
761
+ rescue StandardError => e
762
+ problem = "#{e.class}: #{e.message.lines.first.to_s.strip[0, 200]}"
764
763
  nil
765
764
  end
765
+ problem ||= "its top level isn't a mapping" unless data.nil? || data.is_a?(Hash)
766
766
  data = nil unless data.is_a?(Hash)
767
767
  cache[path] = [key, data]
768
- notify_restart_settings(path, key, data)
768
+ if problem
769
+ Config::RestartWatch.unreadable(path, key, problem)
770
+ else
771
+ Config::RestartWatch.notify(path, key, data)
772
+ end
769
773
  data
770
774
  end
771
775
 
772
776
  # Calls +on_change+ with one line ("server.port changed — applies after a
773
777
  # worker restart") when config.yml changes a setting this process only
774
- # reads at its start (Config::Entry#lifecycle: worker_start or web_start,
775
- # and process_start). Checked when the file is read again (#read_yaml),
776
- # once per key per new value: none at start, none for a value back at its
777
- # start value.
778
+ # reads at its start (Config::RestartWatch). Checked when the file is
779
+ # read again (#read_yaml), once per key per new value: none at start,
780
+ # none for a value back at its start value, none while the file doesn't
781
+ # parse (that warns once).
778
782
  # @param process [Symbol] :worker or :web
779
783
  # @return [Proc] unsubscribes
780
- def watch_restart_settings!(process:, env: ENV, &on_change)
781
- lifecycles = case process
782
- when :worker then %i[worker_start process_start]
783
- when :web then %i[web_start process_start]
784
- else raise ArgumentError, "unknown config process #{process.inspect}"
785
- end
786
- raise ArgumentError, "a config change callback is required" unless on_change
787
-
784
+ def watch_restart_settings!(process:, env: ENV, &)
788
785
  path = global_path(env: env)
789
786
  data = read_yaml(env: env, path: path)
790
- entries = Config::ENTRIES.select { |entry| entry.config_exposed? && lifecycles.include?(entry.lifecycle) }
791
- watcher = {
792
- path: path, process: process, entries: entries,
793
- values: entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] },
794
- noticed: {},
795
- file_key: File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil,
796
- on_change: on_change
797
- }
798
- restart_watchers_mutex.synchronize { (@restart_watchers ||= []) << watcher }
799
- -> { restart_watchers_mutex.synchronize { @restart_watchers&.delete(watcher) } }
800
- end
801
-
802
- # Tells each watcher of +path+ which of its start-only settings the file
803
- # read now (+data+, under +file_key+) changes.
804
- def notify_restart_settings(path, file_key, data)
805
- notices = restart_watchers_mutex.synchronize do
806
- Array(@restart_watchers).flat_map do |watcher|
807
- next [] unless watcher[:path] == path && watcher[:file_key] != file_key
808
-
809
- watcher[:file_key] = file_key
810
- watcher[:entries].filter_map do |entry|
811
- value = Config.resolve(entry.key, file_data: data, env: {})
812
- if watcher[:values][entry.key] == value
813
- watcher[:noticed].delete(entry.key)
814
- next
815
- end
816
- # Already told for this value: a later edit of another key.
817
- next if watcher[:noticed].key?(entry.key) && watcher[:noticed][entry.key] == value
818
-
819
- watcher[:noticed][entry.key] = value
820
- restart = watcher[:process] == :worker ? "a worker restart" : "restarting chi web"
821
- [watcher[:on_change], "#{entry.key} changed — applies after #{restart}"]
822
- end
823
- end
824
- end
825
- notices.each { |callback, message| callback.call(message) }
826
- end
827
-
828
- def restart_watchers_mutex
829
- @restart_watchers_mutex ||= Mutex.new
787
+ file_key = File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil
788
+ Config::RestartWatch.watch(process: process, path: path, data: data, file_key: file_key, &)
830
789
  end
831
790
 
832
791
  # Print +message+ to stderr unless this process already did. The config
@@ -1402,14 +1402,7 @@ module Samagotchi
1402
1402
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1403
1403
  # @param event [Hash] the turn's end event (#stop_facts)
1404
1404
  def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1405
- client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1406
- source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
1407
- elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
1408
- elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
1409
- elsif client_id.start_with?(ClientId::TASK_PREFIX) then "task"
1410
- elsif client_id == ClientId::REMINDER then "reminder"
1411
- else "client"
1412
- end
1405
+ source = ClientId.origin(origin.is_a?(Hash) ? origin[:client_id] : nil)
1413
1406
  enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
1414
1407
  session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1415
1408
  origin: source, exhausted: limit ? true : nil, limit: limit,
@@ -1523,10 +1516,9 @@ module Samagotchi
1523
1516
  def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
1524
1517
  private :guardrail_notify
1525
1518
 
1519
+ # @param question [HookQuestion]
1526
1520
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
1527
- def hook_ask_user(question, options, header, allow_freeform, hook, effects: nil)
1528
- @hook_routes.ask_user(question, options, header, allow_freeform, hook, effects: effects)
1529
- end
1521
+ def hook_ask_user(question) = @hook_routes.ask_user(question)
1530
1522
  private :hook_ask_user
1531
1523
 
1532
1524
  # @return [Boolean] true when a running turn was cancelled now
@@ -1828,8 +1820,9 @@ module Samagotchi
1828
1820
  # that put the turn's first message in the session already (a context
1829
1821
  # wake's note) names it with this id
1830
1822
  #
1831
- # An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
1832
- # the prompt is kept in the session and :turn_canceled is emitted, then the
1823
+ # An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
1824
+ # session as after a Stop (the prompt, the loop's finished tool
1825
+ # iterations, a cancel note) and :turn_canceled is emitted, then the
1833
1826
  # Interrupt is re-raised so the caller still decides whether to exit. One
1834
1827
  # after the turn's end event (in its after_turn/session_end hooks) is
1835
1828
  # only re-raised: the turn already ended; so is any error there. Any
@@ -1856,14 +1849,14 @@ module Samagotchi
1856
1849
  complete_turn(turn, result)
1857
1850
  warm_up_next_turn(turn, result)
1858
1851
  result
1859
- rescue Interrupt
1852
+ rescue Interrupt => e
1860
1853
  # After the end event (in the after_turn/session_end hooks) the
1861
1854
  # turn is over: no second ending, the answer stays.
1862
1855
  raise if turn.ended
1863
1856
 
1864
1857
  turn.controller.cancel!(:ctrl_c)
1865
1858
  end_turn(turn, "canceled") do |seconds|
1866
- [ctrl_c_messages(turn, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
1859
+ [ctrl_c_messages(turn, e, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
1867
1860
  end
1868
1861
  raise
1869
1862
  rescue StandardError => e
@@ -2263,17 +2256,26 @@ module Samagotchi
2263
2256
  end
2264
2257
  private :kept_messages
2265
2258
 
2266
- # A Ctrl-C keeps the turn's messages so far with a cancel note (nil
2267
- # before there are any). Only the pre-turn messages survive here, so
2268
- # tasks this turn started are missed (plan wait-stop §6).
2269
- def ctrl_c_messages(turn, seconds)
2270
- return nil unless turn.messages
2259
+ # A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
2260
+ # finished tool iterations, every call answered: ToolResponse.run_batch),
2261
+ # else the turn's messages so far (it came before the loop), with a
2262
+ # cancel note naming the tasks still running; nil before there are any.
2263
+ def ctrl_c_messages(turn, interrupt, seconds)
2264
+ kept = loop_conversation(interrupt) || turn.messages
2265
+ return nil unless kept
2271
2266
 
2272
- note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(turn.messages))
2273
- TurnNote.replace_trailing(turn.messages, note)
2267
+ note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
2268
+ TurnNote.replace_trailing(kept, note)
2274
2269
  end
2275
2270
  private :ctrl_c_messages
2276
2271
 
2272
+ # The conversation the loop had built when +error+ ended the turn
2273
+ # (LLM::FailedTurn), or nil.
2274
+ def loop_conversation(error)
2275
+ error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
2276
+ end
2277
+ private :loop_conversation
2278
+
2277
2279
  # A failure keeps the loop's partial conversation (else the turn's
2278
2280
  # messages so far) with a failed note; nil when the turn never reached
2279
2281
  # the model. The model reads why on its next turn. A turn that got to
@@ -2283,7 +2285,7 @@ module Samagotchi
2283
2285
  # turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
2284
2286
  # note of their own; -p --non-interactive keeps what this keeps.
2285
2287
  def failed_messages(turn, error)
2286
- kept = error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array) ? error.partial_conversation : turn.messages
2288
+ kept = loop_conversation(error) || turn.messages
2287
2289
  return nil unless kept
2288
2290
 
2289
2291
  steps = LLM::FailedTurn.progress(turn.session.messages, kept)
@@ -2559,9 +2561,7 @@ module Samagotchi
2559
2561
  messages: -> { plugin_messages },
2560
2562
  messages_partial: -> { turn_running? && @running_turn_messages.nil? },
2561
2563
  notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
2562
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
2563
- hook_ask_user(question, options, header, allow_freeform, hook, effects: effects)
2564
- },
2564
+ ask_user: ->(question) { hook_ask_user(question) },
2565
2565
  # An init task's is its own (a Ctrl-C ends a turn, not the task).
2566
2566
  cancelled: lambda {
2567
2567
  (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?