samagotchi 0.92.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/bin/chi +4 -4
  4. data/docs/architecture.md +4 -4
  5. data/docs/guardrails.md +4 -5
  6. data/docs/internals/engine.md +6 -0
  7. data/docs/sessions.md +1 -1
  8. data/docs/testing.md +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +12 -14
  12. data/lib/samagotchi/client_id.rb +18 -0
  13. data/lib/samagotchi/config/restart_watch.rb +112 -0
  14. data/lib/samagotchi/config.rb +18 -59
  15. data/lib/samagotchi/engine.rb +4 -14
  16. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  17. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  18. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  19. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  20. data/lib/samagotchi/hook_question.rb +21 -0
  21. data/lib/samagotchi/hook_routes.rb +6 -6
  22. data/lib/samagotchi/hooks/registry.rb +5 -5
  23. data/lib/samagotchi/host_registry.rb +1 -1
  24. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  25. data/lib/samagotchi/parent_answers_command.rb +6 -6
  26. data/lib/samagotchi/plugin/context.rb +6 -5
  27. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  28. data/lib/samagotchi/plugin/sessions.rb +2 -3
  29. data/lib/samagotchi/question_desk.rb +12 -9
  30. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  31. data/lib/samagotchi/reply_wait.rb +47 -190
  32. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  33. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  34. data/lib/samagotchi/send_command.rb +31 -100
  35. data/lib/samagotchi/session.rb +10 -9
  36. data/lib/samagotchi/session_inbox.rb +31 -8
  37. data/lib/samagotchi/session_manager.rb +48 -15
  38. data/lib/samagotchi/session_metrics.rb +22 -3
  39. data/lib/samagotchi/sessions_command.rb +4 -4
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  41. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  42. data/lib/samagotchi/testing/bundle.rb +1 -1
  43. data/lib/samagotchi/testing/host.rb +2 -8
  44. data/lib/samagotchi/testing/recorder.rb +6 -9
  45. data/lib/samagotchi/tools/delegate.rb +10 -10
  46. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  47. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  48. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +46 -63
  51. data/lib/samagotchi/web/public/app.js +122 -556
  52. data/lib/samagotchi/web/public/index.html +1 -1
  53. data/lib/samagotchi/web/public/send_turn.js +173 -0
  54. data/lib/samagotchi/web/public/session_actions.js +203 -0
  55. data/lib/samagotchi/web/public/start_page.js +236 -0
  56. metadata +10 -1
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_inbox"
5
+ require_relative "../turn_checkpoint"
6
+
7
+ module Samagotchi
8
+ module ReplyWait
9
+ # One ReplyWait.call: what the wait learned so far (the turn's input
10
+ # claimed, its turn seen running, any turn seen running, the worker
11
+ # gone since) and one look at the session per poll. Each check returns
12
+ # a Result that ends the wait, or nil to go on, in the order #look runs
13
+ # them. ReplyWait.call's parameters say what each means.
14
+ class Poll
15
+ def initialize(id, state_dir:, cursor:, timeout:, poll_interval:, cancelled:, baseline:, owner_grace:, interject:,
16
+ turn:, queued:, ended_before:)
17
+ @id = id
18
+ @state_dir = state_dir
19
+ @cursor = cursor
20
+ @poll_interval = poll_interval
21
+ @cancelled = cancelled
22
+ @baseline = baseline
23
+ @owner_grace = owner_grace
24
+ @interject = interject
25
+ @ended_before = ended_before
26
+ @deadline = timeout && (ReplyWait.monotonic + timeout.to_f)
27
+ @turn = turn.to_s.empty? ? nil : turn.to_s
28
+ @session_dir = Session.session_dir(id, state_dir: state_dir) if @turn
29
+ # Whether the turn's input file has been taken (latched): looked at
30
+ # before each load, so a running session seen after it is that turn
31
+ # or a later one, never the turn that ran when the message was sent.
32
+ @claimed = false
33
+ # Its input runs a turn of its own (a queue, images: +queued+, or
34
+ # seen waiting as one): never merged into another turn.
35
+ @own_turn_input = queued
36
+ # The turn was seen running (its mark's id), and last_turn's
37
+ # ended_at then: a later end that isn't its is a turn after it.
38
+ @seen_own = false
39
+ @own_base = nil
40
+ @seen_running = false
41
+ @gone_since = nil
42
+ end
43
+
44
+ # @return [Result]
45
+ def run
46
+ loop do
47
+ result = look
48
+ return result if result
49
+
50
+ if @interject
51
+ started = ReplyWait.monotonic
52
+ @interject.call
53
+ @deadline += ReplyWait.monotonic - started if @deadline
54
+ next if @cancelled.call
55
+ end
56
+ sleep(@poll_interval)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ # One poll: the input file, then the session as loaded now.
63
+ # @return [Result, nil]
64
+ def look
65
+ look_at_input
66
+ session = Session.load(@id, state_dir: @state_dir)
67
+ if (result = removed(session))
68
+ return result
69
+ end
70
+
71
+ note_own_running(session)
72
+ follow_merge(session)
73
+ reply(session) || settled_turn(session) || status_end(session) || question(session) || canceled ||
74
+ ended_before_reply(session) || ended_before_turn(session) || turn_end(session) || owner_gone(session) ||
75
+ timed_out
76
+ end
77
+
78
+ def look_at_input
79
+ return unless @turn && !@claimed
80
+
81
+ waiting = SessionInbox.waiting_input(@session_dir, @turn)
82
+ @own_turn_input ||= waiting == :turn_end
83
+ @claimed = waiting.nil?
84
+ end
85
+
86
+ # Taken back before it ran (POST turn/dequeue): no turn will come. A
87
+ # tombstone with the claimed file still there is a removal not done
88
+ # yet (or one a turn's claim beat): looked at again next poll. One
89
+ # whose turn ran anyway (its reply, or last_turn its own) is a stray:
90
+ # a worker killed between the tombstone and the claim left it, or a
91
+ # short turn won the claim and ended before the removal took it back.
92
+ def removed(session)
93
+ return nil unless @turn && @claimed && !@seen_own && SessionInbox.removed?(@session_dir, @turn)
94
+ return nil if SessionInbox.claimed_input?(@session_dir, @turn) || turn_ran?(session)
95
+
96
+ Result.new(status: :no_reply, outcome: "removed", session: session)
97
+ end
98
+
99
+ def turn_ran?(session)
100
+ session.last_turn&.enqueued_id == @turn || !SessionInbox.reply_for(@session_dir, @turn).nil?
101
+ end
102
+
103
+ def note_own_running(session)
104
+ return unless @turn && !@seen_own && own_running?(session)
105
+
106
+ @seen_own = true
107
+ @own_base = session.last_turn&.ended_at
108
+ end
109
+
110
+ # Taken but never run as a turn of its own: a running turn's drain
111
+ # merged it as a steer, so that turn's reply (or question) is its
112
+ # answer. Waited for as the next reply past the cursor, as without
113
+ # turn:, never as a turn that will not come.
114
+ def follow_merge(session)
115
+ return unless @turn && @claimed && !@seen_own && !@own_turn_input &&
116
+ ReplyWait.merged_elsewhere?(session, @turn, @baseline, @session_dir)
117
+
118
+ @turn = nil
119
+ @seen_running = running?(session)
120
+ end
121
+
122
+ def reply(session)
123
+ reply = if @turn
124
+ ReplyWait.reply_for_turn(@id, state_dir: @state_dir, turn: @turn)
125
+ else
126
+ ReplyWait.reply_past(@id, state_dir: @state_dir, cursor: @cursor)
127
+ end
128
+ return nil unless reply
129
+
130
+ reply.session = ReplyWait.settled(session, @id, state_dir: @state_dir)
131
+ reply
132
+ end
133
+
134
+ # Its worker died in it and the next one settled it, or an input
135
+ # command finished without a turn. After the reply check: a worker
136
+ # killed between writing the reply and its idle save left the reply.
137
+ def settled_turn(session)
138
+ settled = @turn && TurnCheckpoint.settled_turn(@session_dir, @turn)
139
+ return nil unless settled
140
+
141
+ Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
142
+ **settled.stop_facts)
143
+ end
144
+
145
+ def status_end(session)
146
+ case session.status
147
+ when Session::STATUS_ERROR then Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
148
+ when Session::STATUS_STOPPED then Result.new(status: :stopped, session: session)
149
+ end
150
+ end
151
+
152
+ # Only a question a live worker holds waits for an answer (the lists'
153
+ # rule): one a dead worker left waits for no one, and a chi REPL's is
154
+ # answered there.
155
+ def question(session)
156
+ pending = session.pending_question
157
+ return nil unless pending && !@baseline&.question?(pending.id) &&
158
+ (@turn.nil? || ReplyWait.own_question?(session, @turn, @claimed)) &&
159
+ session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
160
+
161
+ Result.new(status: :waiting_for_answer, question: pending, session: session)
162
+ end
163
+
164
+ def canceled = @cancelled.call ? Result.new(status: :canceled) : nil
165
+
166
+ # The turn that ended before this wait (ended_before: chi send --wait
167
+ # with no message, on a session that went idle in the gap between
168
+ # waits): its last_turn still stands, the session is idle, so its end
169
+ # is the answer, not a wait that times out "running" again. Its reply
170
+ # file sorts at the cursor (the newest), not past it: it is the
171
+ # turn's when it is at least as new as the turn's end (the worker
172
+ # writes it after recording the ended_at), else the turn left no
173
+ # reply and an earlier turn's file is not its.
174
+ def ended_before_reply(session)
175
+ return nil unless @ended_before && @turn.nil? && session.status == Session::STATUS_IDLE && baseline_end?(session)
176
+
177
+ file = ReplyWait.newest_reply(@id, state_dir: @state_dir)
178
+ path = File.join(ReplyWait.reply_dir(@id, state_dir: @state_dir), file) if file
179
+ if file && ReplyWait.reply_is_turns?(path, session.last_turn.ended_at)
180
+ reply = Result.new(status: :done, text: File.read(path), file: file)
181
+ reply.session = ReplyWait.settled(session, @id, state_dir: @state_dir)
182
+ return reply
183
+ end
184
+
185
+ ReplyWait.no_reply(session, @baseline, fresh: true)
186
+ end
187
+
188
+ # The same for a turn: wait whose turn is that last one (not running
189
+ # again). The turn's own reply lookup (#reply) already found its file,
190
+ # if one exists; without it the newest file is no fallback: it could
191
+ # be another turn's.
192
+ def ended_before_turn(session)
193
+ return nil unless @ended_before && @turn && session.last_turn&.enqueued_id == @turn && !own_running?(session) &&
194
+ baseline_end?(session)
195
+
196
+ ReplyWait.no_reply(session, @baseline, fresh: true)
197
+ end
198
+
199
+ def turn_end(session)
200
+ return own_turn_end(session) if @turn
201
+
202
+ if running?(session)
203
+ @seen_running = true
204
+ return nil
205
+ end
206
+ # It ran and is idle again with no new reply: canceled, failed or
207
+ # empty. Before it was ever seen running, idle means a
208
+ # file-delivered message its worker has not picked up yet. With a
209
+ # baseline only a turn that ended since counts: a session left
210
+ # running by a worker that died goes idle when the next worker
211
+ # settles that turn (TurnCheckpoint.settle), before the sent
212
+ # message's turn runs.
213
+ return nil unless (@seen_running && @baseline.nil?) || ReplyWait.turn_ended_since?(session, @baseline)
214
+
215
+ # The worker writes the reply before its idle save, so the read
216
+ # above has it; one more look costs nothing should that change.
217
+ ReplyWait.reply_past(@id, state_dir: @state_dir, cursor: @cursor)&.tap { |r| r.session = session } ||
218
+ ReplyWait.no_reply(session, @baseline)
219
+ end
220
+
221
+ # Only the turn's own end: the session runs another turn when the
222
+ # wait starts, and that one's end is not the answer. A last_turn with
223
+ # its id is its end, even with a next queued turn running by now (the
224
+ # look between the two missed the idle). Only an end after the
225
+ # baseline: a continue offer answered from idle leaves the exhausted
226
+ # turn's last_turn, with the same id, until the continue turn (or a
227
+ # Stop) records its own.
228
+ def own_turn_end(session)
229
+ if session.last_turn&.enqueued_id == @turn && ReplyWait.ended_since_baseline?(session, @baseline)
230
+ return ReplyWait.reply_for_turn(@id, state_dir: @state_dir, turn: @turn)&.tap { |r| r.session = session } ||
231
+ ReplyWait.no_reply(session, @baseline, fresh: true)
232
+ end
233
+ # Seen running, and now another turn runs or a later turn ended
234
+ # while last_turn (looked at first) isn't its: lost (its worker
235
+ # died), or it ended with no reply and a next turn ran and ended
236
+ # between two looks. Waiting on would sit through those turns.
237
+ return Result.new(status: :no_reply, session: session) if @seen_own && ReplyWait.past_own?(session, @turn, @own_base)
238
+
239
+ nil
240
+ end
241
+
242
+ def owner_gone(session)
243
+ return nil unless @owner_grace
244
+
245
+ if SessionManager.session_owner(@id, state_dir: @state_dir)
246
+ @gone_since = nil
247
+ elsif ReplyWait.monotonic - (@gone_since ||= ReplyWait.monotonic) > @owner_grace
248
+ return Result.new(status: :worker_gone, session: session)
249
+ end
250
+ nil
251
+ end
252
+
253
+ def timed_out = @deadline && ReplyWait.monotonic > @deadline ? Result.new(status: :timeout) : nil
254
+
255
+ # Whether the session's last turn is the one that had ended at the
256
+ # baseline (its ended_at set and unchanged).
257
+ def baseline_end?(session)
258
+ ended = session.last_turn&.ended_at
259
+ (!ended.to_s.empty? && @baseline&.ended_at?(ended)) || false
260
+ end
261
+
262
+ def running?(session) = session.status == Session::STATUS_RUNNING
263
+
264
+ def own_running?(session) = running?(session) && session.interrupted_turn&.enqueued_id == @turn
265
+ end
266
+ end
267
+ end
@@ -5,6 +5,7 @@ require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
6
  require_relative "turn_checkpoint"
