samagotchi 0.125.0 → 0.126.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/docs/guardrails.md +22 -0
  4. data/docs/internals/participants.md +70 -2
  5. data/docs/internals/web-push.md +10 -6
  6. data/docs/sessions.md +3 -0
  7. data/lib/samagotchi/bridge.rb +4 -3
  8. data/lib/samagotchi/bridge_client.rb +8 -1
  9. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  10. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/child_reports.rb +3 -0
  13. data/lib/samagotchi/children_status.rb +6 -1
  14. data/lib/samagotchi/engine.rb +43 -7
  15. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  16. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  17. data/lib/samagotchi/session.rb +40 -3
  18. data/lib/samagotchi/session_inbox.rb +14 -8
  19. data/lib/samagotchi/session_manager.rb +11 -4
  20. data/lib/samagotchi/session_participants.rb +10 -2
  21. data/lib/samagotchi/verdict.rb +20 -0
  22. data/lib/samagotchi/verify_anchor.rb +28 -0
  23. data/lib/samagotchi/verify_fresh.rb +275 -0
  24. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +3 -0
  27. data/lib/samagotchi/web/participant_access.rb +15 -4
  28. data/lib/samagotchi/web/participant_review.rb +7 -1
  29. data/lib/samagotchi/web/participant_view.rb +46 -5
  30. data/lib/samagotchi/web/public/annotate_bar.js +9 -1
  31. data/lib/samagotchi/web/public/app.js +69 -4
  32. data/lib/samagotchi/web/public/data.js +2 -1
  33. data/lib/samagotchi/web/public/index.html +1 -0
  34. data/lib/samagotchi/web/public/notify.js +13 -2
  35. data/lib/samagotchi/web/public/participant.html +2 -1
  36. data/lib/samagotchi/web/public/participant.js +69 -7
  37. data/lib/samagotchi/web/public/send_turn.js +6 -1
  38. data/lib/samagotchi/web/public/session_state.js +5 -0
  39. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  40. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  41. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  42. data/lib/samagotchi/web/public/verify_card.css +17 -0
  43. data/lib/samagotchi/web/push/attention.rb +25 -5
  44. data/lib/samagotchi/web/push/notifier.rb +4 -3
  45. data/lib/samagotchi/web/session_control.rb +2 -0
  46. data/lib/samagotchi/web/session_summary.rb +25 -1
  47. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  48. data/lib/samagotchi/worker.rb +68 -9
  49. data/lib/samagotchi/worker_inbound.rb +4 -4
  50. metadata +9 -1
@@ -20,6 +20,10 @@ require_relative "project_scope"
20
20
  require_relative "guardrails/parent_levels"
21
21
 
22
22
  module Samagotchi
23
+ # Loaded on first use (a Verify fresh child): keeps the bundle test kit's
24
+ # load budget (spec/samagotchi/testing/sandbox_spec.rb).
25
+ autoload :VerifyFreshOrigin, File.expand_path("verify_fresh_origin", __dir__)
26
+
23
27
  # archive_store requires this file.
24
28
  autoload :ArchiveStore, File.expand_path("archive_store", __dir__)
25
29
 
@@ -134,6 +138,14 @@ module Samagotchi
134
138
  @last_turn = LastTurn.from_file(value)
135
139
  end
136
140
 
141
+ # Verdict cards recorded by completed Verify turns, in append order.
142
+ # A missing field stays missing until the first verdict is recorded.
143
+ def verdicts = @verdicts || []
144
+
145
+ def verdicts=(values)
146
+ @verdicts = values.nil? ? nil : Array(values)
147
+ end
148
+
137
149
  # The mark of a turn a worker checkpointed and never finished
138
150
  # (InterruptedTurn), as loaded; the next worker settles it and sets it
139
151
  # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
@@ -280,6 +292,20 @@ module Samagotchi
280
292
  # Whether this is a review session (chi review, the web's Start review).
281
293
  def review? = !@review.nil?
282
294
 
