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
@@ -86,11 +86,16 @@ module Samagotchi
86
86
  state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
87
87
  delegate: session.delegate?, cwd: session.working_directory, branch: GitHead.branch(session.working_directory),
88
88
  last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
89
- last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
89
+ last_reply_at: reply && reply_time(reply), reported: !reply.nil? && reported?(session, cursor, reply),
90
90
  updated_at: session.updated_at, archived: !!row[:archived])
91
91
  end
92
92
  private_class_method :child
93
93
 
94
+ # A reply the parent was given; a Verify fresh child's always counts
95
+ # (its verdict goes to the parent's card, never a report).
96
+ def reported?(session, cursor, reply) = !session.verify_fresh.nil? || cursor.reply_file == reply
97
+ private_class_method :reported?
98
+
94
99
  def state_of(session, row, reply)
95
100
  return "waiting" if row[:waiting]
96
101
  return "running" if Tools::Delegate.running?(row)
@@ -15,6 +15,8 @@ require_relative "steer"
15
15
  require_relative "turn_note"
16
16
  require_relative "turn_endings"
17
17
  require_relative "turn_origin"
18
+ require_relative "verify_anchor"
19
+ require_relative "verdict"
18
20
  require_relative "model_profile"
19
21
  require_relative "effective_model"
20
22
  require_relative "session_setting"
@@ -901,10 +903,12 @@ module Samagotchi
901
903
 
902
904
  # Event types #announce accepts: facts about the session's input queue,
903
905
  # a failed turn's prompt handed back and the continue offer, which live
904
- # UIs need in the event log, emitted outside any turn's stream.
906
+ # UIs need in the event log, emitted outside any turn's stream; a
907
+ # Verify fresh child's verdict landing on its card (:verdict_recorded).
905
908
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
906
909
  command_queued command_ran context_added card hook_notice guardrail_warning
907
- plugin_init_started plugin_init_finished working_set_updated participants_changed].freeze
910
+ plugin_init_started plugin_init_finished working_set_updated participants_changed
911
+ verdict_recorded].freeze
908
912
 
909
913
  # Put a transport-level event into the ordered event log. Unlike turn
910
914
  # events it reaches only persistent observers (no turn sink, no memory
@@ -1589,7 +1593,10 @@ module Samagotchi
1589
1593
  seconds = turn.elapsed
1590
1594
  synchronize_events do
1591
1595
  kept, event = yield(seconds)
1596
+ stamp_verify_anchor(kept, turn)
1592
1597
  replace_session_messages(turn.session, kept) if kept
1598
+ verdict = record_verdict(turn, outcome)
1599
+ event[:verdict] = verdict if verdict
1593
1600
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
1594
1601
  turn.session.status = Session::STATUS_IDLE
1595
1602
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
@@ -1602,6 +1609,31 @@ module Samagotchi
1602
1609
  end
1603
1610
  private :end_turn
1604
1611
 
1612
+ def stamp_verify_anchor(messages, turn)
1613
+ return unless turn.verify && messages.is_a?(Array)
1614
+
1615
+ opening = messages.reverse.find do |message|
1616
+ (message[:role] || message["role"]).to_s == "user" &&
1617
+ (message[:turn_id] || message["turn_id"]).to_s == turn.id
1618
+ end
1619
+ opening[:verify] = turn.verify.to_h if opening
1620
+ end
1621
+ private :stamp_verify_anchor
1622
+
1623
+ def record_verdict(turn, outcome)
1624
+ return unless outcome == "completed" && turn.verify
1625
+
1626
+ answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
1627
+ parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
1628
+ return unless parsed
1629
+
1630
+ record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
1631
+ by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
1632
+ turn.session.verdicts = turn.session.verdicts + [record]
1633
+ record
1634
+ end
1635
+ private :record_verdict
1636
+
1605
1637
  def save_quietly(session)
1606
1638
  session.save(state_dir: session_state_dir)