7
7
  require_relative "worker_sidecar"
8
+ require_relative "reply_wait/poll"
8
9
 
9
10
  module Samagotchi
10
11
  # Loaded on first use: this autoload keeps SessionManager and its ~80
@@ -65,6 +66,31 @@ module Samagotchi
65
66
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
66
67
  :stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
67
68
 
69
+ # The session as it was before a message went in (call's baseline:,
70
+ # #baseline_of): its message count, the question pending then, and when
71
+ # its last turn ended (+last_turn+, last_turn.ended_at, maybe nil).
72
+ # Every turn's end records last_turn as it goes idle (Engine#end_turn),
73
+ # so +last_turn+ alone says whether a turn ended since: messages also
74
+ # grow between turns (a new worker's context note, a chi note) with no
75
+ # turn run. +messages+ is kept for a delegate's cursor (DelegateCursor:
76
+ # nil there means no baseline taken).
77
+ Baseline = Data.define(:messages, :question_id, :last_turn) do
78
+ def initialize(messages: nil, question_id: nil, last_turn: nil) = super
79
+
80
+ # Whether +session+'s last turn ended since: its last_turn moved on.
81
+ def turn_ended_since?(session)
82
+ ended = session.last_turn&.ended_at
83
+ !ended.nil? && ended != last_turn
84
+ end
85
+
86
+ # Whether +ended_at+ is the end of the turn that was last at the
87
+ # baseline (a turn that ended before it).
88
+ def ended_at?(ended_at) = ended_at == last_turn
89
+
90
+ # Whether +question_id+ was pending at the baseline (not the answer's).
91
+ def question?(question_id) = question_id == self.question_id
92
+ end
93
+
68
94
  module_function