295
+ # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
296
+ # nil for any other session. Set once, at spawn (VerifyFresh.start).
297
+ attr_reader :verify_fresh
298
+
299
+ # @param value [VerifyFreshOrigin, Hash, nil] the session file's form too
300
+ def verify_fresh=(value)
301
+ @verify_fresh = value.nil? ? nil : VerifyFreshOrigin.of(value)
302
+ end
303
+
304
+ # The session whose participants.json names this session's
305
+ # participants: a Verify fresh child's parent (its requester's grant is
306
+ # the parent's), else the session itself.
307
+ def participants_source_id = @verify_fresh && @parent_id ? @parent_id : @id
308
+
283
309
  # How many tool outputs its history holds: its tool messages
284
310
  # (tool_response, a chat loop's tool; a native step's one message holds
285
311
  # every call's output and counts once).
@@ -333,7 +359,7 @@ module Samagotchi
333
359
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
334
360
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
335
361
  toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
336
- working_set_pins: [], review: nil)
362
+ working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil)
337
363
  @id = id
338
364
  @metadata_version = metadata_version
339
365
  @mode = mode
@@ -370,6 +396,8 @@ module Samagotchi
370
396
  self.parent_answers = parent_answers
371
397
  self.working_set_pins = working_set_pins
372
398
  self.review = review
399
+ self.verdicts = verdicts
400
+ self.verify_fresh = verify_fresh
373
401
  self.interrupted_turn = interrupted_turn
374
402
  @turn_checkpoint = nil
375
403
  @archived = false
@@ -539,6 +567,7 @@ module Samagotchi
539
567
  "parent_id" => nil,
540
568
  "scratch" => false,
541
569
  "last_turn" => nil,
570
+ "verdicts" => nil,
542
571
  "delegate" => false,
543
572
  "llm_context" => nil,
544
573
  "continues" => nil,
@@ -553,12 +582,13 @@ module Samagotchi
553
582
  "parent_answers" => nil,
554
583
  "working_set_pins" => [],
555
584
  "review" => nil,
556
- "first_turn_id" => nil
585
+ "first_turn_id" => nil,
586
+ "verify_fresh" => nil
557
587
  }.freeze
558
588
 
559
589
  # FIELDS a session file has only when set: a session without them saves
560
590
  # as it did before they existed.
561
- OPTIONAL_FIELDS = %w[review first_turn_id].freeze
591
+ OPTIONAL_FIELDS = %w[review first_turn_id verdicts verify_fresh].freeze
562
592
 
563
593
  # A session from a parsed session file. +messages+ false leaves the
564
594
  # conversation out (the lists' lightweight summaries).
@@ -712,6 +742,13 @@ module Samagotchi
712
742
  when "last_turn" then @last_turn&.to_file
713
743
  when "working_set_pins" then @working_set_pins.map(&:to_file)
714
744
  when "review" then @review&.to_file
745
+ when "verify_fresh" then @verify_fresh&.to_file
746
+ when "verdicts"
747
+ @verdicts&.map do |record|
748
+ scrub_utf8(record.to_h do |key, entry|
749
+ [key.to_s, entry.is_a?(Hash) ? stringify_message_keys(entry) : entry]
750
+ end)
751
+ end
715
752
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
716
753
  Array(instance_variable_get(:"@#{key}"))
717
754
  else instance_variable_get(:"@#{key}")
@@ -12,6 +12,10 @@ require_relative "steer"
12
12
  require_relative "turn_origin"
13
13
 
14
14
  module Samagotchi
15
+ # Loaded on first use (a Verify turn's input): keeps the bundle test kit's
16
+ # load budget (spec/samagotchi/testing/sandbox_spec.rb).
17
+ autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
18
+
15
19
  # A session directory's file inbox and outbox (see SessionManager for the
16
20
  # layout): input/ holds one JSON file per queued turn, notes/ the context
17
21
  # notes a worker adds between turns, output/ one text file per reply,
@@ -53,7 +57,7 @@ module Samagotchi
53
57
  # @param now [Time] the time the file is named by (a spec's clock)
54
58
  # @return [String, false] the input file's path, or false
55
59
  def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
56
- delivery: nil, author: nil, now: Time.now)
60
+ delivery: nil, author: nil, verify: nil, now: Time.now)
57
61
  images = Array(images)
58
62
  input_dir = File.join(session_dir, INPUT_DIR)
59
63
  FileUtils.mkdir_p(input_dir)
@@ -63,7 +67,7 @@ module Samagotchi
63
67
  "no_interrupt" => (no_interrupt ? true : nil),
64
68
  "delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
65
69
  "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }),
