samagotchi 0.124.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -1
@@ -13,7 +13,8 @@ module Samagotchi
13
13
  # the summaries the page's trackAttention keeps (seeded by the
14
14
  # subscribe's snapshot, so what was open before never pushes), turns
15
15
  # each new attention (Attention.for: a question, an approval, a
16
- # step-limit continue, a card with actions, a failed or done turn)
16
+ # step-limit continue, a card with actions, a failed or done turn, a
17
+ # recorded Verify verdict)
17
18
  # into a show, and hands it to its own sender thread through a Queue;
18
19
  # one answered anywhere (Attention.closed_keys) only cancels its
19
20
  # show's hold.
@@ -43,7 +44,7 @@ module Samagotchi
43
44
  HOLD = 3.0
44
45
  # Waiting kinds: kept 12 h for an offline phone, delivered at once.
45
46
  SHOW_TTL = 12 * 3600
46
- # A failed or done turn: stale soon, no need to wake the phone.
47
+ # A failed, done or verdict turn: stale soon, no need to wake the phone.
47
48
  EVENT_TTL = 600
48
49
  # A newly available chi is useful for a day while the phone is away.
49
50
  UPDATE_TTL = 24 * 3600
@@ -280,7 +281,7 @@ module Samagotchi
280
281
  retries.each { |again| send_retry(again, open) if still_due?(again.show, open) }
281
282
  end
282
283
 
283
- # A waiting show while its key is open; a failed or done turn's as
284
+ # A waiting show while its key is open; a failed, done or verdict turn's as
284
285
  # is (nothing to answer).
285
286
  def still_due?(show, open) = !Attention.waiting?(show.attention.reason) || open.include?(show.attention.key)
286
287
 
@@ -196,9 +196,11 @@ module Samagotchi
196
196
 
197
197
  # no_interrupt: an attached TUI's --no-interrupt (chi --remote --attach).
198
198
  # author: the principal's (nil for the owner), never the body's.
199
+ verify = VerifyAnchor.from(body["verify"])
199
200
  result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
200
201
  delivery: delivery, no_interrupt: body["no_interrupt"] == true,
201
202
  author: principal(req).author,
203
+ verify: verify,
202
204
  state_dir: @state_dir, manager: @manager,
203
205
  bridge: -> { live_bridge_client(id) })
204
206
  case result.status
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../session"
4
+ require_relative "../steer"
5
+ require_relative "../participants"
6
+ require_relative "../verify_fresh"
4
7
  require_relative "../recap_store"
5
8
  require_relative "../archive_store"
6
9
  require_relative "../session_metrics"
@@ -108,10 +111,31 @@ module Samagotchi
108
111
  # chi REPL's (only a worker shares it).
109
112
  pending_question: session.waiting_question(live: !!owner&.worker?),
110
113
  pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
111
- last_turn: session.last_turn&.to_file
114
+ last_turn: session.last_turn&.to_file,
115
+ # Its Verify verdicts, the fresh ones its worker hasn't taken yet
116
+ # too (VerifyFresh.verdicts_of), and the fresh checks running now
117
+ # ({of, child_id}: the card's "verifying fresh" and its link).
118
+ verdicts: VerifyFresh.verdicts_of(session, session_dir),
119
+ verify_fresh: VerifyFresh.running(session.id, state_dir: File.dirname(session_dir))
120
+ .map { |row| { of: row[:of], child_id: row[:child_id] } },
121
+ last_verdict: last_verdict(session, session_dir)
112
122
  }
113
123
  end
114
124
 
125
+ def last_verdict(session, session_dir)
126
+ raw = Array(session.verdicts).last
127
+ return unless raw.is_a?(Hash)
128
+
129
+ verdict = raw.to_h { |key, value| [key.to_sym, value] }
130
+ author = verdict[:by]
131
+ return verdict unless Steer.participant_author?(author)
132
+
133
+ participant = Participants.read(session.id, state_dir: File.dirname(session_dir))
134
+ .find { |entry| entry.id == Steer.participant_id(author) }
135
+ participant ? verdict.merge(by_name: participant.name) : verdict
136
+ end
137
+ private_class_method :last_verdict
138
+
115
139
  # The budget resolver SessionMetrics.saved_summary takes: a callable,