1607
1639
  rescue StandardError => e
@@ -2011,7 +2043,7 @@ module Samagotchi
2011
2043
  # +wall+: the wall time it started (MessageTime.now, once): the
2012
2044
  # :turn_started event's at and its user message's (and reminders') stamp.
2013
2045
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
2014
- :settings, :id, :limit, :resumed, :resume_from, :wall) do
2046
+ :settings, :id, :limit, :resumed, :resume_from, :wall, :verify) do
2015
2047
  def resumed? = resumed ? true : false
2016
2048
 
2017
2049
  # The turn's boundary events carry the origin only when it names a
@@ -2083,9 +2115,9 @@ module Samagotchi
2083
2115
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
2084
2116
  # one-line summary:.
2085
2117
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
2086
- origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil)
2118
+ origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil)
2087
2119
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
2088
- continue: continue, id: id)
2120
+ continue: continue, id: id, verify: verify)
2089
2121
  if continue
2090
2122
  turn.resumed = resumed
2091
2123
  turn.resume_from = resume_from
@@ -2133,8 +2165,11 @@ module Samagotchi
2133
2165
  # The turn's state before anything can fail it: the session running,
2134
2166
  # the turn flag, its cancel controller and sink. The cross-thread state
2135
2167
  # is set here and cleared in #release_turn only.
2136
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil)
2168
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil)
2137
2169
  origin = TurnOrigin.of(origin)
2170
+ # A participant's Verify fresh child: every turn is theirs (their
2171
+ # author, boundary and cap), whoever started it.
2172
+ origin = session.verify_fresh.origin_for(origin) if session.respond_to?(:verify_fresh) && session.verify_fresh
2138
2173
  # Track the active session for recap and status snapshot.
2139
2174
  @session = session
2140
2175
  # status is turn state: running now, idle again before the turn's end
@@ -2157,7 +2192,7 @@ module Samagotchi
2157
2192
 
2158
2193
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
2159
2194
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
2160
- id: id || SecureRandom.uuid, wall: MessageTime.now)
2195
+ id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify))
2161
2196
  end
2162
2197
  private :begin_turn
2163
2198
 
@@ -2360,6 +2395,7 @@ module Samagotchi
2360
2395
  unless turn.continue
2361
2396
  user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
2362
2397
  **participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
2398
+ user_message[:verify] = turn.verify.to_h if turn.verify
2363
2399
  user_message[:images] = image_refs unless image_refs.empty?
2364
2400
  turn.messages << user_message
2365
2401
  session.last_prompt = turn.prompt
@@ -109,9 +109,18 @@ module Samagotchi
109
109
  @git = Guardrails::GitInfo.new
110
110
  author = turn_author
111
111
  @turn_entry = author && @participant.call(author)
112
- @turn_mode = author && ParticipantMode.for_turn(@session_lookup.call, @turn_entry)
112
+ @turn_mode = author && participant_turn_mode(@session_lookup.call, @turn_entry)
113
113
  end
114
114
 
115
+ # A participant's turn's mode: in a Verify fresh child the one saved
116
+ # on it at spawn (VerifyFreshOrigin#turn_mode: :review unless saved
117
+ # :pair), never looked up again; elsewhere ParticipantMode.for_turn.
118
+ def participant_turn_mode(session, entry)
119
+ fresh = session.respond_to?(:verify_fresh) && session.verify_fresh
120
+ fresh ? fresh.turn_mode : ParticipantMode.for_turn(session, entry)
121
+ end
122
+ private :participant_turn_mode
123
+
115
124
  # The running participant turn's mode (ParticipantMode.for_turn: :pair
116
125
  # only in a working session for a participant invited to act), decided
117
126
  # at its start; nil for the owner's turns. The boundary in #checks, the
@@ -447,6 +447,7 @@ module Samagotchi
447
447
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
448
448
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
449
449
  message[:turn_start] = true if entry[:turn_start] == true