66
- "author" => author }.compact
70
+ "author" => author, "verify" => VerifyAnchor.from(verify)&.to_file }.compact
67
71
  AtomicFile.write(path, JSON.generate(record))
68
72
  path
69
73
  rescue StandardError
@@ -228,9 +232,9 @@ module Samagotchi
228
232
  # (+no_interrupt+, --no-interrupt), its +images+ ({file:, name:} refs),
229
233
  # and its +delivery+ ("queue" or nil; a file without the key is one an
230
234
  # older chi wrote, so nil).
231
- Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
232
- def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil)
233
- super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:)
235
+ Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery, :verify) do
236
+ def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil, verify: nil)
237
+ super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:, verify: verify && VerifyAnchor.from(verify))
234
238
  end
235
239
  end
236
240
  # A file that doesn't parse, or isn't a JSON object: no prompt.
@@ -246,7 +250,7 @@ module Samagotchi
246
250
  origin = TurnOrigin.from(data.slice("client_id", "enqueued_id", "author"))
247
251
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
248
252
  Input.new(prompt: data["prompt"].to_s, origin: origin, no_interrupt: data["no_interrupt"] == true,
249
- images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
253
+ images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil, verify: data["verify"])
250
254
  rescue JSON::ParserError
251
255
  UNREADABLE_INPUT
252
256
  end
@@ -278,9 +282,11 @@ module Samagotchi
278
282
  true
279
283
  end
280
284
 
281
- # A parsed input file's #waits_for_turn_end?.
285
+ # A parsed input file's #waits_for_turn_end?. A Verify turn waits too:
286
+ # merged into a running turn as a steer, its anchor would be lost.
282
287
  def self.turn_end_data?(data)
283
- Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"])
288
+ Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"]) ||
289
+ data["verify"].is_a?(Hash)
284
290
  end
285
291
 
286
292
  # How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
@@ -164,6 +164,8 @@ module Samagotchi
164
164
  # turn sees it
165
165
  # @param review [SessionReview, nil] a review session's (ReviewStart; a
166
166
  # continue keeps its previous link's)
167
+ # @param verify_fresh [VerifyFreshOrigin, nil] a Verify fresh child's
168
+ # requester, rules and claim (VerifyFresh.start)
167
169
  # @param before_spawn [#call, nil] (session) runs once the session and
168
170
  # its note are saved, before its worker spawns
169
171
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
@@ -171,7 +173,7 @@ module Samagotchi
171
173
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
172
174
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
173
175
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
174
- note_source: "chi", before_spawn: nil, review: nil)
176
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil)
175
177
  sd = state_dir || Session.default_state_dir
176
178
  # The resolved ref is stored (a resumed session keeps its model when an
177
179
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -198,6 +200,8 @@ module Samagotchi
198
200
  )
199
201
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
200
202
  session.toolkit_origin = setup.toolkit_origin || "default"
203
+ # Saved before the worker starts: its first turn is already the requester's.
204
+ session.verify_fresh = verify_fresh
201
205
  session.model_warning = model_warning
202
206
  # With no prompt there is no first turn to run (an attaching UI sends
203
207
  # the prompts), so the session starts idle.
@@ -1310,10 +1314,10 @@ module Samagotchi
1310
1314
  # session's images/
1311
1315
  # @return [String, false] the input file's path, or false.
1312
1316
  def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
1313
- images: [], delivery: nil, author: nil)
1317
+ images: [], delivery: nil, author: nil, verify: nil)
1314
1318
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1315
1319
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1316
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
1320
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, verify: verify)
1317
1321
  end
1318
1322
 
1319
1323
  # How long a turn waits for the Bridge of a worker a resume just spawned.
@@ -1395,7 +1399,8 @@ module Samagotchi
1395
1399
  # @raise [OwnedByTUI] a chi REPL owns the session
1396
1400
  # @raise [ArgumentError] no such session
1397
1401
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1398
- author: nil, pending_limit: nil, state_dir: nil, manager: self, bridge: nil)
1402
+ author: nil, pending_limit: nil, verify: nil, state_dir: nil, manager: self, bridge: nil)
1403
+ verify = VerifyAnchor.from(verify)
1399
1404
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1400
1405
  bridge ||= lambda do
1401
1406
  link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
