samagotchi 0.120.0 → 0.121.1

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +34 -11
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +6 -2
  12. data/docs/internals/prompt-caching.md +7 -6
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/plugins.md +28 -8
  16. data/docs/sessions.md +15 -0
  17. data/docs/testing.md +44 -2
  18. data/lib/samagotchi/answer_command.rb +1 -1
  19. data/lib/samagotchi/bridge.rb +8 -8
  20. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  21. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  22. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  23. data/lib/samagotchi/bridge_client.rb +52 -65
  24. data/lib/samagotchi/bridge_secret.rb +9 -7
  25. data/lib/samagotchi/bundle_command/check.rb +27 -2
  26. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/claude-peer/scripts/pair +21 -7
  28. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  30. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  35. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/engine.rb +1 -0
  39. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  41. data/lib/samagotchi/guardrails_command.rb +41 -6
  42. data/lib/samagotchi/image_store.rb +6 -3
  43. data/lib/samagotchi/interrupted_turn.rb +6 -3
  44. data/lib/samagotchi/kernel_loop.rb +5 -0
  45. data/lib/samagotchi/live_versions.rb +6 -7
  46. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  47. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  48. data/lib/samagotchi/plugin/api.rb +14 -8
  49. data/lib/samagotchi/plugin/sessions.rb +13 -4
  50. data/lib/samagotchi/push_command.rb +1 -1
  51. data/lib/samagotchi/relay_verifier.rb +2 -3
  52. data/lib/samagotchi/relay_watcher.rb +2 -2
  53. data/lib/samagotchi/remote_attach_command.rb +1 -1
  54. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  55. data/lib/samagotchi/reply_wait.rb +4 -3
  56. data/lib/samagotchi/review_command.rb +1 -1
  57. data/lib/samagotchi/run_options.rb +1 -0
  58. data/lib/samagotchi/session.rb +4 -0
  59. data/lib/samagotchi/session_access/local.rb +18 -8
  60. data/lib/samagotchi/session_access/remote.rb +5 -0
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  62. data/lib/samagotchi/session_access.rb +21 -9
  63. data/lib/samagotchi/session_continue.rb +2 -3
  64. data/lib/samagotchi/session_manager.rb +118 -28
  65. data/lib/samagotchi/sessions_command.rb +10 -5
  66. data/lib/samagotchi/system_one/client.rb +6 -1
  67. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  68. data/lib/samagotchi/system_one/log.rb +21 -6
  69. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  70. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  71. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  72. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  73. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  74. data/lib/samagotchi/tool_declarations.rb +1 -1
  75. data/lib/samagotchi/tool_runner.rb +37 -1
  76. data/lib/samagotchi/toolkit.rb +117 -27
  77. data/lib/samagotchi/toolkit_binding.rb +32 -0
  78. data/lib/samagotchi/toolkit_command.rb +23 -0
  79. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  80. data/lib/samagotchi/tools/delegate.rb +1 -1
  81. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  82. data/lib/samagotchi/tools/registry.rb +20 -6
  83. data/lib/samagotchi/tools/tool_index.rb +103 -20
  84. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +123 -65
  87. data/lib/samagotchi/web/participant_access.rb +26 -7
  88. data/lib/samagotchi/web/participant_review.rb +11 -6
  89. data/lib/samagotchi/web/participant_view.rb +7 -3
  90. data/lib/samagotchi/web/participants_admin.rb +13 -0
  91. data/lib/samagotchi/web/public/app.js +1 -1
  92. data/lib/samagotchi/web/public/forms.css +12 -0
  93. data/lib/samagotchi/web/public/index.html +7 -5
  94. data/lib/samagotchi/web/public/level_chip.js +2 -2
  95. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  96. data/lib/samagotchi/web/public/notifications.js +21 -1
  97. data/lib/samagotchi/web/public/participant.html +4 -2
  98. data/lib/samagotchi/web/public/participant.js +34 -11
  99. data/lib/samagotchi/web/public/participants_panel.js +49 -14
  100. data/lib/samagotchi/web/public/push.js +4 -3
  101. data/lib/samagotchi/web/public/push_devices.js +17 -3
  102. data/lib/samagotchi/web/public/question_card.js +15 -1
  103. data/lib/samagotchi/web/public/question_cards.js +11 -2
  104. data/lib/samagotchi/web/public/review_history.js +24 -4
  105. data/lib/samagotchi/web/public/review_start.js +1 -3
  106. data/lib/samagotchi/web/public/session_actions.js +14 -2
  107. data/lib/samagotchi/web/public/storage.js +16 -0
  108. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  109. data/lib/samagotchi/web/push/client.rb +5 -0
  110. data/lib/samagotchi/web/push/store.rb +16 -3
  111. data/lib/samagotchi/web/push_routes.rb +3 -2
  112. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  113. data/lib/samagotchi/web/session_create.rb +4 -4
  114. data/lib/samagotchi/web/session_hub.rb +6 -4
  115. data/lib/samagotchi/web/session_summary.rb +8 -16
  116. data/lib/samagotchi/web/tunnel.rb +10 -1
  117. data/lib/samagotchi/worker.rb +1 -1
  118. data/lib/samagotchi/worker_link.rb +213 -0
  119. data/lib/samagotchi/worker_sidecar.rb +15 -37
  120. metadata +4 -1