116
140
  # so the session's llm_context budget (config + the registry's index,
117
141
  # no network) is resolved only for a session that has a saved context
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../delivery"
4
+ require_relative "../participant_mode"
5
+ require_relative "../participant_messages"
6
+ require_relative "../participants"
7
+ require_relative "../session"
8
+ require_relative "../steer"
9
+ require_relative "../verify_fresh"
10
+
11
+ module Samagotchi
12
+ module Web
13
+ # POST /api/sessions/:id/verdicts/:check_turn_id/fresh, mixed into App:
14
+ # Verify fresh (VerifyFresh) for the owner and for a participant. A
15
+ # participant's is refused as their turn would be (their cap, counted
16
+ # with their fresh children's spend; a worker that predates
17
+ # participants' turns) and audited like one (participants.log,
18
+ # verify_fresh); their child runs under the boundary their grant has
19
+ # here now (ParticipantMode.live_for), saved on the child. See
20
+ # docs/internals/participants.md (Verify fresh).
21
+ module VerifyFreshRoutes
22
+ private
23
+
24
+ # Whose child it is (D6, the security review's MED-2): a verdict a
25
+ # participant's turn recorded is re-checked AS that participant
26
+ # (their author, their grant's mode, their cap), whoever clicks: its
27
+ # quote is their text, never run with the owner's power. Their link
28
+ # must still be live (else 409 author_link_ended). An owner's verdict
29
+ # is re-checked as whoever clicks: the owner's child for the owner, the
30
+ # clicking participant's for a participant. A participant who clicks
31
+ # passes their own turn's refusals first.
32
+ # @return 202 {of, running: true} (+child_id+ for the owner only)
33
+ def handle_verify_fresh(req, id, of)
34
+ clicker = principal(req).owner? ? nil : principal(req).grant
35
+ session = @session_class.load(id, state_dir: default_state_dir)
36
+ if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session))
37
+ return verify_fresh_refused(clicker, of, *refused)
38
+ end
39
+
40
+ by = Steer.sender_author(field_of(VerifyFresh.original(session.verdicts, of), :by))
41
+ runner = by ? verdict_author_grant(id, by) : clicker
42
+ return author_link_ended(clicker, id, of, by) if by && runner.nil?
43
+ if runner && runner != clicker && (refused = participant_cap_refusal(runner) || fresh_worker_refusal(session))
44
+ return verify_fresh_refused(clicker, of, *refused, runner: runner)
45
+ end
46
+
47
+ mode = runner && ParticipantMode.live_for(session, runner, state_dir: default_state_dir)
48
+ child = VerifyFresh.start(id, of: of, author: runner&.author, mode: mode, state_dir: default_state_dir,
49
+ spawn: fresh_spawn)
50
+ audit_verify_fresh(clicker, of, status: "accepted", child: child.id, mode: mode&.to_s, runner: runner)
51
+ json_response(202, clicker ? { of: of, running: true } : { of: of, running: true, child_id: child.id })
52
+ rescue VerifyFresh::Refused => e
53
+ verify_fresh_refused(clicker, of, e.status, e.code, e.message, runner: runner)
54
+ end
55
+
56
+ # The live grant of the participant +author+ ("p:<id>") in session
57
+ # +id+, nil when it is gone, revoked or expired.
58
+ def verdict_author_grant(id, author)
59
+ grant = @grants.find(Steer.participant_id(author))
60
+ grant if grant && grant.session_id == id && grant.active?(@grants.now)
61
+ end
62
+
63
+ # 409 author_link_ended: the verdict's participant can't be charged or
64
+ # bounded any more, so no child runs for it.
65
+ def author_link_ended(clicker, id, of, author)
66
+ pid = Steer.participant_id(author)
67
+ name = Participants.read(id, state_dir: default_state_dir).find { |entry| entry.id == pid }&.name
68
+ name = pid if name.to_s.strip.empty?
69
+ detail = format(AUTHOR_LINK_ENDED, name: name)
70
+ audit_verify_fresh(clicker, of, status: "refused", reason: "author_link_ended", participant_id: pid) if clicker
71
+ error_response(409, "author_link_ended", detail)
72
+ end
73
+
74
+ AUTHOR_LINK_ENDED = "%<name>s's link has ended: their verdict can't be checked again as theirs " \
75
+ "(re-invite them, or verify the claim yourself)"
76
+
77
+ def field_of(record, key) = record && (record[key] || record[key.to_s])
78
+
79
+ # A live worker of the session that predates participants' turns runs
80
+ # no participant's turn (Delivery::REVIEW_UNAVAILABLE): no fresh
81
+ # child for one either. nil to go on.
82
+ def fresh_worker_refusal(session)
83
+ features = ParticipantMode.live_features(session, default_state_dir)
84
+ return nil if features.nil? || features.include?(Delivery::REVIEW_FEATURE)
85
+
86
+ [409, Delivery::REVIEW_UNAVAILABLE["error"], Delivery::REVIEW_UNAVAILABLE["detail"]]
87
+ end
88
+
89
+ def verify_fresh_refused(clicker, of, status, code, detail, runner: nil)
90
+ audit_verify_fresh(clicker, of, status: "refused", reason: code, runner: runner)
91
+ error_response(status, code, detail)
92
+ end
93
+
94
+ # Audited in the participants' trail when a participant clicked or the
95
+ # child runs as one: +clicker+ the participant who asked (nil: the
96
+ # owner), +runner+ whose child it is.
97
+ def audit_verify_fresh(clicker, of, status:, runner: nil, participant_id: nil, **fields)
98
+ subject = runner || clicker
99
+ return if subject.nil? && participant_id.nil?
100
+
101
+ Participants.log(subject&.session_id || clicker.session_id, :verify_fresh, state_dir: default_state_dir,
102
+ participant: subject&.id || participant_id,
103
+ asked_by: clicker ? clicker.id : "owner",
104
+ of: of.to_s[0, 64], status: status, **fields.compact)
105
+ end
106
+
107
+ def fresh_spawn = ->(**options) { @manager.spawn_session(**options) }
108
+ end
109
+ end
110
+ end
@@ -21,6 +21,7 @@ require_relative "worker_signals"
21
21
  require_relative "worker_task_wakes"