450
+ message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
450
451
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
451
452
  message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
452
453
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
@@ -119,7 +119,7 @@ module Samagotchi
119
119
  # How long #stop waits for +stopped+ after the KILL by default.
120
120
  KILL_WAIT_SEC = 1.0
121
121
 
122
- # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
122
+ # TERMs (and CONTs) the group, waits up to +grace+ for +stopped+, then KILLs it and
123
123
  # waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
124
124
  # done, when kill returns).
125
125
  #
@@ -146,6 +146,15 @@ module Samagotchi
146
146
  extra = walk ? descendant_groups(pgid) : []
147
147
  signal(pgid, "TERM", **signal_options)
148
148
  extra.each { |group| signal(group, "TERM") }
149
+ # A stopped process (SIGSTOP, Ctrl-Z) holds the TERM pending, its
150
+ # handler unrun, until it is continued: CONT it after the TERM, so it
151
+ # resumes with the TERM already waiting (a CONT first would let it run
152
+ # unsignalled, to be stopped again) and stops within the grace, not by
153
+ # the KILL. A running process ignores the CONT. Always quiet: a group
154
+ # the TERM already ended may be zombies only by now, which macOS
155
+ # refuses a signal with EPERM.
156
+ signal(pgid, "CONT", **signal_options, quiet: QUIET)
157
+ extra.each { |group| signal(group, "CONT") }
149
158
  # +stopped+ is asked until it says true once (it may reap: a second
150
159
  # wait on a reaped pid raises ECHILD), then only the extra groups.
151
160
  done = false
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "log"
8
+
9
+ module Samagotchi
10
+ # A restart's hand-off of the session's reminders, which otherwise live only
11
+ # in the worker's Engine: the leaving worker writes `<id>/reminders.json`
12
+ # (Worker#leave_on_request, as it takes a restart) and its successor takes
13
+ # it as it starts (Worker#start): read and deleted before its Engine is
14
+ # built, restored into the Engine's store after. Only an asked
15
+ # restart writes one: a stop, an idle exit, a crash and a fallback restart
16
+ # (a TERM) drop the reminders as before, and a stop or an archive removes a
17
+ # file left behind (SessionManager.stop_session, .archive_session); a
18
+ # restart a stop landed on starts no successor (.run_session_loop). A file is honoured only by the same
19
+ # session, within WINDOW of its writing (a successor starts in seconds), so
20
+ # one no successor read can't bring reminders back a day later.
21
+ module ReminderCarry
22
+ FILE = "reminders.json"
23
+ # Seconds a hand-off stays good after it was written.
24
+ WINDOW = 120
25
+
26
+ module_function
27
+
28
+ def path(session_dir) = File.join(session_dir, FILE)
29
+
30
+ # Write +store+'s reminders for the successor; no reminders, no file.
31
+ # @return [Integer] how many were written
32
+ # @raise [SystemCallError] when the write fails (the caller stays up)
33
+ def write(session_dir, session_id:, store:, wall: Time.now)
34
+ entries = store.export(wall: wall)
35
+ if entries.empty?
36
+ remove(session_dir)
37
+ return 0
38
+ end
39
+
40
+ body = { "session_id" => session_id, "written_at" => wall.getutc.iso8601(3), "reminders" => entries }
41
+ AtomicFile.write(path(session_dir), "#{JSON.generate(body)}\n", perm: 0o600)
42
+ entries.size
43
+ end
44
+
45
+ # Read the hand-off and delete it. A file of another session, outside
46
+ # WINDOW or unreadable, or one found as a stopped session starts, is
47
+ # deleted unused (log reminders_carry_dropped; at warn unless stopped).
48
+ # @return [Array<Hash>, nil] the entries for ReminderStore#restore, nil
49
+ # when there is none to restore
50
+ def take(session_dir, session_id:, stopped: false, wall: Time.now)
51
+ file = path(session_dir)
52
+ return nil unless File.exist?(file)
53
+
54
+ data = read(file)
55
+ remove(session_dir)
56
+ why = drop_reason(data, session_id, stopped, wall)
57
+ return data["reminders"] unless why
58
+
59
+ if why == :stopped
60
+ Log.info(:worker, "reminders_carry_dropped", why: why)
61
+ else
62
+ Log.warn(:worker, "reminders_carry_dropped", why: why)
63
+ end
64
+ nil
65
+ end
66
+
67
+ # @return [true]
68
+ def remove(session_dir)
69
+ FileUtils.rm_f(path(session_dir))
70
+ true
71
+ end
72
+
73
+ def read(file)
74
+ data = JSON.parse(File.read(file))
75
+ data.is_a?(Hash) ? data : nil
76
+ rescue JSON::ParserError, SystemCallError
77
+ nil
78
+ end
79
+
80
+ def drop_reason(data, session_id, stopped, wall)
81
+ return :unreadable unless data
82
+ return :stopped if stopped
83
+ return :other_session unless data["session_id"] == session_id
84
+
85
+ written = Time.iso8601(data["written_at"].to_s)
86
+ :stale if (wall - written).abs > WINDOW
87
+ rescue ArgumentError
88
+ :stale
89
+ end
90
+ private_class_method :read, :drop_reason
91
+ end
92
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "monitor"
4
4
  require "securerandom"