@@ -61,6 +61,7 @@ module Samagotchi
61
61
  # uses.
62
62
  SKIP_WORDS = {
63
63
  already_replayed: "already replayed",
64
+ judged_live: "already judged live",
64
65
  answered_by_parent: "answered by a parent agent",
65
66
  parent_cannot_allow: "answered by a parent that could not allow",
66
67
  unanswered: "unanswered",
@@ -73,6 +74,36 @@ module Samagotchi
73
74
  # and when the user was asked.
74
75
  Ask = Data.define(:question_id, :session_id, :approval, :label, :asked_at)
75
76
 
77
+ # What the judgments log already holds, so no ask is judged twice: the
78
+ # question ids its judgments name, with their origin (a replay's own, a
79
+ # live one's through its label), and, for live judgments with none (an ask that was
80
+ # cancelled, or a line older than the id), how many each (session, call
81
+ # digest) has. A card with no id match takes one of those (#take_live).
82
+ Judged = Data.define(:question_ids, :live_calls) do
83
+ # @param entries [Array<Hash>] SystemOne::Log#entries
84
+ def self.of(entries)
85
+ ids = entries.filter_map { |entry| [entry["question_id"], entry["origin"]] if entry["question_id"] }.to_h
86
+ live = Hash.new(0)
87
+ entries.each do |entry|
88
+ next unless entry["origin"] == "live" && entry["question_id"].nil?
89
+
90
+ live[[entry["session_id"], entry["call_digest"]]] += 1
91
+ end
92
+ new(question_ids: ids, live_calls: live)
93
+ end
94
+
95
+ # Whether a live judgment without a question id is this card's: true
96
+ # (and that judgment counted off) while one with its session and call
97
+ # digest is left.
98
+ def take_live(session_id, approval)
99
+ key = [session_id, SystemOne::ApprovalQuestion.call_fields(approval)["call_digest"]]
100
+ return false unless live_calls[key].positive?
101
+
102
+ live_calls[key] -= 1
103
+ true
104
+ end
105
+ end
106
+
76
107
  # @param argv [Array<String>] the arguments after "guardrails"
77
108
  # @param client [SystemOne::Client, nil] the judgment client; nil: the
78
109
  # one config.yml describes (the seam the specs pass a fake through)
@@ -122,7 +153,7 @@ module Samagotchi
122
153
 
123
154
  sessions_dir = @sessions_dir || Session.default_state_dir
124
155
  log = @log || SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(sessions_dir))
125
- replayed = File.exist?(log.path) ? log.entries.filter_map { |entry| entry["question_id"] if entry["origin"] == "replay" }.to_set : Set.new
156
+ judged = Judged.of(File.exist?(log.path) ? log.entries : [])
126
157
  @dry_run = options[:dry_run] ? true : false
127
158
 
128
159
  asks = []
@@ -140,7 +171,7 @@ module Samagotchi
140
171
  end
141
172
 
142
173
  entries.each do |entry|
143
- ask = ask_of(entry, name, replayed, sessions_dir)
174
+ ask = ask_of(entry, name, judged, sessions_dir)
144
175
  next if ask.nil?