22
22
  require_relative "session_manager"
23
23
  require_relative "archive_store"
24
+ require_relative "reminder_carry"
24
25
  require_relative "log"
25
26
  require_relative "turn_flow"
26
27
  require_relative "turn_checkpoint"
@@ -32,6 +33,7 @@ require_relative "session_setting"
32
33
  require_relative "queued_command"
33
34
  require_relative "model_profile"
34
35
  require_relative "child_reports"
36
+ require_relative "verify_fresh"
35
37
  require_relative "child_move"
36
38
  require_relative "context_absorber"
37
39
  require_relative "context_poller"
@@ -248,7 +250,11 @@ module Samagotchi
248
250
  drop_dead_question
249
251
  # The running turn, on disk at its iteration boundaries.
250
252
  @checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
253
+ # A restart's hand-off, checked before the Engine's build (its window
254
+ # runs from the leave) and restored into it after.
255
+ carried = take_carried_reminders
251
256
  @engine = build_engine
257
+ restore_carried_reminders(carried)
252
258
  @config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :worker) do |message|
253
259
  @engine.announce(type: :hook_notice, hook: "config", text: message, level: :warn, between_turns: true)
254
260
  end
@@ -418,6 +424,7 @@ module Samagotchi
418
424
  # A change that asks to wake may start a turn here (C4), but not
419
425
  # ahead of an exit or restart a client asked for: it is a note then.
420
426
  @inbound.absorb_notes
427
+ absorb_fresh_verdicts
421
428
  return if absorb_context(wake: nothing_queued? && !@exit_requested)
422
429
 
423
430
  if (prompt = @inbound.take_initial_prompt)
@@ -446,7 +453,9 @@ module Samagotchi
446
453
  return if run_task_wake
447
454
  return if run_due_reminders
448
455
  return left(:idle_exit) if @idle_exit.due? && leave_idle
449
- return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
456
+
457
+ leaving = @exit_requested && leave_on_request
458
+ return left(leaving) if leaving
450
459
 
451
460
  watch_participants
452
461
  @waker.wait(idle_wait)
@@ -652,9 +661,13 @@ module Samagotchi
652
661
  # The first prompt (spawn_session's): a delegate child's task, which is
