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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. 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 [Hash, nil] {messages:, question_id:} as the session
74
- # was before the message went in. With it, a turn that grew the
75
- # messages (a failed, canceled or empty one leaves its note) and went
76
- # idle again ends the wait even if it was never seen running, and a
77
- # question already pending then is not the answer's. A nil
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
- deadline = timeout && (monotonic + timeout.to_f)
117
- seen_running = false
118
- gone_since = nil
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 [Hash]
148
+ # @return [Baseline]
277
149
  def baseline_of(session, question_id: session.pending_question&.id)
278
- { 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)
279
151
  end
280
152
 
281
- # An idle session whose turn ended after the baseline was taken: its
282
- # last_turn moved on. Every turn's end records last_turn as it goes
283
- # idle (Engine#end_turn), so with one in the baseline that alone
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, or
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&.key?(:last_turn)
215
+ return true unless baseline
357
216
 
358
- session.last_turn&.ended_at != baseline[:last_turn]
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
- baseline&.key?(:last_turn) ? turn_ended_since?(session, baseline) : false
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? || !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?
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