145
176
 
146
177
  ask.is_a?(Symbol) ? skipped[ask] += 1 : asks << ask
@@ -168,9 +199,10 @@ module Samagotchi
168
199
  end
169
200
 
170
201
  # One card entry as an ask to judge: an Ask, a skip reason (:already_replayed,
171
- # :answered_by_parent, :parent_cannot_allow, :unanswered, :no_approval,
172
- # :no_question_id), or nil for an entry that is not a question at all.
173
- def ask_of(entry, session_id, replayed, sessions_dir)
202
+ # :judged_live, :answered_by_parent, :parent_cannot_allow, :unanswered,
203
+ # :no_approval, :no_question_id), or nil for an entry that is not a
204
+ # question at all.
205
+ def ask_of(entry, session_id, judged, sessions_dir)
174
206
  return nil unless entry.is_a?(Hash) && entry[:type] == "question"
175
207
 
176
208
  pending = entry[:pending_question]
@@ -178,7 +210,10 @@ module Samagotchi
178
210
 
179
211
  question_id = pending[:id].to_s
180
212
  return :no_question_id if question_id.empty?
181
- return :already_replayed if replayed.include?(question_id)
213
+
214
+ origin = judged.question_ids[question_id]
215
+ return origin == "replay" ? :already_replayed : :judged_live if origin
216
+ return :judged_live if judged.take_live(session_id, pending[:approval])
182
217
 
183
218
  answer = entry[:answer]
184
219
  label = if entry[:cancelled]
@@ -201,6 +201,9 @@ module Samagotchi
201
201
  # a request is sent.
202
202
  module ImageStore
203
203
  class Error < StandardError
204
+ # A ref to a file this session doesn't hold as an image: one wording
205
+ # for a turn's refs (#check_refs) and a web upload's (#ref_for).
206
+ def self.unknown_image(file) = new("unknown image #{file.to_s[0, 80]}")
204
207
  end
205
208
 
206
209
  DIR = "images"
@@ -278,7 +281,7 @@ module Samagotchi
278
281
 
279
282
  ref = symbolize(image)
280
283
  return "images are refs to uploaded files, not paths" if ref.key?(:path)
281
- return "unknown image #{ref[:file].to_s[0, 80]}" unless valid_ref?(session_dir, ref)
284
+ return Error.unknown_image(ref[:file]).message unless valid_ref?(session_dir, ref)
282
285
 
283
286
  { file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
284
287
  end
@@ -321,11 +324,11 @@ module Samagotchi
321
324
  # A ref for an image already stored in this session (a web upload),
322
325
  # rebuilt from the file itself: only its name comes from the caller.
323
326
  def self.ref_for(session_dir, file:, name: nil, source: "user")
324
- raise Error, "unknown image #{file.to_s[0, 80]}" unless valid_ref?(session_dir, { file: file })
327
+ raise Error.unknown_image(file) unless valid_ref?(session_dir, { file: file })
325
328
 
326
329
  bytes = File.binread(File.join(session_dir.to_s, file.to_s))
327
330
  info = ImageHeader.read(bytes)
328
- raise Error, "unknown image #{file}" unless info && MIME.key?(info.format) && info.width
331
+ raise Error.unknown_image(file) unless info && MIME.key?(info.format) && info.width
329
332
 
330
333
  name = File.basename(name.to_s).gsub(/[[:cntrl:]]/, "")[0, 120]
331
334
  { file: file.to_s, mime: MIME.fetch(info.format), width: info.width, height: info.height,
@@ -13,9 +13,12 @@ module Samagotchi
13
13
  # runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
14
14
  # carries its offer turn's), so any process that loads the session can
15
15
  # tell which turn is running (ReplyWait.own_turn); nil when it ran none,
16
- # or a worker from before it wrote one.
17
- InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
18
- def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
16
+ # or a worker from before it wrote one. author: a participant's turn's
17
+ # "p:<id>" (nil for the owner's), which its settled record keeps, so a
18
+ # chi web restarted since still knows whose wait it is
19
+ # (ParticipantAccess#turn_author).
20
+ InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id, :author) do
21
+ def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil, author: nil) = super
19
22
 
20
23
  # A saved record (string or symbol keys), an InterruptedTurn as it is;