653
662
  # its parent's although no client sent it. Its turn carries the id
654
663
  # spawn_session gave it, so a wait for it (--new --wait) names it.
664
+ # A Verify fresh child's is a Verify turn on its claim (its verdict is
665
+ # recorded and forwarded to the parent).
655
666
  def run_initial_prompt(prompt)
656
667
  @initial_turn = true
657
- run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: TurnOrigin.new(enqueued_id: @session.first_turn_id)))
668
+ fresh = @session.verify_fresh
669
+ run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: TurnOrigin.new(enqueued_id: @session.first_turn_id),
670
+ verify: fresh&.anchor))
658
671
  ensure
659
672
  @initial_turn = false
660
673
  end
@@ -665,7 +678,7 @@ module Samagotchi
665
678
 
666
679
  # @param prompt [WorkerInbound::Prompt]
667
680
  def run_prompt(prompt)
668
- origin = prompt.origin
681
+ origin = fresh_origin(prompt.origin)
669
682
  # A participant's turn queued before their link ended or their cap was
670
683
  # reached doesn't start (chi web refuses new ones; this is the
671
684
  # worker's check).
@@ -681,7 +694,7 @@ module Samagotchi
681
694
  @continue_offer.drop(origin)
682
695
  @turn_flow.before_prompt_turn
683
696
  run_engine_turn(prompt.text, origin: origin, max_iterations: max_iterations(prompt.no_interrupt),
684
- images: prompt.images) do |result, error|
697
+ images: prompt.images, **(prompt.verify ? { verify: prompt.verify } : {})) do |result, error|
685
698
  if error
686
699
  # The Engine announced :turn_failed (with the error's one line).
687
700
  restore_failed_turn([[prompt.text, origin, prompt.images], *@merged_this_turn], error: error)
@@ -692,6 +705,13 @@ module Samagotchi
692
705
  participant_refusal(origin.author)
693
706
  end
694
707
 
708
+ # +origin+ as this session's turns run it: a participant's Verify fresh
709
+ # child's are all theirs (VerifyFreshOrigin#origin_for).
710
+ def fresh_origin(origin)
711
+ fresh = @session&.verify_fresh
712
+ fresh ? fresh.origin_for(origin) : TurnOrigin.of(origin)
713
+ end
714
+
695
715
  # Why the participant +author+ ("p:<id>") may run no more turns, nil
696
716
  # while they may (and for the owner): "revoked" when participants.json
697
717
  # has no active entry for them (revoked, expired, never invited),
@@ -701,11 +721,11 @@ module Samagotchi
701
721
  def participant_refusal(author)
702
722
  author = Steer.sender_author(author) or return nil
703
723
 
704
- entry = Participants.read(@session_id, state_dir: @state_dir).find { |e| Steer.author_for(e.id) == author }
724
+ entry = participant_entry(author)
705
725
  return "revoked" unless entry&.active?(Time.now)
706
726
 
707
727
  cap = participant_cap(entry)
708
- return nil if @engine.metrics.spend(author) < cap
728
+ return nil if participant_spend(author) < cap
709
729
 
710
730
  cap_notice(author, entry, cap)
711
731
  "cap_reached"
@@ -718,13 +738,30 @@ module Samagotchi
718
738
  def participant_turn_stop(author)
719
739
  reason = participant_refusal(author) or return nil
720
740
 
721
- entry = Participants.read(@session_id, state_dir: @state_dir).find { |e| Steer.author_for(e.id) == author }
741
+ entry = participant_entry(author)
722
742
  name = participant_label(author, entry&.name)
723
743
  return format(REVOKED_STOP, name: name) if reason == "revoked"
724
744
 
725
745
  format(CAP_STOP, name: name, cap: participant_cap(entry))
726
746
  end
727
747
 