5
+ require "time"
5
6
 
6
7
  module Samagotchi
7
8
  # Thread-safe store for periodic reminders registered by the agent.
@@ -122,10 +123,58 @@ module Samagotchi
122
123
  end
123
124
  end
124
125
 
126
+ # The reminders as a restart hands them to the next worker (ReminderCarry):
127
+ # each one's id, name, description and interval, and its next fire as
128
+ # wall-clock ISO time, since the monotonic clock doesn't cross processes.
129
+ # @return [Array<Hash{String => Object}>]
130
+ def export(wall: Time.now, mono: Process.clock_gettime(Process::CLOCK_MONOTONIC))
131
+ @mutex.synchronize do
132
+ @reminders.each_value.map do |r|
133
+ { "id" => r[:id], "name" => r[:name], "description" => r[:description],
134
+ "interval_minutes" => r[:interval_minutes],
135
+ "next_fire_at" => (wall + (r[:next_fire_at] - mono)).utc.iso8601(3) }
136
+ end
137
+ end
138
+ end
139
+
140
+ # Take back what #export wrote in another process. A reminder keeps its
141
+ # id and schedule: one overdue is due now (once, no catch-up), one further
142
+ # ahead than its interval (a clock that jumped) is due one interval from
143
+ # now. An entry #register would refuse, or with no time, is skipped.
144
+ # @param entries [Array<Hash>] #export's, parsed from JSON
145
+ # @return [Array<String>] the names restored
146
+ def restore(entries, wall: Time.now, mono: Process.clock_gettime(Process::CLOCK_MONOTONIC))
147
+ return [] unless entries.is_a?(Array)
148
+
149
+ restored = entries.filter_map { |entry| restored_entry(entry, wall, mono) }
150
+ @mutex.synchronize { restored.each { |r| @reminders[r[:name]] = r } }
151
+ restored.map { |r| r[:name] }
152
+ end
153
+
125
154
  # Clear all reminders.
126
155
  # Called when the session ends or the engine is reset.
127
156
  def clear_all
128
157
  @mutex.synchronize { @reminders.clear }
129
158
  end