21
24
  # nil for anything else.
@@ -176,6 +176,11 @@ module Samagotchi
176
176
  # The Guardrails::Gate ToolRunner asks before each call; the Engine sets
177
177
  # it (nil: ToolRunner's own, hooks only).
178
178
  attr_accessor :guardrail_gate
179
+ # Whether the current turn belongs to a participant, so pre-gate
180
+ # refusals can defer to ReviewBoundary.
181
+ attr_accessor :participant_turn
182
+
183
+ def participant_turn? = @participant_turn&.call == true
179
184
  # @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
180
185
  # model name, set by the Engine per turn (#run sets the model name
181
186
  # too); the thinking level is its own field, since the native path
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "session"
4
4
  require_relative "version"
5
- require_relative "worker_sidecar"
5
+ require_relative "worker_link"
6
6
  require_relative "web/info_probe"
7
7
 
8
8
  module Samagotchi
@@ -21,13 +21,12 @@ module Samagotchi
21
21
 
22
22
  # @return [Array<Worker>] the workers whose Bridge answers, by session id
23
23
  def workers(state_dir: Session.default_state_dir)
24
- Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
25
- session_dir = File.dirname(path)
26
- sidecar = WorkerSidecar.live(session_dir, unlink: false)
27
- next unless sidecar
24
+ WorkerLink.each_dir(state_dir).filter_map do |session_dir|
25
+ link = WorkerLink.resolve(session_dir, unlink: false)
26
+ next unless link.live?
28
27
 
29
- Worker.new(session_id: File.basename(session_dir), version: sidecar.version,
30
- restart: sidecar.features.include?("restart"))
28
+ Worker.new(session_id: File.basename(session_dir), version: link.version,
29
+ restart: link.features.include?("restart"))
31
30
  end
32
31
  end
33
32
 
@@ -152,7 +152,7 @@ module Samagotchi
152
152
  # @raise [Uninstaller::UninstallError] when +name+ isn't installed
153
153
  def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
154
154
  bundle = Provenance.new(name: name).record
155
- raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless bundle
155
+ raise Uninstaller::UninstallError.not_installed(name) unless bundle
156
156
 
157
157
  shipped = File.join(shipped_dir, name)
158
158
  manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
@@ -12,7 +12,11 @@ require_relative "../model_overlay"
12
12
  module Samagotchi
13
13
  module MemoryBundle
14
14
  class Uninstaller
15
- class UninstallError < StandardError; end
15
+ class UninstallError < StandardError
16
+ # The bundle +name+ has no install record: one wording for the
17
+ # uninstall of a bundle and of a profile (Profile#uninstall).
18
+ def self.not_installed(name) = new("Bundle '#{name}' is not installed")
19
+ end
16
20
 
17
21
  # removed_files: the bundle's own hooks/ and plugin/ files (deleted);
18
22
  # trashed_files: its memory files, moved to trash_dir (Trash), nil
@@ -34,7 +38,7 @@ module Samagotchi
34
38
  def run
35
39
  provenance = Provenance.new(name: @name)
36
40
  bundle = provenance.record
37
- raise UninstallError, "Bundle '#{@name}' is not installed" unless bundle
41
+ raise UninstallError.not_installed(@name) unless bundle
38
42
 
39
43
  # The scope from the flag, else the record's
40
44
  target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
@@ -24,9 +24,11 @@ module Samagotchi
24
24
  # A call not listed gets no floor check: it is in the first tag (v0.2.0:
25
25
  # chi.command/tool/on/service/init, ctx.settings, ctx.sessions,
26
26
  # ctx.ask_model, ctx.notify, ctx.card, ctx.messages…). A member of
27
- # one (ctx.sessions.children) is listed when it came later. Keys have
28
- # no "?" (API_CALL drops it). A keyword added later (ctx.ask_model's
29
- # model: in 0.80.0, ctx.sessions.send's delivery: in 0.64.0) has none.
27
+ # one (ctx.sessions.children) is listed when it came later; fork, send
28
+ # and read are in v0.2.0. Keys have no "?" (API_CALL drops it). A
29
+ # keyword added later is "call(keyword:)", the call's receiver as the
30
+ # plugin writes it (Check#keyword_calls: `ctx.sessions.send(…,
31
+ # delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
30
32
  # Events aren't listed: chi.on takes any symbol and an older chi never