748
+ # +author+'s participants.json entry: this session's, a Verify fresh
749
+ # child's parent's (Session#participants_source_id); nil for none.
750
+ def participant_entry(author)
751
+ Participants.read(participants_id, state_dir: @state_dir).find { |e| Steer.author_for(e.id) == author }
752
+ end
753
+
754
+ # The session whose participants.json and audit trail this session's
755
+ # participants are in.
756
+ def participants_id = @session&.participants_source_id || @session_id
757
+
758
+ # What +author+ spent against their cap: this session's metrics (live)
759
+ # and, through Verify fresh, the parent's or the children's saved ones
760
+ # (VerifyFresh.related_spend).
761
+ def participant_spend(author)
762
+ @engine.metrics.spend(author) + VerifyFresh.related_spend(@session, author, state_dir: @state_dir)
763
+ end
764
+
728
765
  # A participant's cap in dollars: their own, else the config's.
729
766
  def participant_cap(entry) = entry&.cap.is_a?(Numeric) ? entry.cap.to_f : Web::Grants.default_cap
730
767
 
@@ -746,7 +783,7 @@ module Samagotchi
746
783
  id = Steer.participant_id(author)
747
784
  text = ParticipantMode.for_turn(@session, entry) == ParticipantMode::PAIR ? PAIR_CAP_NOTICE : CAP_NOTICE
748
785
  @engine.announce(type: :hook_notice, hook: Engine::PARTICIPANTS_HOOK, level: :warn, between_turns: true,
749
- text: format(text, name: participant_label(author, entry.name), cap: cap, session: @session_id[0, 8], id: id))
786
+ text: format(text, name: participant_label(author, entry.name), cap: cap, session: participants_id[0, 8], id: id))
750
787
  end
751
788
 
752
789
  CAP_NOTICE = "%<name>s reached their $%<cap>.2f review cap: their comments are refused until you raise it " \
@@ -767,7 +804,7 @@ module Samagotchi
767
804
  # @param origin [TurnOrigin] a participant's
768
805
  def refuse_participant_turn(origin, reason)
769
806
  TurnCheckpoint.record_refused(@session_dir, origin.enqueued_id, reason: reason, author: origin.participant_author)