@@ -1417,6 +1422,7 @@ module Samagotchi
1417
1422
  options[:no_interrupt] = true if no_interrupt
1418
1423
  options[:author] = author if author
1419
1424
  options[:pending_limit] = pending_limit if author && pending_limit
1425
+ options[:verify] = verify if verify
1420
1426
  reply = client.post_turn(**options)
1421
1427
  ack = reply.json
1422
1428
  return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
@@ -1454,6 +1460,7 @@ module Samagotchi
1454
1460
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1455
1461
  input[:no_interrupt] = true if no_interrupt
1456
1462
  input[:author] = author if author
1463
+ input[:verify] = verify if verify
1457
1464
  path = participant_queue_lock(session_dir, author && pending_limit) do
1458
1465
  # No worker: the participant's waiting files are all their turns,
1459
1466
  # counted and written under one lock (the Bridge counts under its).
@@ -22,8 +22,10 @@ module Samagotchi
22
22
  end
23
23
 
24
24
  # Reads +session+'s sidecar again.
25
+ # A Verify fresh child reads its parent's (Session#participants_source_id):
26
+ # its requester's grant is there.
25
27
  def refresh!(session)
26
- @session_id = session&.id
28
+ @session_id = source_id(session)
27
29
  @entries = read
28
30
  self
29
31
  end
@@ -44,7 +46,7 @@ module Samagotchi
44
46
  def entry_for(author, session: nil)
45
47
  return nil unless Steer.participant_author?(author)
46
48
 
47
- refresh!(session) if session && session.id != @session_id
49
+ refresh!(session) if session && source_id(session) != @session_id
48
50
  id = Steer.participant_id(author)
49
51
  @entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
50
52
  end
@@ -62,6 +64,12 @@ module Samagotchi
62
64
 
63
65
  private
64
66
 
67
+ def source_id(session)
68
+ return nil unless session
69
+
70
+ session.respond_to?(:participants_source_id) ? session.participants_source_id : session.id
71
+ end
72
+
65
73
  def read
66
74
  return [] if @session_id.to_s.empty?