69
95
 
70
96
  # @param id [String] the session's full id
@@ -73,16 +99,11 @@ module Samagotchi
73
99
  # @param timeout [Numeric, nil] seconds; nil waits with no limit
74
100
  # @param poll_interval [Float]
75
101
  # @param cancelled [#call] true stops the wait (:canceled)
76
- # @param baseline [Hash, nil] {messages:, question_id:} as the session
77
- # was before the message went in. With it, a turn that grew the
78
- # messages (a failed, canceled or empty one leaves its note) and went
79
- # idle again ends the wait even if it was never seen running, and a
80
- # question already pending then is not the answer's. A nil
81
- # messages: skips the count. last_turn: (the session's
82
- # last_turn.ended_at then, maybe nil) ends the wait on any turn
83
- # that ended since, idle again, however fast it ran (a failure after
84
- # a failure replaces the note and leaves the count as it was), and
85
- # with it the count isn't used: a note between turns grows it too.
102
+ # @param baseline [Baseline, nil] the session as it was before the
103
+ # message went in (#baseline_of). With it, any turn that ended since
104
+ # (its last_turn moved on) and went idle again ends the wait, even if
105
+ # it was never seen running, however fast it ran, and a question
106
+ # already pending then is not the answer's.
86
107
  # @param owner_grace [Numeric, nil] seconds with no live worker before