31
33
  # fires one it doesn't know (harmless; chi bundle check warns on it),
32
34
  # and a manifest hook's events: list (BundleHook) needs the bundle's
@@ -42,7 +44,8 @@ module Samagotchi
42
44
  "chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
43
45
  "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
44
46
  "ctx.usage" => "0.100.0", "ctx.sessions.stop" => "0.34.0", "ctx.turn_author" => "0.106.0",
45
- "ctx.sessions.review" => "0.107.0"
47
+ "ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
48
+ "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0"
46
49
  }.freeze
47
50
 
48
51
  # @param bundle [String]
@@ -308,16 +311,18 @@ module Samagotchi
308
311
  # toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
309
312
  # a bundle has) with +add+ declared and +remove+ left out, and
310
313
  # +prompt+: text the sessions on it get in their system prompt (nil:
311
- # its base's, "" none; at most Toolkit::PROMPT_MAX characters). A
314
+ # its base's, "" none; at most Toolkit::PROMPT_MAX characters);
315
+ # +groups+: MCP source group names/globs (nil inherits its base).
316
+ # A
312
317
  # name chi (full, lean, child) or another bundle has is a load error,
313
318
  # and so is a prompt that isn't text or is too long; a base that
314
319
  # doesn't resolve leaves the sessions on it with every tool
315
320
  # (Toolkit#resolve).
316
- def toolkit(name, base:, add: [], remove: [], prompt: nil)
321
+ def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
317
322
  name = name.to_s
318
323
  raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
319
324
 
320
- problem = Toolkit.prompt_error(prompt)
325
+ problem = Toolkit.prompt_error(prompt) || (Toolkit.groups_error(groups) unless groups == :all)
321
326
  raise ArgumentError, "toolkit #{name}: #{problem}" if problem
322
327
  raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
323
328
  if Toolkit::BUILTIN_KITS.key?(name)
@@ -327,7 +332,8 @@ module Samagotchi
327
332
  end
328
333
  raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
329
334
 
330
- @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt, source: @bundle)
335
+ @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
336
+ groups: groups, source: @bundle)
331
337
  nil
332
338
  end
333
339
 
@@ -3,7 +3,7 @@
3
3
  require_relative "../client_id"
4
4
  require_relative "../delivery"
5
5
  require_relative "../session"
6
- require_relative "../bridge_client"
6
+ require_relative "../worker_link"
7
7
  require_relative "../bridge/turn_accumulator"
8
8
  require_relative "../tools/delegate"
9
9
  require_relative "../children_status"
@@ -27,6 +27,11 @@ module Samagotchi
27
27
  # A fork, send, read or stop that couldn't be done; the message says why.
28
28
  class Error < StandardError; end
29
29
 
30
+ # What starting a session can be refused by, answered as Error with
31
+ # chi's own message: a model that is gone, a toolkit the config refuses,
32
+ # a bad argument. #review adds its own: a subject it won't take.
33
+ REFUSED = [ModelProfile::MissingModel, Toolkit::Refused, ArgumentError].freeze
34
+
30
35
  # @param host [Host] session_id, cwd, model_name and state_dir
31
36
  def initialize(host)
32
37
  @host = host
@@ -44,7 +49,9 @@ module Samagotchi
44
49
  # @param prompt [String, nil] the child's first turn
45
50
  # @return [String] the child's id
46
51
  # @raise [Error] no session yet, a scratch session (the child would
47
- # outlive it), or too many children running
52
+ # outlive it), too many children running, or the model or toolkit the
53
+ # child starts on being refused (a config kit with an invalid groups:,
54
+ # a model that is gone)
48
55
  def fork(messages:, title: nil, prompt: nil)
49
56
  parent_id = @host.session_id.call or raise Error, "this session has no id yet"
50
57
  raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
@@ -60,6 +67,8 @@ module Samagotchi
60
67
  setup: @host.setup&.call || SessionSetup.new
61
68
  )
62
69
  child.id
70
+ rescue *REFUSED => e
71
+ raise Error, e.message
63
72
  end
64
73
 
