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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/guardrails.md +4 -5
- data/docs/internals/engine.md +6 -0
- data/docs/sessions.md +1 -1
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +4 -14
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +47 -190
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +122 -556
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- 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 [
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 [
|
|
148
|
+
# @return [Baseline]
|
|
281
149
|
def baseline_of(session, question_id: session.pending_question&.id)
|
|
282
|
-
|
|
150
|
+
Baseline.new(messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at)
|
|
283
151
|
end
|
|
284
152
|
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
|
|
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,
|
|
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
|
|
215
|
+
return true unless baseline
|
|
361
216
|
|
|
362
|
-
session.last_turn&.ended_at
|
|
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
|
-
|
|
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? ||
|
|
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
|