770
- Participants.log(@session_id, :turn, state_dir: @state_dir, participant: origin.participant_id,
807
+ Participants.log(participants_id, :turn, state_dir: @state_dir, participant: origin.participant_id,
771
808
  enqueued_id: origin.enqueued_id, status: "refused", reason: reason)
772
809
  @engine.announce(type: :turn_dequeued, enqueued_id: origin.enqueued_id, client_id: origin.client_id)
773
810
  nil
@@ -1189,7 +1226,9 @@ module Samagotchi
1189
1226
  # Every kind of turn merges steering (a reminder turn too, which may be
1190
1227
  # a fresh worker's first), so the list is this turn's from the start.
1191
1228
  @merged_this_turn = []
1192
- origin = TurnOrigin.of(turn_args[:origin])
1229
+ origin = fresh_origin(turn_args[:origin])
1230
+ turn_args = turn_args.merge(origin: origin)
1231
+ verdicts_before = @session.verdicts.size
1193
1232
  @reporting_turn = reports_to_parent?(origin)
1194
1233
  # A participant's review turn merges no input (#pending_input_drain).
1195
1234
  @participant_turn = origin.participant?
@@ -1234,6 +1273,33 @@ module Samagotchi
1234
1273
  # turn that failed (kept with its failed note), read them.
1235
1274
  settle_child_reports(rolled_back: @turn_flow.restores != restores || merges_lost?(error))
1236
1275
  ring_parent_after_turn
1276
+ forward_fresh_verdicts(verdicts_before)
1277
+ end
1278
+
1279
+ # A Verify fresh child's verdicts this turn recorded go to its parent
1280
+ # (VerifyFresh.forward), after the save: the parent's card shows them.
1281
+ # A fresh child never rings (ChildRing.ring): nothing enters the
1282
+ # parent's conversation.
1283
+ def forward_fresh_verdicts(before)
1284
+ return unless @session.verify_fresh
1285
+
1286
+ added = @session.verdicts.drop(before)
1287
+ VerifyFresh.forward(@session, added, state_dir: @state_dir) unless added.empty?
1288
+ end
1289
+
1290
+ # The parent's side, between turns: the verdicts its Verify fresh
1291
+ # children forwarded join the session's (saved, then their files go)
1292
+ # and the UIs hear each (:verdict_recorded).
1293
+ def absorb_fresh_verdicts
1294
+ added, files = VerifyFresh.take(@session, @session_dir)
1295
+ return if files.empty?
1296
+
1297
+ return unless save_or_log(:fresh_verdicts) { save_session }
1298
+
1299
+ FileUtils.rm_f(files)
1300
+ added.each { |record| @engine.announce(type: :verdict_recorded, verdict: record) }
1301
+ rescue StandardError => e
1302
+ Log.warn(:worker, "fresh_verdicts_failed", error: e.class.name, msg: e.message)
1237
1303
  end
1238
1304
 
1239
1305
  # The delegate reports this turn took: a kept turn moves the cursors on
@@ -1514,6 +1580,11 @@ module Samagotchi
1514
1580
  def exit_request(client_id, delete: false, restart: false, stop: false, ask: true)
1515
1581
  # The Bridge serves before the idle-exit policy exists.
1516
1582
  return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
1583
+ # One exit is already taken: the first one decides how the worker
1584
+ # leaves (a later stop-all can't turn a restart, whose reminders go to
1585
+ # the next worker, into a plain exit). Held at the leave, it is
1586
+ # forgotten and the next request counts again.
1587
+ return nil if @exit_requested
1517
1588
 
1518
1589
  if restart
1519
1590
  hold = @idle_exit.hold_for_restart
@@ -1551,7 +1622,8 @@ module Samagotchi
1551
1622
  # intake and stop the idle jobs (the Bridge stops after the log). Other clients' streams no longer hold: the asker's
1552
1623
  # may not have closed yet, and a UI joining now finds the worker gone
1553
1624
  # (:stream_closed), the window #leave_idle has too.
1554
- # @return [Boolean] false when something came in since the request
1625
+ # @return [Symbol, false] how it leaves (:restart or :exit_requested,
1626
+ # decided under the lock), false when something came in since the request
1555
1627
  def leave_on_request
1556
1628
  stop_bridge_after do
1557
1629
  @engine.synchronize_events do
@@ -1566,16 +1638,57 @@ module Samagotchi
1566
1638
  Log.info(:worker, "exit_held", reason: hold)
1567
1639
  next false
1568
1640
  end
1641
+ next false if @exit_restart && !carry_reminders
1569
1642
 
1570
1643
  @bridge&.close_intake
1571
1644
  # Not joined: a recap tick's emit waits for the event log held here.
1572
1645
  @engine.stop_idle(join: false)
1573
1646
  Log.info(:worker, @exit_restart ? "restart_requested" : "exit_requested", by: @exit_requested_by || "a client")
1574
- true
1647
+ @exit_restart ? :restart : :exit_requested
1575
1648
  end
1576
1649
  end
1577
1650
  end
1578
1651
 
1652
+ # A restart hands the reminders to the next worker (ReminderCarry), while
1653
+ # the event log is held: no turn can register or fire one from here. A
1654
+ # write that fails keeps this worker up with them rather than losing them.
1655
+ # A session stopped meanwhile drops them (run_session_loop then starts no
1656
+ # successor): nothing is written.
1657
+ # @return [Boolean] false when the write failed
1658
+ def carry_reminders
1659
+ if stopped_on_disk?
1660
+ ReminderCarry.remove(@session_dir)
1661
+ else
1662
+ ReminderCarry.write(@session_dir, session_id: @session_id, store: @engine.reminder_store)
1663
+ end
1664
+ true
1665
+ rescue StandardError => e
1666
+ @exit_requested = nil
1667
+ @exit_restart = false
1668
+ Log.warn(:worker, "reminders_carry_failed", error: e.class.name, msg: e.message)
1669
+ false
1670
+ end
1671
+
1672
+ # The reminders a restart handed over (#carry_reminders), read and
1673
+ # deleted; a stopped session drops them.
1674
+ # @return [Array<Hash>, nil] ReminderStore#export's entries
1675
+ def take_carried_reminders
1676
+ ReminderCarry.take(@session_dir, session_id: @session_id, stopped: stopped_on_disk?)
1677
+ rescue StandardError => e
1678
+ Log.warn(:worker, "reminders_carry_take_failed", error: e.class.name, msg: e.message)
1679
+ nil
1680
+ end
1681
+
1682
+ # Into the new Engine's store, before the idle scheduler starts polling it.
1683
+ def restore_carried_reminders(entries)
1684
+ return unless entries
1685
+
1686
+ names = @engine.reminder_store.restore(entries)
1687
+ Log.info(:worker, "reminders_carried", count: names.size)
1688
+ rescue StandardError => e
1689
+ Log.warn(:worker, "reminders_carry_restore_failed", error: e.class.name, msg: e.message)
1690
+ end
1691
+
1579
1692
  # @return [Symbol] +result+, with #discard? decided and, for a session
1580
1693
  # kept, its recap written: the Bridge is closed and the idle jobs are
1581
1694
  # stopped by now, and nothing waits on this (the TUI has detached). A
@@ -11,7 +11,8 @@ module Samagotchi
11
11
  # * a client holds the Bridge's stream (an attached TUI, even an idle one,
12
12
  # or a web tab);
13
13
  # * a reminder is registered. Reminders live only in the Engine and repeat
14
- # until the agent cancels them, so an exit would drop them for good;
14
+ # until the agent cancels them, so an exit would drop them for good (a
15
+ # restart hands them to the next worker instead: ReminderCarry);
15
16
  # * a continue offer waits for an answer; it too lives only in memory;
16
17
  # * a background task the worker watches for a finish wake (TaskWatch)
17
18
  # still runs, or finished and waits for the next pass's wake: its end
@@ -167,18 +168,18 @@ module Samagotchi
167
168
  # approval waiting (a turn's), a /btw (or another anytime command)
168
169
  # running, a delegate's approval relay this worker still answers for,
169
170
  # background tasks this conversation started (their output and end are
170
- # this worker's to report). Clients' streams don't hold: a web tab
171
- # reconnects on the new worker's bridge_up, an attached terminal finds
172
- # the new worker's Bridge.
171
+ # this worker's to report). Reminders don't hold: the worker writes them
172
+ # for its successor as it leaves (Worker#leave_on_request, ReminderCarry).
173
+ # Clients' streams don't hold either: a web tab reconnects on the new
174
+ # worker's bridge_up, an attached terminal finds the new worker's Bridge.
173
175
  # @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
174
- # :question_pending, :reminders, :command_running, :relays_open or
176
+ # :question_pending, :command_running, :relays_open or
175
177
  # :background_tasks
176
178
  def hold_for_restart
177
179
  return :turn_running if @engine.turn_running?
178
180
  return :input_queued if @input_pending.call
179
181
  return :continue_offered if @awaiting_continue.call
180
182
  return :question_pending if @engine.pending_question
181
- return :reminders if @engine.reminder_store&.any?
182
183
  return :command_running if @engine.anytime_running?
183
184
  return :relays_open if @engine.relay_desk.active?
184
185
  return :background_tasks if @running_tasks.call.any?
@@ -40,9 +40,9 @@ module Samagotchi
40
40
  # turn; +images+: its image refs ({file:, name:}); +delivery+: the wire
41
41
  # value the sender asked for ("queue", or nil for the default
42
42
  # next_step); +origin+: a TurnOrigin (a Hash is read as one).
43
- Prompt = Data.define(:text, :origin, :no_interrupt, :images, :delivery) do
44
- def initialize(text:, origin: nil, no_interrupt: false, images: [], delivery: nil)
45
- super(text:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:)
43
+ Prompt = Data.define(:text, :origin, :no_interrupt, :images, :delivery, :verify) do
44
+ def initialize(text:, origin: nil, no_interrupt: false, images: [], delivery: nil, verify: nil)
45
+ super(text:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:, verify: VerifyAnchor.from(verify))
46
46
  end
47
47
  end
48
48
 
@@ -215,7 +215,7 @@ module Samagotchi
215
215
  return if input.images.empty? && queue_as_command(input.prompt, input.origin, File.basename(input_file))
216
216
 
217
217
  yield Prompt.new(text: input.prompt, origin: input.origin, no_interrupt: input.no_interrupt, images: input.images,
218
- delivery: input.delivery)
218
+ delivery: input.delivery, verify: input.verify)
219
219
  ensure