87
108
  # :worker_gone (one that died before its rescue leaves it running)
88
109
  # @param interject [#call, nil] called once a poll, before the sleep
@@ -116,187 +137,22 @@ module Samagotchi
116
137
  # @raise [ArgumentError] no such session
117
138
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
118
139
  baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
119
- deadline = timeout && (monotonic + timeout.to_f)
120
- seen_running = false
121
- gone_since = nil
122
- turn = turn.to_s.empty? ? nil : turn.to_s
123
- session_dir = Session.session_dir(id, state_dir: state_dir) if turn
124
- # Whether the turn's input file has been taken (latched): looked at
125
- # before each load, so a running session seen after it is that turn
126
- # or a later one, never the turn that ran when the message was sent.
127
- claimed = false
128
- # Its input runs a turn of its own (a queue, images: +queued+, or seen
129
- # waiting as one): never merged into another turn.
130
- own_turn_input = queued
131
- # The turn was seen running (its mark's id), and last_turn's ended_at
132
- # then: a later end that isn't its is a turn after it.
133
- seen_own = false
134
- own_base = nil
135
-
136
- loop do
137
- if turn && !claimed
138
- waiting = SessionInbox.waiting_input(session_dir, turn)
139
- own_turn_input ||= waiting == :turn_end
140
- claimed = waiting.nil?
141
- end
142
- session = Session.load(id, state_dir: state_dir)
143
- # Taken back before it ran (POST turn/dequeue): no turn will come.
144
- if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
145
- return Result.new(status: :no_reply, outcome: "removed", session: session)
146
- end
147
-
148
- if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
149
- seen_own = true
150
- own_base = session.last_turn&.ended_at
151
- end
152
- # Taken but never run as a turn of its own: a running turn's drain
153
- # merged it as a steer, so that turn's reply (or question) is its
154
- # answer. Waited for as the next reply past the cursor, as without
155
- # turn:, never as a turn that will not come.
156
- if turn && claimed && !seen_own && !own_turn_input && merged_elsewhere?(session, turn, baseline, session_dir)
157
- turn = nil
158
- seen_running = session.status == Session::STATUS_RUNNING
159
- end
160
-
161
- if (reply = turn ? reply_for_turn(id, state_dir: state_dir, turn: turn) : reply_past(id, state_dir: state_dir, cursor: cursor))
162
- reply.session = settled(session, id, state_dir: state_dir)
163
- return reply
164
- end
165
- # Its worker died in it and the next one settled it, or an input
166
- # command finished without a turn. After the reply check: a worker
167
- # killed between writing the reply and its idle save left the reply.
168
- if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
169
- return Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
170
- **settled.stop_facts)
171
- end
172
-
173
- case session.status
174
- when Session::STATUS_ERROR
175
- return Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
176
- when Session::STATUS_STOPPED
177
- return Result.new(status: :stopped, session: session)
178
- end
179
-
180
- # Only a question a live worker holds waits for an answer (the
181
- # lists' rule): one a dead worker left waits for no one, and a chi
182
- # REPL's is answered there.
183
- pending = session.pending_question
184
- if pending && !(baseline && pending.id == baseline[:question_id]) &&
185
- (turn.nil? || own_question?(session, turn, claimed)) &&
186
- session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
187
- return Result.new(status: :waiting_for_answer, question: pending, session: session)
188
- end
189
-
190
- return Result.new(status: :canceled) if cancelled.call
191
-
192
- # The turn that ended before this wait (ended_before: chi send
193
- # --wait with no message, on a session that went idle in the gap
194
- # between waits): its last_turn still stands, the session is idle,
195
- # so its end is the answer, not a wait that times out "running"
196
- # again. Its reply file sorts at the cursor (the newest), not past
197
- # it: it is the turn's when it is at least as new as the turn's
198
- # end (the worker writes it after recording the ended_at), else the
199
- # turn left no reply and an earlier turn's file is not its.
200
- if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
201
- (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
202
- baseline && ended == baseline[:last_turn]
203
- file = newest_reply(id, state_dir: state_dir)
204
- path = File.join(reply_dir(id, state_dir: state_dir), file) if file
205
- if file && reply_is_turns?(path, ended)
206
- reply = Result.new(status: :done, text: File.read(path), file: file)
207
- reply.session = settled(session, id, state_dir: state_dir)
208
- return reply
209
- end
210
-
211
- return no_reply(session, baseline, fresh: true)
212
- end
213
-
214
- if ended_before && turn && session.last_turn&.enqueued_id == turn &&
215
- !(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
216
- (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
217
- baseline && ended == baseline[:last_turn]
218
- # The turn-specific reply lookup above already found its file, if
219
- # one exists. Without it, don't use the newest file as a fallback:
220
- # it could belong to another turn.
221
- return no_reply(session, baseline, fresh: true)
222
- end
223
-
224
- if turn
225
- # Only the turn's own end: the session runs another turn when the
226
- # wait starts, and that one's end is not the answer. A last_turn
227
- # with its id is its end, even with a next queued turn running
228
- # by now (the look between the two missed the idle).
229
- # Only an end after the baseline: a continue offer answered from
230
- # idle leaves the exhausted turn's last_turn, with the same id,
231
- # until the continue turn (or a Stop) records its own.
232
- if session.last_turn&.enqueued_id == turn && ended_since_baseline?(session, baseline)
233
- return reply_for_turn(id, state_dir: state_dir, turn: turn)&.tap { |r| r.session = session } ||
234
- no_reply(session, baseline, fresh: true)
235
- end
236
- # Seen running, and now another turn runs or a later turn ended
237
- # while last_turn (looked at first) isn't its: lost (its worker
238
- # died), or it ended with no reply and a next turn ran and ended
239
- # between two looks. Waiting on would sit through those turns.
240
- return Result.new(status: :no_reply, session: session) if seen_own && past_own?(session, turn, own_base)
241
- elsif session.status == Session::STATUS_RUNNING
242
- seen_running = true
243
- elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
244
- # It ran and is idle again with no new reply: canceled, failed or
245
- # empty. Before it was ever seen running, idle means a
246
- # file-delivered message its worker has not picked up yet. With
247
- # the baseline's last_turn only a turn that ended since counts:
248
- # a session left running by a worker that died goes idle when the
249
- # next worker settles that turn (TurnCheckpoint.settle), before
250
- # the sent message's turn runs.
251
- # The worker writes the reply before its idle save, so the read
252
- # above has it; one more look costs nothing should that change.
253
- return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
254
- no_reply(session, baseline)
255
- end
256
-
257
- if owner_grace
258
- if SessionManager.session_owner(id, state_dir: state_dir)
259
- gone_since = nil
260
- elsif monotonic - (gone_since ||= monotonic) > owner_grace
261
- return Result.new(status: :worker_gone, session: session)
262
- end
263
- end
264
-
265
- return Result.new(status: :timeout) if deadline && monotonic > deadline
266
-
267
- if interject
268
- started = monotonic
269
- interject.call
270
- deadline += monotonic - started if deadline
271
- next if cancelled.call
272
- end
273
- sleep(poll_interval)
274
- end
140
+ Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
141
+ cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
142
+ queued: queued, ended_before: ended_before).run
275
143
  end
276
144
 
277
145
  # The session as it was before a message went in (call's baseline:):
278
146
  # its messages, the question pending then, and when its last turn
279
147
  # ended, so a turn that ends before the first look still ends the wait.
280
- # @return [Hash]
148
+ # @return [Baseline]
281
149
  def baseline_of(session, question_id: session.pending_question&.id)
282
- { messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
150
+ Baseline.new(messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at)
283
151
  end
284
152
 
285
- # An idle session whose turn ended after the baseline was taken: its
286
- # last_turn moved on. Every turn's end records last_turn as it goes
287
- # idle (Engine#end_turn), so with one in the baseline that alone
288
- # decides: messages also grow between turns (a new worker's context
289
- # note, a chi note) with no turn run. A baseline without it falls back
290
- # to the count (a turn's note).
291
- def turn_ended_since?(session, baseline)
292
- return false unless baseline
293
-
294
- if baseline.key?(:last_turn)
295
- ended = session.last_turn&.ended_at
296
- return !ended.nil? && ended != baseline[:last_turn]
297
- end
298
- !!(baseline[:messages] && session.messages.size > baseline[:messages])
299
- end
153
+ # A session whose turn ended after the baseline was taken (Baseline#
154
+ # turn_ended_since?); false with no baseline.
155
+ def turn_ended_since?(session, baseline) = baseline ? baseline.turn_ended_since?(session) : false
300
156
 
301
157
  # The turn a caller that sent nothing means (chi answer, chi send --wait
302
158
  # with no message): the one running now (its checkpoint mark's
@@ -354,20 +210,21 @@ module Samagotchi
354
210
  !ended && claimed
355
211
  end
356
212
 
357
- # The turn's end, after the baseline was taken (with no baseline, or
358
- # one without last_turn, any).
213
+ # The turn's end, after the baseline was taken (with no baseline, any).
359
214
  def ended_since_baseline?(session, baseline)
360
- return true unless baseline&.key?(:last_turn)
215
+ return true unless baseline
361
216
 
362
- session.last_turn&.ended_at != baseline[:last_turn]
217
+ !baseline.ended_at?(session.last_turn&.ended_at)
363
218
  end
364
219
 
365
220
  # A turn: wait's taken input ran in another turn (a steer its drain
366
221
  # merged): that turn runs (its mark has another id, or none from a
367
222
  # worker that writes ids), or, idle, a turn not its ended since the
368
- # baseline. last_turn or a reply file being its own is never that.
223
+ # baseline. last_turn or a reply file being its own is never that, nor
224
+ # an input with a tombstone (a removal's, written before its claim).
369
225
  def merged_elsewhere?(session, turn, baseline, session_dir)
370
226
  return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
227
+ return false if SessionInbox.removed?(session_dir, turn)
371
228
 
372
229
  if session.status == Session::STATUS_RUNNING
373
230
  running_id = session.interrupted_turn&.enqueued_id
@@ -375,7 +232,7 @@ module Samagotchi
375
232
 
376
233
  return turn_ids?(session_dir)
377
234
  end
378
- baseline&.key?(:last_turn) ? turn_ended_since?(session, baseline) : false
235
+ turn_ended_since?(session, baseline)
379
236
  end
380
237
 
381
238
  # A turn seen running (+own_base+: last_turn's ended_at then) is past:
@@ -406,7 +263,7 @@ module Samagotchi
406
263
  # records, when it is the turn waited for
407
264
  def no_reply(session, baseline, fresh: nil)
408
265
  last = session.last_turn || LastTurn.new
409
- fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn]) if fresh.nil?
266
+ fresh = baseline.nil? || (last.ended_at && !baseline.ended_at?(last.ended_at)) if fresh.nil?
410
267
  outcome = fresh ? last.outcome : nil
411
268
  return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
412
269
 
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_chain"
5
+ require_relative "../session_commands"
6
+ require_relative "../parent_answers_command"
7
+ require_relative "../turn_flow"
8
+ require_relative "../guardrails/parent_approvals"
9
+ require_relative "../guardrails/parent_policy"
10
+
11
+ module Samagotchi
12
+ class SendCommand
13
+ # What a parent agent's line may do, checked before chi send sends it
14
+ # (before a --new session is created too), against each target's own
15
+ # parent_answers: a --new session's --parent-answers, a --continues
16
+ # link's previous session's (the link keeps it), each id's.
17
+ #
18
+ # A parent agent's /continue yes is chi answer --option Continue by
19
+ # another route: held to guardrails.parent_answers tightened by each
20
+ # target's own (#continue_refusal), and sent marked as chi answer's
21
+ # (SendCommand#client_id_for) so the worker checks it again. A
22
+ # /parent-answers line never loosens a target's own value (#loosens?):
23
+ # only a user at a UI does, and the worker refuses it too. Tightening
24
+ # goes.
25
+ class ParentGate
26
+ # @param options [Hash] chi send's parsed options (:new, :continues,
27
+ # :parent_answers, :ids)
28
+ def initialize(options, state_dir:)
29
+ @options = options
30
+ @state_dir = state_dir
31
+ end
32
+
33
+ # A /continue yes (or bare /continue) from a parent agent's process.
34
+ def self.parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
35
+
36
+ # @return [String, nil] the first target's refusal of a parent's
37
+ # /continue yes; nil for any other line, or when none refuses
38
+ def continue_refusal(prompt)
39
+ return nil unless self.class.parent_continue?(prompt)
40
+
41
+ target_values.each do |value|
42
+ strategy = Guardrails::ParentPolicy.effective(value)
43
+ refusal = Guardrails::ParentPolicy.continue_refusal(strategy: strategy)
44
+ return refusal if refusal
45
+ end
46
+ nil
47
+ end
48
+
49
+ # Whether +prompt+ is a /parent-answers line that loosens a target's
50
+ # own value.
51
+ def loosens?(prompt)
52
+ SessionCommands.builtin_registry.lookup(prompt)&.id == :parent_answers &&
53
+ target_values.any? { |value| ParentAnswersCommand.loosens?(prompt, value) }
54
+ end
55
+
56
+ private
57
+
58
+ # The targets' own parent_answers values. An id that doesn't resolve
59
+ # is skipped (SendCommand#resolve reports it later); none left is
60
+ # config.yml's alone ([nil]).
61
+ def target_values
62
+ @target_values ||= if @options[:new] && !@options[:continues]
63
+ [@options[:parent_answers]]
64
+ else
65
+ sessions = if @options[:new]
66
+ [target_session { SessionChain.resolve(@options[:continues], state_dir: @state_dir) }]
67
+ else
68
+ @options[:ids].map { |given| target_session { Session.resolve_id(given, state_dir: @state_dir) } }
69
+ end
70
+ values = sessions.compact.map(&:parent_answers)
71
+ values.empty? ? [nil] : values
72
+ end
73
+ end
74
+
75
+ # The session the block's id names, or nil when there is none.
76
+ def target_session
77
+ Session.load(yield, state_dir: @state_dir)
78
+ rescue ArgumentError
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end