159
+
160
+ private
161
+
162
+ def restored_entry(entry, wall, mono)
163
+ return nil unless entry.is_a?(Hash)
164
+
165
+ name = entry["name"].to_s.strip
166
+ description = entry["description"].to_s.strip
167
+ interval = Integer(entry["interval_minutes"], exception: false)
168
+ return nil if name.empty? || description.empty? || interval.nil? || !(1..1440).cover?(interval)
169
+
170
+ next_wall = Time.iso8601(entry["next_fire_at"].to_s)
171
+ period = interval * 60
172
+ next_fire = mono + (next_wall - wall).clamp(0, period)
173
+ id = entry["id"].is_a?(String) && !entry["id"].empty? ? entry["id"] : SecureRandom.hex(6)
174
+ { id: id, name: name, description: description, interval_minutes: interval,
175
+ last_fire_at: next_fire - period, next_fire_at: next_fire }
176
+ rescue ArgumentError
177
+ nil
178
+ end
130
179
  end
131
180
  end
@@ -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
@@ -36,6 +36,7 @@ require_relative "model_profile"
36
36
  require_relative "installed_gem"
37
37
  require_relative "installed_versions"
38
38
  require_relative "recap_store"
39
+ require_relative "reminder_carry"
39
40
  require_relative "archive_store"
40
41
  require_relative "continue_offer"
41
42
  require_relative "image_store"
@@ -163,6 +164,8 @@ module Samagotchi
163
164
  # turn sees it
164
165
  # @param review [SessionReview, nil] a review session's (ReviewStart; a
165
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)
166
169
  # @param before_spawn [#call, nil] (session) runs once the session and
167
170
  # its note are saved, before its worker spawns
168
171
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
@@ -170,7 +173,7 @@ module Samagotchi
170
173
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
171
174
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
172
175
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
173
- note_source: "chi", before_spawn: nil, review: nil)
176
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil)
174
177
  sd = state_dir || Session.default_state_dir
175
178
  # The resolved ref is stored (a resumed session keeps its model when an
176
179
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -197,6 +200,8 @@ module Samagotchi
197
200
  )
198
201
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
199
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
200
205
  session.model_warning = model_warning
201
206
  # With no prompt there is no first turn to run (an attaching UI sends
202
207
  # the prompts), so the session starts idle.
@@ -402,6 +407,9 @@ module Samagotchi
402
407
  stopped = tree.select { |sid| owners[sid] }
403
408
  stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
404
409
  archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
410
+ # A restart's reminders hand-off no successor took (the stop above
411
+ # removes a live one's): archived sessions keep no reminders.
412
+ archived.each { |sid| ReminderCarry.remove(Session.session_dir(sid, state_dir: sd)) }
405
413
  # D8: an archive ends the review (unarchiving doesn't restore it).
406
414
  Participants.end_sessions(archived, by: "archive", state_dir: sd)
407
415
  { id: id, archived: archived, stopped: stopped, discarded: discarded }
@@ -663,7 +671,8 @@ module Samagotchi
663
671
  input_queued: "a prompt is queued",
664
672
  continue_offered: "the step-limit question waits for an answer",
665
673
  question_pending: "a question or approval waits for an answer",
666
- reminders: "it has reminders, which a restart would drop",
674
+ # Only a worker from before ReminderCarry still holds a restart for its reminders.
675
+ reminders: "it has reminders, which its worker's older chi can't carry over a restart",
667
676
  command_running: "a /btw (or another command) is still running",
668
677
  relays_open: "a delegate's approval relayed here is open or just answered",
669
678
  background_tasks: "background tasks it started are still running"
@@ -822,7 +831,7 @@ module Samagotchi
822
831
  end
823
832
  private_class_method :running_created_task?
824
833
 
825
- # Stop a session by sending TERM to its process.
834
+ # Stop a session by sending TERM (then CONT, for a stopped one) to its process.
826
835
  # With +wait+ (seconds), also wait for the owner to let go of the session,
827
836
  # so a resume right after spawns a fresh worker instead of finding the
828
837
  # dying one.
@@ -838,15 +847,29 @@ module Samagotchi
838
847
  cancel_running_turn(session_id, state_dir: sd)
839
848
  # Mark first: a worker that has not taken the lock yet sees it and exits.
840
849
  Session.mark_stopped(session_id, state_dir: sd)