220
220
  FileUtils.rm_f(claimed_file)
221
221
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.124.0
4
+ version: 0.126.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -528,6 +528,7 @@ files:
528
528
  - lib/samagotchi/relay_desk.rb
529
529
  - lib/samagotchi/relay_verifier.rb
530
530
  - lib/samagotchi/relay_watcher.rb
531
+ - lib/samagotchi/reminder_carry.rb
531
532
  - lib/samagotchi/reminder_queue.rb
532
533
  - lib/samagotchi/reminder_store.rb
533
534
  - lib/samagotchi/remote_attach_command.rb
@@ -603,13 +604,20 @@ files:
603
604
  - lib/samagotchi/terminal_ui/attach_launcher.rb
604
605
  - lib/samagotchi/terminal_ui/attach_link.rb
605
606
  - lib/samagotchi/terminal_ui/attached_loop.rb
607
+ - lib/samagotchi/terminal_ui/attached_loop/composer.rb
608
+ - lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb
609
+ - lib/samagotchi/terminal_ui/attached_loop/questions.rb
610
+ - lib/samagotchi/terminal_ui/attached_loop/queueing.rb
611
+ - lib/samagotchi/terminal_ui/attached_loop/sending.rb
606
612
  - lib/samagotchi/terminal_ui/attached_view.rb