65
74
  # A review of this session (ReviewStart.fork, chi review --from): a new
@@ -81,7 +90,7 @@ module Samagotchi
81
90
 
82
91
  ReviewStart.fork(from: source, subjects: Array(subjects), seed: seed, state_dir: state_dir,
83
92
  recap_wait: recap_wait).id
84
- rescue ReviewStart::Refused, ModelProfile::MissingModel, ArgumentError => e
93
+ rescue *REFUSED, ReviewStart::Refused => e
85
94
  raise Error, e.message
86
95
  end
87
96
 
@@ -212,7 +221,7 @@ module Samagotchi
212
221
  end
213
222
 
214
223
  def live_snapshot(sid, state_dir)
215
- BridgeClient.discover(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
224
+ WorkerLink.client(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
216
225
  rescue StandardError
217
226
  nil
218
227
  end
@@ -135,7 +135,7 @@ module Samagotchi
135
135
  # route does). @return [Boolean] delivered
136
136
  def test(sub)
137
137
  result = client_for(sub).deliver(sub, Web::Push::TEST_PAYLOAD, ttl: Web::Push::TEST_TTL, urgency: "high")
138
- words = "#{result.status} #{result.code}#{" (#{result.ms} ms)" if result.ms}"
138
+ words = [result.status, result.code, ("(#{result.ms} ms)" if result.ms)].reject { |part| part.to_s.empty? }.join(" ")
139
139
  words += ": #{result.error}" if result.error && !result.ok?
140
140
  if result.status == :gone
141
141
  @store.remove(sub.id)
@@ -2,8 +2,7 @@
2
2
 
3
3
  require_relative "client_id"
4
4
  require_relative "session"
5
- require_relative "bridge_client"
6
- require_relative "worker_sidecar"
5
+ require_relative "worker_link"
7
6
  require_relative "question_desk"
8
7
  require_relative "log"
9
8
 
@@ -87,7 +86,7 @@ module Samagotchi
87
86
  # @return [Hash, nil] what the parent's live Bridge says about the relay
88
87
  def parent_relay(parent_id, relay_id)
89
88
  dir = Session.session_dir(parent_id, state_dir: @state_dir)
90
- client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
89
+ client = WorkerLink.client(parent_id, session_dir: dir) or return nil
91
90
  response = client.relay_status(relay_id)
92
91
  response.ok? ? response.json : nil
93
92
  rescue SystemCallError, IOError
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "worker_sidecar"
3
+ require_relative "worker_link"
4
4
  require_relative "log"
5
5
 
6
6
  module Samagotchi
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  # @param live [#call] whether the parent's worker is up
21
21
  # @return [Thread]
22
22
  def start(engine:, question_id:, relay_id:, parent_dir:, interval: INTERVAL,
23
- live: -> { WorkerSidecar.live(parent_dir, unlink: false) })
23
+ live: -> { WorkerLink.resolve(parent_dir, unlink: false).live? })
24
24
  Thread.new do
25
25
  loop do
26
26
  sleep(interval)
@@ -48,7 +48,7 @@ module Samagotchi
48
48
  ignored(id) if existing
49
49
  launch(id, existing: existing)
50
50
  rescue TerminalUI::AttachLauncher::Error, SessionAccess::RemoteFailed, SessionAccess::Refused,
51
- SessionManager::OwnedByTUI, ModelProfile::MissingModel, ArgumentError => e
51
+ SessionManager::OwnedByTUI, ModelProfile::MissingModel, Toolkit::Refused, ArgumentError => e
52
52
  e.is_a?(SessionAccess::BadRequest) ? usage(e.message) : failed(e.message)
53
53
  rescue Interrupt
54
54
  CLI::Exit::INTERRUPTED
@@ -5,6 +5,7 @@ require_relative "../session_inbox"
5
5
  require_relative "../turn_checkpoint"
6
6
  require_relative "../continue_offer"
7
7
  require_relative "../steer"
8
+ require_relative "../worker_link"
8
9
 
9
10
  module Samagotchi
10
11
  module ReplyWait
@@ -164,11 +165,16 @@ module Samagotchi
164
165
  pending = session.pending_question
165
166
  return nil unless pending && !@baseline&.question?(pending.id) &&
166
167
  (@turn.nil? || ReplyWait.own_question?(session, @turn, @claimed)) &&
167
- session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
168
+ session.waiting_question(live: worker_owned?)
168
169
 
169
170
  Result.new(status: :waiting_for_answer, question: pending, session: session)
170
171
  end
171
172
 
173
+ # A worker holds the session's lock (a chi REPL's question is its own).
174
+ def worker_owned?
175
+ WorkerLink.resolve(Session.session_dir(@id, state_dir: @state_dir), unlink: false).worker_owned?
176
+ end
177
+
172
178
  # A participant's turn its worker holds while the owner's Continue
173
179
  # offer stands (Worker#run_input_files): it waits for the owner's
174
180
  # answer to that question, whenever it was asked (the baseline's
@@ -176,7 +182,7 @@ module Samagotchi
176
182
  def held(session)
177
183
  pending = session.pending_question
178
184
  return nil unless @participant_input && !@claimed && pending&.kind.to_s == ContinueOffer::KIND &&
179
- session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
185
+ session.waiting_question(live: worker_owned?)
180
186
 
181
187
  Result.new(status: :waiting_for_answer, question: pending, session: session)
182
188
  end
@@ -4,7 +4,7 @@ require_relative "session"
4
4
  require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
6
  require_relative "turn_checkpoint"
7
- require_relative "worker_sidecar"
7
+ require_relative "worker_link"
8
8
  require_relative "reply_wait/poll"
9
9
 
10
10
  module Samagotchi
@@ -214,9 +214,10 @@ module Samagotchi
214
214
  running || queued || ended
215
215
  end
216
216
 
217
- # Whether the session's worker sidecar names TURN_IDS_FEATURE.
217
+ # Whether the session's live worker names TURN_IDS_FEATURE.
218
218
  def turn_ids?(session_dir)
219
- WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
219
+ link = WorkerLink.resolve(session_dir, unlink: false)
220
+ link.live? && link.features.include?(TURN_IDS_FEATURE)
220
221
  end
221
222
 
222
223
  # A pending question is a turn: wait's (its enqueued_id) only when that
@@ -61,7 +61,7 @@ module Samagotchi
61
61
  start(parsed.options, parsed.args)
62
62
  rescue ReviewStart::Refused, SessionAccess::BadRequest => e
63
63
  refused(e.message)
64
- rescue ModelProfile::MissingModel => e
64
+ rescue ModelProfile::MissingModel, Toolkit::Refused => e
65
65
  refused("could not start: #{e.message}")
66
66
  rescue ArgumentError => e
67
67
  refused(e.is_a?(Session::AmbiguousId) ? e.message : e.message.sub(/\ASession not found: /, "no session "))
@@ -165,6 +165,7 @@ module Samagotchi
165
165
  chi review FILE... [--dir DIR] | --from ID a review session to share (chi share)
166
166
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
167
167
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
168
+ chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
168
169
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
169
170
  chi models [--format json] [TEXT] the models --model takes, from every host
170
171
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
@@ -427,10 +427,14 @@ module Samagotchi
427
427
  # seed); [] by default
428
428
  # @param setup [SessionSetup] the session's own settings (none by default)
429
429
  # @param now [Time] its created_at and updated_at (a spec's clock)
430
+ # @raise [Toolkit::Refused] its toolkit (setup's, else the configured
431
+ # one SessionManager.spawn_session gives it) builds on a config kit
432
+ # with an invalid groups: (Toolkit.check_new!)
430
433
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
431
434
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
432
435
  scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil,
433
436
  review: nil, now: Time.now)
437
+ Toolkit.check_new!(setup.toolkit || Toolkit.configured(delegate: delegate))
434
438
  now = now.iso8601(3)
435
439
  resolved_test = if test_run.nil?
436
440
  test_session_env?
@@ -14,6 +14,7 @@ require_relative "../context_sources"
14
14
  require_relative "../send_command/busy"
15
15
  require_relative "../chi_invocation"
16
16
  require_relative "../worker_sidecar"
17
+ require_relative "../worker_link"
17
18
  require_relative "../review_start"
18
19
  require_relative "../launch_mode"
19
20
  require_relative "../participants"
@@ -95,13 +96,22 @@ module Samagotchi
95
96
  session = show(id)
96
97
  raise Refused.new(LaunchMode::SCRATCH_LEFTOVER, code: "scratch") if session.scratch
97
98
 
98
- live = WorkerSidecar.live(session_dir(id), unlink: true)
99
- return Woken.new(woke: false, sidecar: live) if live
99
+ link = WorkerLink.resolve(session_dir(id), unlink: true)
100
+ case link
101
+ when WorkerLink::Live then return Woken.new(woke: false, sidecar: link.sidecar)
102
+ when WorkerLink::Skew then raise Refused.new(link.message(id), code: "worker_newer")
103
+ when WorkerLink::Starting
104
+ # A worker that never publishes its Bridge (a failed start, a
105
+ # crash's teardown) lets go of the lock: then resume, not 504.
106
+ found = WorkerLink.await(id, session_dir: session_dir(id), timeout: wait,
107
+ give_up: ->(link) { !link.worker_owned? })
108
+ return Woken.new(woke: false, sidecar: found.live? ? found.sidecar : nil) if found.worker_owned?
109
+ end
100
110
 
101
111
  OwnerLock.await_release(session_dir(id))
102
112
  SessionManager.resume_session(id, **dir)
103
- found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
104
- Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
113
+ found = WorkerLink.await(id, session_dir: session_dir(id), timeout: wait)
114
+ Woken.new(woke: true, sidecar: found.live? ? found.sidecar : nil)
105
115
  end
106
116
 
107
117
  # The session's worker now, never started (chi web's GET …/worker).
@@ -109,8 +119,8 @@ module Samagotchi
109
119
  # @raise [ArgumentError] no such session
110
120
  def worker(id)
111
121
  show(id)
112
- WorkerState.new(sidecar: WorkerSidecar.live(session_dir(id), unlink: true),
113
- stopped: Session.stopped_marker?(id, state_dir: state_dir!), owner: owner(id))
122
+ WorkerState.new(link: WorkerLink.resolve(session_dir(id), unlink: true),
123
+ stopped: Session.stopped_marker?(id, state_dir: state_dir!))
114
124
  end
115
125
 
116
126
  # --- create and send
@@ -148,7 +158,7 @@ module Samagotchi
148
158
  # Whether a just-created session's worker came up (its Bridge
149
159
  # answers) within +timeout+ seconds.
150
160
  def await_worker(id, timeout:)
151
- BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: timeout)
161
+ WorkerLink.await(id, session_dir: session_dir(id), timeout: timeout).live?
152
162
  end