850
+ # A stop drops the reminders: a restart's hand-off no successor took
851
+ # yet must not bring them back on the next resume.
852
+ ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
841
853
  pid = owner&.pid.to_i
842
854
  begin
843
855
  Process.kill("TERM", pid) if pid && pid > 0
844
856
  rescue Errno::ESRCH
845
857
  nil # already exited
846
858
  end
859
+ continue_stopped(pid)
847
860
  wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
848
861
  end
849
862
 
863
+ # A stopped worker (SIGSTOP, Ctrl-Z) runs its TERM handler only once
864
+ # continued: CONT it after the TERM (as ProcessGroup.stop does), so it
865
+ # leaves now rather than outliving the stop's wait. Best effort: one the
866
+ # TERM ended may be gone already.
867
+ private_class_method def self.continue_stopped(pid)
868
+ Process.kill("CONT", pid) if pid && pid > 0
869
+ rescue Errno::ESRCH, Errno::EPERM
870
+ nil
871
+ end
872
+
850
873
  STOP_CANCEL_WAIT = 3.0
851
874
  # How long a stop from the CLI (chi sessions stop, delete, a continue's
852
875
  # orphan) waits for the worker to let go of the session, here or
@@ -1137,8 +1160,13 @@ module Samagotchi
1137
1160
  # A restart starts its successor here, on the newest chi installed
1138
1161
  # (worker_command), once this one's lock is free.
1139
1162
  # Delegate reports that rang while it left wake one too.
1140
- if result == :restart ||
1141
- (%i[idle_exit exit_requested].include?(result) &&
1163
+ if result == :restart && Session.stopped_marker?(session_id, state_dir: sd)
1164
+ # A stop landed as it left: no successor (resume_session would clear
1165
+ # the stop), and the reminders go with it.
1166
+ ReminderCarry.remove(session_dir)
1167
+ Log.info(:worker, "restart_dropped", why: :stopped)
1168
+ elsif result == :restart ||
1169
+ (%i[idle_exit exit_requested].include?(result) &&
1142
1170
  (!SessionInbox.find_new_input_files(session_dir).empty? || worker.wake_due?))
1143
1171
  resume_session(session_id, state_dir: sd)
1144
1172
  elsif worker.discard?
@@ -1286,10 +1314,10 @@ module Samagotchi
1286
1314
  # session's images/
1287
1315
  # @return [String, false] the input file's path, or false.
1288
1316
  def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
1289
- images: [], delivery: nil, author: nil)
1317
+ images: [], delivery: nil, author: nil, verify: nil)
1290
1318
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1291
1319
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1292
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
1320
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, verify: verify)
1293
1321
  end
1294
1322
 
1295
1323
  # How long a turn waits for the Bridge of a worker a resume just spawned.
@@ -1371,7 +1399,8 @@ module Samagotchi
1371
1399
  # @raise [OwnedByTUI] a chi REPL owns the session
1372
1400
  # @raise [ArgumentError] no such session
1373
1401
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1374
- 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)
1375
1404
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1376
1405
  bridge ||= lambda do
1377
1406
  link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
@@ -1393,6 +1422,7 @@ module Samagotchi
1393
1422
  options[:no_interrupt] = true if no_interrupt
1394
1423
  options[:author] = author if author
1395
1424
  options[:pending_limit] = pending_limit if author && pending_limit
1425
+ options[:verify] = verify if verify
1396
1426
  reply = client.post_turn(**options)
1397
1427
  ack = reply.json
1398
1428
  return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
@@ -1430,6 +1460,7 @@ module Samagotchi
1430
1460
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1431
1461
  input[:no_interrupt] = true if no_interrupt
1432
1462
  input[:author] = author if author
1463
+ input[:verify] = verify if verify
1433
1464
  path = participant_queue_lock(session_dir, author && pending_limit) do
1434
1465
  # No worker: the participant's waiting files are all their turns,
1435
1466
  # counted and written under one lock (the Bridge counts under its).