607
613
  - lib/samagotchi/terminal_ui/between_turns.rb
608
614
  - lib/samagotchi/terminal_ui/event_renderer.rb
615
+ - lib/samagotchi/terminal_ui/exit_lines.rb
609
616
  - lib/samagotchi/terminal_ui/formatting.rb
610
617
  - lib/samagotchi/terminal_ui/gap_clock.rb
611
618
  - lib/samagotchi/terminal_ui/image_input.rb
612
619
  - lib/samagotchi/terminal_ui/input_support.rb
620
+ - lib/samagotchi/terminal_ui/join_replay.rb
613
621
  - lib/samagotchi/terminal_ui/line_reader.rb
614
622
  - lib/samagotchi/terminal_ui/live_region.rb
615
623
  - lib/samagotchi/terminal_ui/local_link.rb
@@ -719,6 +727,10 @@ files:
719
727
  - lib/samagotchi/update_hint.rb
720
728
  - lib/samagotchi/used_memories.rb
721
729
  - lib/samagotchi/utf8_default.rb
730
+ - lib/samagotchi/verdict.rb
731
+ - lib/samagotchi/verify_anchor.rb
732
+ - lib/samagotchi/verify_fresh.rb
733
+ - lib/samagotchi/verify_fresh_origin.rb
722
734
  - lib/samagotchi/version.rb
723
735
  - lib/samagotchi/vision_context.rb
724
736
  - lib/samagotchi/vision_support.rb
@@ -757,6 +769,7 @@ files:
757
769
  - lib/samagotchi/web/public/chains.js
758
770
  - lib/samagotchi/web/public/chip_popover.js
759
771
  - lib/samagotchi/web/public/chunk_router.js
772
+ - lib/samagotchi/web/public/claim_link.js
760
773
  - lib/samagotchi/web/public/command_complete.js
761
774
  - lib/samagotchi/web/public/command_list.js
762
775
  - lib/samagotchi/web/public/command_view.js
@@ -871,6 +884,10 @@ files:
871
884
  - lib/samagotchi/web/public/vendor/directives/repeat.js
872
885
  - lib/samagotchi/web/public/vendor/directives/unsafe-html.js
873
886
  - lib/samagotchi/web/public/vendor/lit-html.js
887
+ - lib/samagotchi/web/public/verdict_card.js
888
+ - lib/samagotchi/web/public/verdict_fresh.js
889
+ - lib/samagotchi/web/public/verify_card.css
890
+ - lib/samagotchi/web/public/verify_preset.js
874
891
  - lib/samagotchi/web/public/working_set.js
875
892
  - lib/samagotchi/web/public/working_set_rail.js
876
893
  - lib/samagotchi/web/public/working_set_view.js
@@ -897,6 +914,7 @@ files:
897
914
  - lib/samagotchi/web/tunnel.rb
898
915
  - lib/samagotchi/web/tunnel/tailscale.rb
899
916
  - lib/samagotchi/web/update_check.rb
917
+ - lib/samagotchi/web/verify_fresh_routes.rb
900
918
  - lib/samagotchi/worker.rb
901
919
  - lib/samagotchi/worker_idle_exit.rb
902
920
  - lib/samagotchi/worker_inbound.rb