153
163
 
154
164
  # Copies of +refs+ (ImageStore refs stored in +from+) in the
@@ -479,7 +489,7 @@ module Samagotchi
479
489
  # The block's post to the session's live worker, again on a 408 (the
480
490
  # worker didn't take it in time).
481
491
  def on_worker(id)
482
- client = BridgeClient.discover(id, session_dir: session_dir(id)) or return nil
492
+ client = WorkerLink.client(id, session_dir: session_dir(id)) or return nil
483
493
  reply = nil
484
494
  2.times do
485
495
  reply = yield client
@@ -12,6 +12,7 @@ require_relative "../bridge_client"
12
12
  require_relative "../session_continue"
13
13
  require_relative "../guardrails/parent_policy"
14
14
  require_relative "../model_profile"
15
+ require_relative "../web/grants"
15
16
  require_relative "http_transport"
16
17
  require_relative "../chi_invocation"
17
18
 
@@ -679,6 +680,10 @@ module Samagotchi
679
680
  # No model configured there, or --model's host unknown there: what
680
681
  # spawn_session raises here (chi send: could not start a session).
681
682
  when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
683
+ # A toolkit whose config kit's groups: is invalid there: the same.
684
+ when "toolkit_refused" then raise Toolkit::Refused, message.to_s
685
+ # The grants store doesn't read there: chi share says what it says here.
686
+ when "grants_unwritable" then raise Web::Grants::Unwritable, message.to_s
682
687
  when /\Ainvalid_/, "missing_fields", "bad_images"
683
688
  # invalid_participant: chi share's name, kind or ttl, an archived session
684
689
  raise BadRequest.new(message.to_s, code: error)