67
75
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A parsed verdict line from a Verify turn's final answer.
5
+ Verdict = Data.define(:verdict, :reason) do
6
+ LINE = /\AVerdict:\s*(confirmed|refuted|unclear)\b\s*(?:—|–|-)\s*(.+?)\s*\z/i
7
+ MARKDOWN = /(?:\*\*|__|`)/
8
+
9
+ def self.parse(text)
10
+ line = text.to_s.lines.reverse_each.find { |candidate| !candidate.strip.empty? }
11
+ return unless line
12
+
13
+ normalized = line.strip.gsub(MARKDOWN, "")
14
+ match = LINE.match(normalized)
15
+ return unless match
16
+
17
+ new(verdict: match[1].downcase, reason: match[2].strip)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A claim selected in an earlier turn, carried only by a Verify turn.
5
+ VerifyAnchor = Data.define(:turn_id, :quote) do
6
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
7
+ QUOTE_MAX = 200
8
+
9
+ def self.from(value)
10
+ return value if value.is_a?(self)
11
+ return nil unless value.is_a?(Hash)
12
+
13
+ fields = value.to_h { |key, entry| [key.to_s, entry] }
14
+ return nil unless fields.keys.sort == %w[quote turn_id]
15
+
16
+ turn_id = fields["turn_id"]
17
+ quote = fields["quote"]
18
+ return nil unless turn_id.is_a?(String) && UUID.match?(turn_id)
19
+ return nil unless quote.is_a?(String) && quote.length.between?(1, QUOTE_MAX) && !quote.strip.empty?
20
+
21
+ new(turn_id:, quote:)
22
+ end
23
+
24
+ def to_h = { turn_id:, quote: }
25
+
26
+ def to_file = { "turn_id" => turn_id, "quote" => quote }
27
+ end
28
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "log"
8
+ require_relative "session"
9
+ require_relative "session_metrics"
10
+ require_relative "steer"
11
+ require_relative "verify_anchor"
12
+ require_relative "verify_fresh_origin"
13
+
14
+ module Samagotchi
15
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
16
+
17
+ # Verify fresh (docs/internals/participants.md, Verify fresh): an unclear
18
+ # verdict checked again by a delegate child that sees only the claim, in
19
+ # the parent session's own folder, with none of its history. The child's
20
+ # field (VerifyFreshOrigin) makes every turn of a participant's child
21
+ # theirs: their author, the boundary their grant has in the parent, their
22
+ # cap counted across the parent and all their fresh children (.spend).
23
+ #
24
+ # Two files in the parent's directory, neither in its conversation:
25
+ # - LEDGER, one line per child started (child id, the verdict it checks,
26
+ # who asked): the running check (one fresh child per verdict at a time)
27
+ # and the spend rollup read it;
28
+ # - INBOX, the child's verdicts (.forward, written by the child's worker
29
+ # after its turn's save), taken into the parent's verdicts by the
30
+ # parent's worker between turns (.take) and shown before that by chi
31
+ # web (.verdicts_of). Verdicts are display state: nothing here enters a
32
+ # prompt.
33
+ module VerifyFresh
34
+ LEDGER = "verify_fresh.jsonl"
35
+ LOCK = "verify_fresh.lock"
36
+ INBOX = "fresh_verdicts"
37
+ # The child's first message, after the claim.
38
+ ASK = "Verify this claim against the files here. You have none of the original session's context. " \
39
+ "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`."
40
+ # How long a child whose worker hasn't come up yet counts as running
41
+ # (Tools::Delegate::STARTING_GRACE_SECONDS).
42
+ STARTING_GRACE_SECONDS = 15
43
+
44
+ # A start refused: the HTTP +status+, the error +code+ and its words.
45
+ class Refused < StandardError
46
+ attr_reader :status, :code
47
+
48
+ def initialize(status, code, detail)
49
+ @status = status
50
+ @code = code
51
+ super(detail)
52
+ end
53
+ end
54
+
55
+ NOT_FOUND = "no Verify verdict with that check turn in this session"
56
+ NOT_UNCLEAR = "only an unclear verdict can be verified fresh"
57
+ RUNNING = "a fresh check of this claim is already running"
58
+ BUSY = "a fresh check this requester started is still running: one at a time each"
59
+ FULL = "this session has as many delegates running as it may (session.max_children): wait for one to finish"
60
+
61
+ module_function
62
+
63
+ # The child's first prompt: the claim, quoted, and the ask. Nothing of
64
+ # the parent's conversation.
65
+ def prompt_for(quote)
66
+ claim = quote.to_s.lines.map { |line| "> #{line.chomp}" }.join("\n")
67
+ "Claim:\n#{claim}\n\n#{ASK}"
68
+ end
69
+
70
+ # Starts a fresh child for the parent's unclear verdict +of+ (its
71
+ # check_turn_id). +author+: the requester ("p:<id>"), nil for the
72
+ # owner; +mode+: a participant's grant's mode in the parent
73
+ # (ParticipantMode.live_for), saved on the child.
74
+ # @return [Session] the child
75
+ # @raise [Refused] no such verdict, not unclear, or a fresh check of it
76
+ # still runs
77
+ def start(parent_id, of:, author:, mode: nil, state_dir: Session.default_state_dir, spawn: nil)
78
+ parent = Session.load(parent_id, state_dir: state_dir)
79
+ record = original(parent.verdicts, of) or raise Refused.new(404, "not_found", NOT_FOUND)
80
+ raise Refused.new(409, "not_unclear", NOT_UNCLEAR) unless field(record, :verdict).to_s == "unclear"
81
+
82
+ anchor = VerifyAnchor.from(field(record, :anchor)) or raise Refused.new(404, "not_found", NOT_FOUND)
83
+ author = Steer.sender_author(author)
84
+ origin = VerifyFreshOrigin.new(author: author, mode: author && mode.to_s, of: of, anchor: anchor)
85
+ dir = Session.session_dir(parent.id, state_dir: state_dir)
86
+ spawn ||= SessionManager.method(:spawn_session)
87
+ locked(dir) do
88
+ refuse_busy!(parent.id, of, author, state_dir)
89
+
90
+ child = spawn.call(prompt: prompt_for(anchor.quote), working_directory: parent.working_directory,
91
+ model_name: parent.model_name, memories: [Session::DELEGATE_MEMORY],
92
+ parent_id: parent.id, delegate: true, verify_fresh: origin, state_dir: state_dir)
93
+ append_ledger(dir, { "child_id" => child.id, "of" => of, "author" => author, "at" => Time.now.utc.iso8601(3) })
94
+ child
95
+ end
96
+ end
97
+
98
+ # Under the lock: one fresh check per verdict, one running fresh child
99
+ # per requester (a participant or the owner: each start passes the cap
100
+ # on saved spend, so parallel children would multiply it), and no more
101
+ # running children of any kind than session.max_children.
102
+ def refuse_busy!(parent_id, of, author, state_dir)
103
+ fresh = running(parent_id, state_dir: state_dir)
104
+ raise Refused.new(409, "fresh_running", RUNNING) if fresh.any? { |row| row[:of] == of }
105
+ raise Refused.new(409, "fresh_busy", BUSY) if fresh.any? { |row| row[:author] == author }
106
+
107
+ require_relative "tools/delegate"
108
+ return if Tools::Delegate.running_children(parent_id, state_dir: state_dir).size < Tools::Delegate.max_children
109
+
110
+ raise Refused.new(409, "children_full", FULL)
111
+ end
112
+ private_class_method :refuse_busy!
113
+
114
+ # The parent's verdict +of+ names, never a fresh one (a fresh verdict
115
+ # lands on its original's card; the original takes another check).
116
+ def original(verdicts, of)
117
+ Array(verdicts).find do |record|
118
+ record.is_a?(Hash) && field(record, :check_turn_id) == of && !field(record, :fresh)
119
+ end
120
+ end
121
+
122
+ # The fresh children of +parent_id+ running now, newest last: [{of:,
123
+ # child_id:, author:}].
124
+ def running(parent_id, state_dir: Session.default_state_dir)
125
+ ledger(Session.session_dir(parent_id, state_dir: state_dir)).select do |row|
126
+ child_running?(row[:child_id], state_dir)
127
+ end
128
+ end
129
+
130
+ # What +author+ spent in the fresh children of +parent_id+ (their saved
131
+ # analytics), but +except+'s (the child asking, whose own spend its
132
+ # worker counts live).
133
+ # @return [Float]
134
+ def children_spend(parent_id, author, state_dir: Session.default_state_dir, except: nil)
135
+ return 0.0 unless Steer.participant_author?(author)
136
+
137
+ ledger(Session.session_dir(parent_id, state_dir: state_dir)).sum(0.0) do |row|
138
+ next 0.0 unless row[:author] == author && row[:child_id] != except
139
+
140
+ SessionMetrics.saved_spend(Session.session_dir(row[:child_id], state_dir: state_dir), author)
141
+ end
142
+ end
143
+
144
+ # What +author+ spent outside +session+'s own metrics that counts
145
+ # against their cap there: in a parent, their fresh children's; in a
146
+ # fresh child, the parent's and its other fresh children's.
147
+ # @return [Float]
148
+ def related_spend(session, author, state_dir: Session.default_state_dir)
149
+ return 0.0 unless Steer.participant_author?(author) && session
150
+
151
+ if session.verify_fresh && session.parent_id
152
+ parent_dir = Session.session_dir(session.parent_id, state_dir: state_dir)
153
+ SessionMetrics.saved_spend(parent_dir, author) +
154
+ children_spend(session.parent_id, author, state_dir: state_dir, except: session.id)
155
+ else
156
+ children_spend(session.id, author, state_dir: state_dir)
157
+ end
158
+ rescue StandardError => e
159
+ Log.warn(:worker, "fresh_spend_failed", error: e.class.name, msg: e.message)
160
+ 0.0
161
+ end
162
+
163
+ # The child's new verdicts (+records+, Engine's verdict records) into
164
+ # its parent's INBOX, marked fresh: true with the child's id and the
165
+ # verdict they check again (of). Best effort.
166
+ # @param child [Session] a Verify fresh child
167
+ # @return [Integer] how many were written
168
+ def forward(child, records, state_dir: Session.default_state_dir)
169
+ fresh = child&.verify_fresh
170
+ return 0 unless fresh&.of && child.parent_id && Session.exist?(child.parent_id, state_dir: state_dir)
171
+
172
+ dir = File.join(Session.session_dir(child.parent_id, state_dir: state_dir), INBOX)
173
+ FileUtils.mkdir_p(dir)
174
+ Array(records).count do |record|
175
+ check = field(record, :check_turn_id).to_s
176
+ next false unless Session.valid_id?(check)
177
+
178
+ out = record.to_h { |key, value| [key.to_s, value.is_a?(Hash) ? value.transform_keys(&:to_s) : value] }
179
+ out.merge!("fresh" => true, "of" => fresh.of, "child_id" => child.id)
180
+ AtomicFile.write(File.join(dir, "#{check}.json"), JSON.generate(out))
181
+ true
182
+ end
183
+ rescue StandardError => e
184
+ Log.warn(:worker, "fresh_forward_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
185
+ 0
186
+ end
187
+
188
+ # The verdicts waiting in +session_dir+'s INBOX: [[record, file]],
189
+ # oldest first.
190
+ def pending(session_dir)
191
+ Dir.glob(File.join(session_dir, INBOX, "*.json")).sort_by { |path| [File.mtime(path), path] }.filter_map do |path|
192
+ record = JSON.parse(File.read(path))
193
+ record.is_a?(Hash) && record["fresh"] == true ? [record, path] : nil
194
+ rescue JSON::ParserError, SystemCallError
195
+ nil
196
+ end
197
+ end
198
+
199
+ # The parent's side, between turns (its worker): the waiting verdicts
200
+ # appended to +session+'s verdicts (one already there is not added
201
+ # twice). The caller saves the session, then deletes the files.
202
+ # @return [Array(Array<Hash>, Array<String>)] the records added and the
203
+ # files taken
204
+ def take(session, session_dir)
205
+ waiting = pending(session_dir)
206
+ return [[], []] if waiting.empty?
207
+
208
+ known = session.verdicts.map { |record| field(record, :check_turn_id) }
209
+ added = waiting.map(&:first).reject { |record| known.include?(record["check_turn_id"]) }
210
+ session.verdicts = session.verdicts + added unless added.empty?
211
+ [added, waiting.map(&:last)]
212
+ end
213
+
214
+ # +session+'s verdicts as a page shows them: its own, then the fresh
215
+ # ones still waiting in its INBOX (no worker took them yet).
216
+ def verdicts_of(session, session_dir)
217
+ own = Array(session.verdicts)
218
+ known = own.map { |record| field(record, :check_turn_id) }
219
+ own + pending(session_dir).map(&:first).reject { |record| known.include?(record["check_turn_id"]) }
220
+ rescue StandardError
221
+ Array(session.verdicts)
222
+ end
223
+
224
+ # The LEDGER's rows: [{child_id:, of:, author:}].
225
+ def ledger(session_dir)
226
+ path = File.join(session_dir, LEDGER)
227
+ return [] unless File.exist?(path)
228
+
229
+ File.readlines(path, chomp: true).filter_map do |line|
230
+ row = JSON.parse(line)
231
+ next unless row.is_a?(Hash) && Session.valid_id?(row["child_id"].to_s)
232
+
233
+ { child_id: row["child_id"], of: row["of"].to_s, author: row["author"] }
234
+ rescue JSON::ParserError
235
+ nil
236
+ end
237
+ rescue SystemCallError
238
+ []
239
+ end
240
+
241
+ def append_ledger(session_dir, row)
242
+ FileUtils.mkdir_p(session_dir)
243
+ File.open(File.join(session_dir, LEDGER), File::WRONLY | File::APPEND | File::CREAT, 0o600) do |io|
244
+ io.write("#{JSON.generate(row)}\n")
245
+ end
246
+ end
247
+ private_class_method :append_ledger
248
+
249
+ # One start at a time per parent: the running check and the spawn.
250
+ def locked(session_dir)
251
+ FileUtils.mkdir_p(session_dir)
252
+ File.open(File.join(session_dir, LOCK), File::RDWR | File::CREAT, 0o600) do |io|
253
+ io.flock(File::LOCK_EX)
254
+ yield
255
+ end
256
+ end
257
+ private_class_method :locked
258
+
259
+ # A child runs while its worker runs a turn, or while it is marked
260
+ # running and its worker is on its way (Tools::Delegate.running?).
261
+ def child_running?(child_id, state_dir)
262
+ child = Session.summary_from_file(Session.session_file(child_id, state_dir: state_dir)) or return false
263
+ return false unless child.status == Session::STATUS_RUNNING
264
+ return true if SessionManager.session_owner(child_id, state_dir: state_dir)&.worker?
265
+
266
+ Time.now - Time.iso8601(child.updated_at.to_s) < STARTING_GRACE_SECONDS
267
+ rescue ArgumentError
268
+ false
269
+ end
270
+ private_class_method :child_running?
271
+
272
+ def field(record, key) = record[key] || record[key.to_s]
273
+ private_class_method :field
274
+ end
275
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "steer"
4
+ require_relative "turn_origin"
5
+ require_relative "verify_anchor"
6
+ require_relative "participant_mode"
7
+
8
+ module Samagotchi
9
+ # A Verify fresh child's own field (Session#verify_fresh; VerifyFresh
10
+ # starts one): who asked for it (+author+, a participant's "p:<id>", nil
11
+ # for the owner), the rules a participant's turns run under in it
12
+ # (+mode+, "review" or "pair": their grant's mode in the parent when it
13
+ # started, ParticipantMode.live_for), the parent's verdict it checks again
14
+ # (+of+, that verdict's check_turn_id) and the claim (+anchor+, a
15
+ # VerifyAnchor). Every turn of a participant's child is theirs
16
+ # (#origin_for): their author, their boundary, their cap. A field that is
17
+ # there but doesn't read as one is INVALID, whose author is no
18
+ # participant's: its turns are refused, never run as the owner's.
19
+ VerifyFreshOrigin = Data.define(:author, :mode, :of, :anchor) do
20
+ # @param value [VerifyFreshOrigin, Hash, nil] the session file's form too
21
+ # @return [VerifyFreshOrigin, nil] nil for nil; INVALID for anything
22
+ # else that doesn't read as one
23
+ def self.of(value)
24
+ return value if value.is_a?(self)
25
+ return nil if value.nil?
26
+ return self::INVALID unless value.is_a?(Hash)
27
+
28
+ field = ->(key) { value.key?(key) ? value[key] : value[key.to_s] }
29
+ author = field.call(:author)
30
+ mode = field.call(:mode)
31
+ anchor = VerifyAnchor.from(field.call(:anchor))
32
+ of = field.call(:of)
33
+ return self::INVALID unless anchor && of.is_a?(String) && !of.empty?
34
+ return new(author: nil, mode: nil, of: of, anchor: anchor) if author.nil?
35
+ return self::INVALID unless author.is_a?(String) && Steer.participant_author?(author) && self::MODES.include?(mode)
36
+
37
+ new(author: author, mode: mode, of: of, anchor: anchor)
38
+ end
39
+
40
+ # Whether a participant asked for it (the owner's child runs as any
41
+ # delegate child).
42
+ def participant? = Steer.participant_author?(author)
43
+
44
+ # The boundary a participant's turn runs under here: :pair only when
45
+ # saved so, else :review (fail closed).
46
+ def turn_mode = mode == ParticipantMode::PAIR.to_s ? ParticipantMode::PAIR : ParticipantMode::REVIEW
47
+
48
+ # +origin+ with this child's requester as its author: every turn of a
49
+ # participant's child is theirs, whoever typed it; the owner's child
50
+ # keeps the origin as it is.
51
+ # @param origin [TurnOrigin]
52
+ # @return [TurnOrigin]
53
+ def origin_for(origin)
54
+ origin = TurnOrigin.of(origin)
55
+ participant? ? origin.with(author: author) : origin
56
+ end
57
+
58
+ # The session file's form.
59
+ def to_file = { "author" => author, "mode" => mode, "of" => of, "anchor" => anchor&.to_file }.compact
60
+ end
61
+
62
+ # A verify_fresh field that doesn't read: a participant author nobody
63
+ # has, so the worker refuses its turns ("revoked"), under the review
64
+ # boundary.
65
+ # The modes a participant's child may be saved with (a constant of
66
+ # VerifyFreshOrigin: one inside the Data.define block would be Samagotchi's).
67
+ VerifyFreshOrigin::MODES = [ParticipantMode::REVIEW.to_s, ParticipantMode::PAIR.to_s].freeze
68
+
69
+ VerifyFreshOrigin::INVALID = VerifyFreshOrigin.new(author: "#{Steer::PARTICIPANT_PREFIX}invalid", mode: "review",
70
+ of: nil, anchor: nil)
71
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.125.0"
4
+ VERSION = "0.126.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end