samagotchi 0.91.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 +88 -1
- data/README.md +5 -2
- data/bin/chi +8 -8
- data/docs/architecture.md +20 -16
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -5
- data/docs/configuration.md +3 -1
- data/docs/context.md +5 -3
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +27 -8
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bridge.rb +8 -9
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_env.rb +50 -0
- 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 +26 -26
- 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 +7 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/parent_report.rb +12 -0
- 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 +51 -190
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- 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 +34 -103
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +60 -35
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/task_watch.rb +64 -21
- 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 +33 -20
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_response.rb +28 -6
- 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/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +130 -554
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- 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
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +77 -185
- data/lib/samagotchi/worker_idle_exit.rb +20 -5
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +0 -3
- metadata +15 -1
|
@@ -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
|
|
@@ -59,8 +60,36 @@ module Samagotchi
|
|
|
59
60
|
# :canceled and :timeout
|
|
60
61
|
# @!attribute turn_id [String, nil] the id of the turn this result is
|
|
61
62
|
# about, when known
|
|
63
|
+
# @!attribute origin [String, nil] "command" for a :no_reply that a
|
|
64
|
+
# file-delivered session command settled (no turn ran:
|
|
65
|
+
# TurnCheckpoint.record_command_outcome); nil otherwise
|
|
62
66
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
63
|
-
:stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
|
|
67
|
+
:stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
|
|
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
|
|
64
93
|
|
|
65
94
|
module_function
|
|
66
95
|
|
|
@@ -70,16 +99,11 @@ module Samagotchi
|
|
|
70
99
|
# @param timeout [Numeric, nil] seconds; nil waits with no limit
|
|
71
100
|
# @param poll_interval [Float]
|
|
72
101
|
# @param cancelled [#call] true stops the wait (:canceled)
|
|
73
|
-
# @param baseline [
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# messages: skips the count. last_turn: (the session's
|
|
79
|
-
# last_turn.ended_at then, maybe nil) ends the wait on any turn
|
|
80
|
-
# that ended since, idle again, however fast it ran (a failure after
|
|
81
|
-
# a failure replaces the note and leaves the count as it was), and
|
|
82
|
-
# 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.
|
|
83
107
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
84
108
|
# :worker_gone (one that died before its rescue leaves it running)
|
|
85
109
|
# @param interject [#call, nil] called once a poll, before the sleep
|
|
@@ -113,186 +137,22 @@ module Samagotchi
|
|
|
113
137
|
# @raise [ArgumentError] no such session
|
|
114
138
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
115
139
|
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
turn = turn.to_s.empty? ? nil : turn.to_s
|
|
120
|
-
session_dir = Session.session_dir(id, state_dir: state_dir) if turn
|
|
121
|
-
# Whether the turn's input file has been taken (latched): looked at
|
|
122
|
-
# before each load, so a running session seen after it is that turn
|
|
123
|
-
# or a later one, never the turn that ran when the message was sent.
|
|
124
|
-
claimed = false
|
|
125
|
-
# Its input runs a turn of its own (a queue, images: +queued+, or seen
|
|
126
|
-
# waiting as one): never merged into another turn.
|
|
127
|
-
own_turn_input = queued
|
|
128
|
-
# The turn was seen running (its mark's id), and last_turn's ended_at
|
|
129
|
-
# then: a later end that isn't its is a turn after it.
|
|
130
|
-
seen_own = false
|
|
131
|
-
own_base = nil
|
|
132
|
-
|
|
133
|
-
loop do
|
|
134
|
-
if turn && !claimed
|
|
135
|
-
waiting = SessionInbox.waiting_input(session_dir, turn)
|
|
136
|
-
own_turn_input ||= waiting == :turn_end
|
|
137
|
-
claimed = waiting.nil?
|
|
138
|
-
end
|
|
139
|
-
session = Session.load(id, state_dir: state_dir)
|
|
140
|
-
# Taken back before it ran (POST turn/dequeue): no turn will come.
|
|
141
|
-
if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
|
|
142
|
-
return Result.new(status: :no_reply, outcome: "removed", session: session)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
|
|
146
|
-
seen_own = true
|
|
147
|
-
own_base = session.last_turn&.ended_at
|
|
148
|
-
end
|
|
149
|
-
# Taken but never run as a turn of its own: a running turn's drain
|
|
150
|
-
# merged it as a steer, so that turn's reply (or question) is its
|
|
151
|
-
# answer. Waited for as the next reply past the cursor, as without
|
|
152
|
-
# turn:, never as a turn that will not come.
|
|
153
|
-
if turn && claimed && !seen_own && !own_turn_input && merged_elsewhere?(session, turn, baseline, session_dir)
|
|
154
|
-
turn = nil
|
|
155
|
-
seen_running = session.status == Session::STATUS_RUNNING
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
if (reply = turn ? reply_for_turn(id, state_dir: state_dir, turn: turn) : reply_past(id, state_dir: state_dir, cursor: cursor))
|
|
159
|
-
reply.session = settled(session, id, state_dir: state_dir)
|
|
160
|
-
return reply
|
|
161
|
-
end
|
|
162
|
-
# Its worker died in it and the next one settled it, or an input
|
|
163
|
-
# command finished without a turn. After the reply check: a worker
|
|
164
|
-
# killed between writing the reply and its idle save left the reply.
|
|
165
|
-
if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
|
|
166
|
-
return Result.new(status: :no_reply, outcome: settled.outcome, session: session, **settled.stop_facts)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
case session.status
|
|
170
|
-
when Session::STATUS_ERROR
|
|
171
|
-
return Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
|
|
172
|
-
when Session::STATUS_STOPPED
|
|
173
|
-
return Result.new(status: :stopped, session: session)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# Only a question a live worker holds waits for an answer (the
|
|
177
|
-
# lists' rule): one a dead worker left waits for no one, and a chi
|
|
178
|
-
# REPL's is answered there.
|
|
179
|
-
pending = session.pending_question
|
|
180
|
-
if pending && !(baseline && pending.id == baseline[:question_id]) &&
|
|
181
|
-
(turn.nil? || own_question?(session, turn, claimed)) &&
|
|
182
|
-
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
183
|
-
return Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
return Result.new(status: :canceled) if cancelled.call
|
|
187
|
-
|
|
188
|
-
# The turn that ended before this wait (ended_before: chi send
|
|
189
|
-
# --wait with no message, on a session that went idle in the gap
|
|
190
|
-
# between waits): its last_turn still stands, the session is idle,
|
|
191
|
-
# so its end is the answer, not a wait that times out "running"
|
|
192
|
-
# again. Its reply file sorts at the cursor (the newest), not past
|
|
193
|
-
# it: it is the turn's when it is at least as new as the turn's
|
|
194
|
-
# end (the worker writes it after recording the ended_at), else the
|
|
195
|
-
# turn left no reply and an earlier turn's file is not its.
|
|
196
|
-
if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
|
|
197
|
-
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
198
|
-
baseline && ended == baseline[:last_turn]
|
|
199
|
-
file = newest_reply(id, state_dir: state_dir)
|
|
200
|
-
path = File.join(reply_dir(id, state_dir: state_dir), file) if file
|
|
201
|
-
if file && reply_is_turns?(path, ended)
|
|
202
|
-
reply = Result.new(status: :done, text: File.read(path), file: file)
|
|
203
|
-
reply.session = settled(session, id, state_dir: state_dir)
|
|
204
|
-
return reply
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
return no_reply(session, baseline, fresh: true)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
if ended_before && turn && session.last_turn&.enqueued_id == turn &&
|
|
211
|
-
!(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
|
|
212
|
-
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
213
|
-
baseline && ended == baseline[:last_turn]
|
|
214
|
-
# The turn-specific reply lookup above already found its file, if
|
|
215
|
-
# one exists. Without it, don't use the newest file as a fallback:
|
|
216
|
-
# it could belong to another turn.
|
|
217
|
-
return no_reply(session, baseline, fresh: true)
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
if turn
|
|
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
|
|
223
|
-
# with its id is its end, even with a next queued turn running
|
|
224
|
-
# by now (the look between the two missed the idle).
|
|
225
|
-
# Only an end after the baseline: a continue offer answered from
|
|
226
|
-
# idle leaves the exhausted turn's last_turn, with the same id,
|
|
227
|
-
# until the continue turn (or a Stop) records its own.
|
|
228
|
-
if session.last_turn&.enqueued_id == turn && ended_since_baseline?(session, baseline)
|
|
229
|
-
return reply_for_turn(id, state_dir: state_dir, turn: turn)&.tap { |r| r.session = session } ||
|
|
230
|
-
no_reply(session, baseline, fresh: true)
|
|
231
|
-
end
|
|
232
|
-
# Seen running, and now another turn runs or a later turn ended
|
|
233
|
-
# while last_turn (looked at first) isn't its: lost (its worker
|
|
234
|
-
# died), or it ended with no reply and a next turn ran and ended
|
|
235
|
-
# between two looks. Waiting on would sit through those turns.
|
|
236
|
-
return Result.new(status: :no_reply, session: session) if seen_own && past_own?(session, turn, own_base)
|
|
237
|
-
elsif session.status == Session::STATUS_RUNNING
|
|
238
|
-
seen_running = true
|
|
239
|
-
elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
|
|
240
|
-
# It ran and is idle again with no new reply: canceled, failed or
|
|
241
|
-
# empty. Before it was ever seen running, idle means a
|
|
242
|
-
# file-delivered message its worker has not picked up yet. With
|
|
243
|
-
# the baseline's last_turn only a turn that ended since counts:
|
|
244
|
-
# a session left running by a worker that died goes idle when the
|
|
245
|
-
# next worker settles that turn (TurnCheckpoint.settle), before
|
|
246
|
-
# the sent message's turn runs.
|
|
247
|
-
# The worker writes the reply before its idle save, so the read
|
|
248
|
-
# above has it; one more look costs nothing should that change.
|
|
249
|
-
return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
|
|
250
|
-
no_reply(session, baseline)
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
if owner_grace
|
|
254
|
-
if SessionManager.session_owner(id, state_dir: state_dir)
|
|
255
|
-
gone_since = nil
|
|
256
|
-
elsif monotonic - (gone_since ||= monotonic) > owner_grace
|
|
257
|
-
return Result.new(status: :worker_gone, session: session)
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
return Result.new(status: :timeout) if deadline && monotonic > deadline
|
|
262
|
-
|
|
263
|
-
if interject
|
|
264
|
-
started = monotonic
|
|
265
|
-
interject.call
|
|
266
|
-
deadline += monotonic - started if deadline
|
|
267
|
-
next if cancelled.call
|
|
268
|
-
end
|
|
269
|
-
sleep(poll_interval)
|
|
270
|
-
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
|
|
271
143
|
end
|
|
272
144
|
|
|
273
145
|
# The session as it was before a message went in (call's baseline:):
|
|
274
146
|
# its messages, the question pending then, and when its last turn
|
|
275
147
|
# ended, so a turn that ends before the first look still ends the wait.
|
|
276
|
-
# @return [
|
|
148
|
+
# @return [Baseline]
|
|
277
149
|
def baseline_of(session, question_id: session.pending_question&.id)
|
|
278
|
-
|
|
150
|
+
Baseline.new(messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at)
|
|
279
151
|
end
|
|
280
152
|
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
# decides: messages also grow between turns (a new worker's context
|
|
285
|
-
# note, a chi note) with no turn run. A baseline without it falls back
|
|
286
|
-
# to the count (a turn's note).
|
|
287
|
-
def turn_ended_since?(session, baseline)
|
|
288
|
-
return false unless baseline
|
|
289
|
-
|
|
290
|
-
if baseline.key?(:last_turn)
|
|
291
|
-
ended = session.last_turn&.ended_at
|
|
292
|
-
return !ended.nil? && ended != baseline[:last_turn]
|
|
293
|
-
end
|
|
294
|
-
!!(baseline[:messages] && session.messages.size > baseline[:messages])
|
|
295
|
-
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
|
|
296
156
|
|
|
297
157
|
# The turn a caller that sent nothing means (chi answer, chi send --wait
|
|
298
158
|
# with no message): the one running now (its checkpoint mark's
|
|
@@ -350,20 +210,21 @@ module Samagotchi
|
|
|
350
210
|
!ended && claimed
|
|
351
211
|
end
|
|
352
212
|
|
|
353
|
-
# The turn's end, after the baseline was taken (with no baseline,
|
|
354
|
-
# one without last_turn, any).
|
|
213
|
+
# The turn's end, after the baseline was taken (with no baseline, any).
|
|
355
214
|
def ended_since_baseline?(session, baseline)
|
|
356
|
-
return true unless baseline
|
|
215
|
+
return true unless baseline
|
|
357
216
|
|
|
358
|
-
session.last_turn&.ended_at
|
|
217
|
+
!baseline.ended_at?(session.last_turn&.ended_at)
|
|
359
218
|
end
|
|
360
219
|
|
|
361
220
|
# A turn: wait's taken input ran in another turn (a steer its drain
|
|
362
221
|
# merged): that turn runs (its mark has another id, or none from a
|
|
363
222
|
# worker that writes ids), or, idle, a turn not its ended since the
|
|
364
|
-
# 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).
|
|
365
225
|
def merged_elsewhere?(session, turn, baseline, session_dir)
|
|
366
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)
|
|
367
228
|
|
|
368
229
|
if session.status == Session::STATUS_RUNNING
|
|
369
230
|
running_id = session.interrupted_turn&.enqueued_id
|
|
@@ -371,7 +232,7 @@ module Samagotchi
|
|
|
371
232
|
|
|
372
233
|
return turn_ids?(session_dir)
|
|
373
234
|
end
|
|
374
|
-
|
|
235
|
+
turn_ended_since?(session, baseline)
|
|
375
236
|
end
|
|
376
237
|
|
|
377
238
|
# A turn seen running (+own_base+: last_turn's ended_at then) is past:
|
|
@@ -402,7 +263,7 @@ module Samagotchi
|
|
|
402
263
|
# records, when it is the turn waited for
|
|
403
264
|
def no_reply(session, baseline, fresh: nil)
|
|
404
265
|
last = session.last_turn || LastTurn.new
|
|
405
|
-
fresh = baseline.nil? ||
|
|
266
|
+
fresh = baseline.nil? || (last.ended_at && !baseline.ended_at?(last.ended_at)) if fresh.nil?
|
|
406
267
|
outcome = fresh ? last.outcome : nil
|
|
407
268
|
return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
|
|
408
269
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require_relative "paths"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The temp folders a sandboxed chi runs in, shared by `chi bundle try`'s
|
|
10
|
+
# child (BundleCommand::Try::Sandbox) and the test kit
|
|
11
|
+
# (Testing::Sandbox): one realpath'd root holding config/, state/,
|
|
12
|
+
# work/ and whatever else the sandbox wants, the XDG pair pointing at
|
|
13
|
+
# them, and the switches every sandbox turns off. Where the environment
|
|
14
|
+
# goes is each sandbox's own: try builds its child's Hash, the kit
|
|
15
|
+
# changes ENV.
|
|
16
|
+
class SandboxLayout
|
|
17
|
+
DIRS = %w[config state work].freeze
|
|
18
|
+
# No recap request and no cache warm-up request in a sandbox.
|
|
19
|
+
QUIET_ENV = { "SAMAGOTCHI_RECAP_ENABLED" => "false", "SAMAGOTCHI_CACHE_WARMUP" => "off" }.freeze
|
|
20
|
+
|
|
21
|
+
# A new temp root (Dir.mktmpdir +prefix+, its realpath) with DIRS and
|
|
22
|
+
# +extra+ folders in it.
|
|
23
|
+
# @return [SandboxLayout]
|
|
24
|
+
def self.create(prefix, extra: [])
|
|
25
|
+
root = File.realpath(Dir.mktmpdir(prefix))
|
|
26
|
+
(DIRS + extra).each { |dir| FileUtils.mkdir_p(File.join(root, dir)) }
|
|
27
|
+
new(root)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
FileUtils.rm_rf(root) if root
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :root
|
|
35
|
+
|
|
36
|
+
def initialize(root)
|
|
37
|
+
@root = root
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dir(name) = File.join(root, name)
|
|
41
|
+
|
|
42
|
+
def config_home = dir("config")
|
|
43
|
+
|
|
44
|
+
def state_home = dir("state")
|
|
45
|
+
|
|
46
|
+
def work_dir = dir("work")
|
|
47
|
+
|
|
48
|
+
# XDG_CONFIG_HOME and XDG_STATE_HOME at the sandbox's, and QUIET_ENV.
|
|
49
|
+
# @return [Hash{String=>String}]
|
|
50
|
+
def env = { "XDG_CONFIG_HOME" => config_home, "XDG_STATE_HOME" => state_home }.merge(QUIET_ENV)
|
|
51
|
+
|
|
52
|
+
# config.yml under +config_home+ (default the sandbox's): a Hash (as
|
|
53
|
+
# YAML) or YAML text.
|
|
54
|
+
# @return [String] its path
|
|
55
|
+
def write_config(data, config_home: self.config_home)
|
|
56
|
+
path = File.join(config_home, Paths::APP_DIR, "config.yml")
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
58
|
+
File.write(path, data.is_a?(String) ? data : YAML.dump(data))
|
|
59
|
+
path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def remove
|
|
63
|
+
FileUtils.rm_rf(root)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../reply_wait"
|
|
4
|
+
require_relative "../session"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class SendCommand
|
|
8
|
+
# Which turn chi send --wait waits for and names in its report:
|
|
9
|
+
# ReplyWait's +turn+ (an enqueued_id; nil: the next reply), +queued+
|
|
10
|
+
# and +ended_before+, and ParentWait's +report_turn_id+ (the turn_id
|
|
11
|
+
# of the --format json line). Each run path builds it its way.
|
|
12
|
+
WaitTarget = Data.define(:turn, :report_turn_id, :queued, :ended_before) do
|
|
13
|
+
def initialize(turn: nil, report_turn_id: nil, queued: false, ended_before: false) = super
|
|
14
|
+
|
|
15
|
+
# A --new session's first message. A file-delivered one's own turn
|
|
16
|
+
# (its id also names the outcome of a session command that runs no
|
|
17
|
+
# turn); through the Bridge, the next reply.
|
|
18
|
+
# @param delivered [SessionManager::DeliverResult, nil] nil when the
|
|
19
|
+
# message started the session itself (no delivery)
|
|
20
|
+
def self.first_message(delivered)
|
|
21
|
+
return new unless delivered
|
|
22
|
+
|
|
23
|
+
new(turn: delivered.file? ? delivered.enqueued_id : nil, report_turn_id: delivered.enqueued_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A message to an existing session. --queue: its own turn, the ack's
|
|
27
|
+
# id, also when the turn running at the check ended before it went in
|
|
28
|
+
# (an idle worker's ack says next_step, and the file still runs as
|
|
29
|
+
# that turn). A file fallback's id names its turn too. Otherwise the
|
|
30
|
+
# next reply, reported as the turn that was running when it went in
|
|
31
|
+
# (+running_turn+, ReplyWait.own_turn), or as its own on an idle one.
|
|
32
|
+
# @param running [Boolean] the session was running when it went in
|
|
33
|
+
def self.message(delivered, queue:, running:, running_turn:)
|
|
34
|
+
turn = delivered.enqueued_id if queue || delivered.file?
|
|
35
|
+
report = if queue then turn
|
|
36
|
+
elsif running then running_turn
|
|
37
|
+
else delivered.enqueued_id
|
|
38
|
+
end
|
|
39
|
+
new(turn: turn, report_turn_id: report, queued: queue && !turn.nil?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --wait with no message: --turn's (+requested+), or the turn running
|
|
43
|
+
# now or whose continue offer stands (ReplyWait.own_turn). Settled
|
|
44
|
+
# (idle, a turn ended, no question standing: a continue offer waits
|
|
45
|
+
# for its answer), its last turn's end is the answer (ended_before);
|
|
46
|
+
# with --turn, when that turn is the last one.
|
|
47
|
+
def self.wait_only(session, session_dir:, requested:)
|
|
48
|
+
turn = requested || ReplyWait.own_turn(session, session_dir: session_dir)
|
|
49
|
+
settled = session.status == Session::STATUS_IDLE && !session.last_turn.nil? && session.pending_question.nil?
|
|
50
|
+
ended_before = requested ? session.last_turn&.enqueued_id == requested : settled
|
|
51
|
+
new(turn: turn, report_turn_id: turn || (session.last_turn&.enqueued_id if settled), ended_before: ended_before)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|