samagotchi 0.34.0 → 0.36.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/README.md +2 -0
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/context.md +37 -0
  9. data/docs/guardrails.md +14 -4
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +3 -0
  12. data/docs/plugins.md +29 -3
  13. data/docs/sessions.md +6 -6
  14. data/lib/samagotchi/bridge/card_store.rb +1 -1
  15. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  16. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  17. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  18. data/lib/samagotchi/broadcast/tags.rb +154 -0
  19. data/lib/samagotchi/broadcast_command.rb +228 -0
  20. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  21. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  22. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  31. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  34. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  35. data/lib/samagotchi/children_status.rb +2 -16
  36. data/lib/samagotchi/config.rb +6 -0
  37. data/lib/samagotchi/engine.rb +29 -5
  38. data/lib/samagotchi/git_head.rb +24 -0
  39. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  40. data/lib/samagotchi/guardrails/rules.rb +15 -4
  41. data/lib/samagotchi/guardrails/targets.rb +9 -2
  42. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  43. data/lib/samagotchi/hooks/registry.rb +11 -5
  44. data/lib/samagotchi/log_subscriber.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  46. data/lib/samagotchi/note_command.rb +13 -13
  47. data/lib/samagotchi/note_delivery.rb +42 -0
  48. data/lib/samagotchi/plugin/context.rb +4 -4
  49. data/lib/samagotchi/session_commands.rb +1 -0
  50. data/lib/samagotchi/session_manager.rb +4 -3
  51. data/lib/samagotchi/sessions_command.rb +3 -2
  52. data/lib/samagotchi/system_prompt.rb +1 -0
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  54. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  55. data/lib/samagotchi/terminal_ui.rb +14 -0
  56. data/lib/samagotchi/tool_activity.rb +14 -3
  57. data/lib/samagotchi/tool_declarations.rb +8 -4
  58. data/lib/samagotchi/tools/builtins.rb +1 -1
  59. data/lib/samagotchi/tools/memory.rb +103 -2
  60. data/lib/samagotchi/tools/send_note.rb +5 -11
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +3 -0
  63. data/lib/samagotchi/web/capabilities.rb +19 -0
  64. data/lib/samagotchi/web/public/activity.js +26 -6
  65. data/lib/samagotchi/web/public/app.js +515 -288
  66. data/lib/samagotchi/web/public/card.js +12 -3
  67. data/lib/samagotchi/web/public/command_view.js +2 -3
  68. data/lib/samagotchi/web/public/families_view.js +171 -0
  69. data/lib/samagotchi/web/public/format.js +52 -0
  70. data/lib/samagotchi/web/public/index.html +94 -21
  71. data/lib/samagotchi/web/public/live_timing.js +154 -0
  72. data/lib/samagotchi/web/public/live_turn.js +44 -0
  73. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  74. data/lib/samagotchi/web/public/strip.js +5 -2
  75. data/lib/samagotchi/web/public/turn_html.js +107 -0
  76. data/lib/samagotchi/web/public/turn_model.js +5 -4
  77. data/lib/samagotchi/web/public/turn_view.js +43 -129
  78. data/lib/samagotchi/web/session_summary.rb +4 -0
  79. data/lib/samagotchi/worker.rb +166 -307
  80. data/lib/samagotchi/worker_inbound.rb +251 -0
  81. data/lib/samagotchi/worker_wakes.rb +120 -0
  82. metadata +15 -1
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "securerandom"
5
- require "time"
6
4
 
7
5
  require_relative "client_id"
8
6
  require_relative "config"
@@ -10,9 +8,10 @@ require_relative "events"
10
8
  require_relative "session"
11
9
  require_relative "session_inbox"
12
10
  require_relative "turn_note"
13
- require_relative "context_note"
14
11
  require_relative "steer"
15
12
  require_relative "worker_idle_exit"
13
+ require_relative "worker_wakes"
14
+ require_relative "worker_inbound"
16
15
  require_relative "session_manager"
17
16
  require_relative "archive_store"
18
17
  require_relative "log"
@@ -57,9 +56,6 @@ module Samagotchi
57
56
  # gets it over the Bridge a moment after it starts, and that message's
58
57
  # turn goes first (the reports join it).
59
58
  WAKE_START_GRACE = 2.0
60
- # One context wake per source in this long (D5): later changes inside
61
- # it arrive as notes.
62
- CONTEXT_WAKE_WINDOW = 600
63
59
  # How much of a command's output goes into its :command_ran.
64
60
  COMMAND_OUTPUT_LIMIT = 4096
65
61
  BUSY_OUTPUT = "busy: wait for the turn to end"
@@ -115,14 +111,6 @@ module Samagotchi
115
111
  @asked_parent = false
116
112
  @owes_parent = false
117
113
  @initial_turn = false
118
- # A parent's side: turns run for delegate reports since the last human
119
- # input (session.max_wakes), whether a failed one paused them until
120
- # then, whether the budget notice went out, and the reports a wake
121
- # turn's first boundary hands over.
122
- @wakes_in_a_row = 0
123
- @wakes_paused = false
124
- @budget_noticed = false
125
- @wake_reports = nil
126
114
  end
127
115
 
128
116
  # Whether the session was empty as the worker left it, so the caller
@@ -145,7 +133,32 @@ module Samagotchi
145
133
  # (chi stop), :crashed when the loop raised (the session is marked
146
134
  # errored); SessionManager.run_session_loop turns it into the exit
147
135
  def run
148
- @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ start
137
+ serve
138
+ begin
139
+ # A session stopped before this worker took the lock (e.g. a stop
140
+ # right after create) must not run its initial prompt.
141
+ return :stopped if stopped_on_disk?
142
+
143
+ start_background
144
+ loop do
145
+ leaving = pass
146
+ return leaving if leaving
147
+ end
148
+ rescue StandardError => e
149
+ crashed(e)
150
+ ensure
151
+ teardown
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ # The session, its Engine and what runs its turns, before anyone can
158
+ # reach it.
159
+ def start
160
+ # The wake turns' budget (delegate reports, attached context), from now.
161
+ @wakes = WorkerWakes.new(grace: WAKE_START_GRACE)
149
162
  @session = Session.load(@session_id, state_dir: @state_dir)
150
163
  drop_dead_question
151
164
  @engine = build_engine
@@ -180,6 +193,12 @@ module Samagotchi
180
193
  # Its attached context's notes (ContextSources).
181
194
  @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
182
195
  project_root: @session.project_root)
196
+ # What comes in between turns, and how each kind is saved.
197
+ @inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
198
+ context_absorber: @context_absorber, wakes: @wakes,
199
+ awaiting_continue: -> { @turn_flow.awaiting_continue? },
200
+ stopped: -> { stopped_on_disk? },
201
+ queue_command: ->(line, client_id) { @bridge.queue_command(line, client_id: client_id) })
183
202
  # /model's default is the config's, as in the REPL; the Engine started
184
203
  # on the session's model.
185
204
  @default_model = ModelProfile.required_model_name(nil)
@@ -189,7 +208,11 @@ module Samagotchi
189
208
  # Start the shared idle scheduler so the worker can trigger turns when
190
209
  # reminders are due (even with no user input).
191
210
  @engine.start_idle
211
+ end
192
212
 
213
+ # The Bridge (UIs and clients reach the worker from here on), then the
214
+ # idle-exit policy.
215
+ def serve
193
216
  @bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
194
217
  on_input: -> { @waker.wake },
195
218
  on_command: lambda { |command|
@@ -213,88 +236,106 @@ module Samagotchi
213
236
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
214
237
  running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) }
215
238
  )
239
+ end
216
240
 
217
- begin
218
- # A session stopped before this worker took the lock (e.g. a stop
219
- # right after create) must not run its initial prompt.
220
- return :stopped if stopped_on_disk?
241
+ # What runs beside the loop until it leaves.
242
+ def start_background
243
+ # Plugins' slow setup (chi.init: an MCP server's first start), in
244
+ # the background, shown by the UIs; a turn waits only for the ones
245
+ # that bring tools.
246
+ @engine.start_init_tasks!
247
+ # Attached context's commands, in the background; a new text wakes
248
+ # the loop to absorb it. Not activity: the idle exit stops it.
249
+ @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
250
+ project_root: @session.project_root, cwd: @session.working_directory,
251
+ on_change: -> { @waker.wake }).start
252
+ end
253
+
254
+ # One pass of the loop: the first work found runs (queued commands, a
255
+ # context wake turn, the first prompt, the queued input files) and the
256
+ # next pass looks again; with none, #idle_pass.
257
+ # @return [Symbol, nil] what #run returns as the worker leaves, nil to go on
258
+ def pass
259
+ # Check if the session was externally marked as stopped. Not
260
+ # stopped_on_disk?, which reads a vanished file as "not stopped":
261
+ # a session file deleted under a running worker crashes it here
262
+ # instead of being saved back by its next turn.
263
+ return :stopped if Session.load(@session_id, state_dir: @state_dir).status == Session::STATUS_STOPPED
264
+
265
+ # Commands queued before a prompt run first (a /continue sent
266
+ # before a new prompt still answers the offer).
267
+ return if run_queued_commands
268
+
269
+ # Between turns, so the next turn (the first one too) sees them.
270
+ # A change that asks to wake may start a turn here (C4), but not
271
+ # ahead of an exit or restart a client asked for: it is a note then.
272
+ @inbound.absorb_notes
273
+ return if absorb_context(wake: nothing_queued? && !@exit_requested)
274
+
275
+ if (prompt = @inbound.take_initial_prompt)
276
+ run_initial_prompt(prompt) unless @inbound.initial_command?(prompt)
277
+ return
278
+ end
221
279
 
222
- # Plugins' slow setup (chi.init: an MCP server's first start), in
223
- # the background, shown by the UIs; a turn waits only for the ones
224
- # that bring tools.
225
- @engine.start_init_tasks!
226
- # Attached context's commands, in the background; a new text wakes
227
- # the loop to absorb it. Not activity: the idle exit stops it.
228
- @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
229
- project_root: @session.project_root, cwd: @session.working_directory,
230
- on_change: -> { @waker.wake }).start
231
- loop do
232
- # Check if the session was externally marked as stopped. Not
233
- # stopped_on_disk?, which reads a vanished file as "not stopped":
234
- # a session file deleted under a running worker crashes it here
235
- # instead of being saved back by its next turn.
236
- return :stopped if Session.load(@session_id, state_dir: @state_dir).status == Session::STATUS_STOPPED
237
-
238
- # Commands queued before a prompt run first (a /continue sent
239
- # before a new prompt still answers the offer).
240
- next if run_queued_commands
241
-
242
- # Between turns, so the next turn (the first one too) sees them.
243
- # A change that asks to wake may start a turn here (C4), but not
244
- # ahead of an exit or restart a client asked for: it is a note then.
245
- absorb_notes
246
- next if absorb_context(wake: nothing_queued? && !@exit_requested)
247
-
248
- if (prompt = take_initial_prompt)
249
- run_initial_prompt(prompt) unless initial_command(prompt)
250
- next
251
- end
280
+ input_files = SessionInbox.find_new_input_files(@session_dir)
281
+ return idle_pass if input_files.empty?
252
282
 
253
- input_files = SessionInbox.find_new_input_files(@session_dir)
254
- if input_files.empty?
255
- next if run_wake_turn
256
- next if run_due_reminders
257
- return left(:idle_exit) if @idle_exit.due? && leave_idle
258
- return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
283
+ run_input_files(input_files)
284
+ end
259
285
 
260
- @waker.wait(idle_wait)
261
- next
262
- end
286
+ # Nothing queued: a delegate wake turn or a due reminder runs, else
287
+ # the worker leaves when the idle exit or a client's exit is due, else
288
+ # it sleeps until woken or the fallback tick.
289
+ # @return [Symbol, nil] as #pass
290
+ def idle_pass
291
+ return if run_wake_turn
292
+ return if run_due_reminders
293
+ return left(:idle_exit) if @idle_exit.due? && leave_idle
294
+ return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
263
295
 
264
- input_files.sort.each do |input_file|
265
- # A stop between two queued turns leaves the rest queued.
266
- break if stopped_on_disk?
296
+ @waker.wait(idle_wait)
297
+ nil
298
+ end
267
299
 
268
- absorb_notes
269
- run_input_file(input_file)
270
- end
271
- end
272
- rescue StandardError => e
273
- # Its stderr is /dev/null: the log is the only trace of why.
274
- Log.exception(:worker, "crashed", e)
275
- Session.mark_error(@session_id, reason: e.message, state_dir: @state_dir)
276
- # A delegate's parent hears of it (best effort).
277
- ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
278
- :crashed
279
- ensure
280
- # The anytime commands finish and the plugins' services stop (a
281
- # server process), whatever the way out; the Bridge last, so a
282
- # command's command_ran still reaches its UI on a crash. A
283
- # step-limit question stays in the file (a save here would write
284
- # over a stop's status): with no live worker the lists don't read
285
- # it as waiting, and the next worker drops it (drop_dead_question).
286
- # The context poller first: a command it runs goes (its process
287
- # group) before the Engine does.
288
- @context_poller&.stop
289
- @engine&.shutdown
290
- @bridge&.stop
291
- # The process exits next: a parent wake it rang for (a crash's
292
- # ring comes just above) must spawn the parent's worker first.
293
- ChildRing.await_wakes
300
+ # @return [nil]
301
+ def run_input_files(input_files)
302
+ input_files.sort.each do |input_file|
303
+ # A stop between two queued turns leaves the rest queued.
304
+ break if stopped_on_disk?
305
+
306
+ @inbound.absorb_notes
307
+ run_input_file(input_file)
294
308
  end
309
+ nil
295
310
  end
296
311
 
297
- private
312
+ # An error outside a turn: the session is marked errored.
313
+ # @return [Symbol] :crashed
314
+ def crashed(error)
315
+ # Its stderr is /dev/null: the log is the only trace of why.
316
+ Log.exception(:worker, "crashed", error)
317
+ Session.mark_error(@session_id, reason: error.message, state_dir: @state_dir)
318
+ # A delegate's parent hears of it (best effort).
319
+ ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
320
+ :crashed
321
+ end
322
+
323
+ # The anytime commands finish and the plugins' services stop (a
324
+ # server process), whatever the way out; the Bridge last, so a
325
+ # command's command_ran still reaches its UI on a crash. A
326
+ # step-limit question stays in the file (a save here would write
327
+ # over a stop's status): with no live worker the lists don't read
328
+ # it as waiting, and the next worker drops it (drop_dead_question).
329
+ # The context poller first: a command it runs goes (its process
330
+ # group) before the Engine does.
331
+ def teardown
332
+ @context_poller&.stop
333
+ @engine&.shutdown
334
+ @bridge&.stop
335
+ # The process exits next: a parent wake it rang for (a crash's
336
+ # ring comes just above) must spawn the parent's worker first.
337
+ ChildRing.await_wakes
338
+ end
298
339
 
299
340
  def build_engine
300
341
  waker = @waker
@@ -336,100 +377,15 @@ module Samagotchi
336
377
  save_or_log(:dead_question) { @session.save(state_dir: @state_dir) }
337
378
  end
338
379
 
339
- # spawn_session hands the first prompt over in last_prompt, but
340
- # last_prompt also records every later turn's prompt (and mark_error's
341
- # reason), so only a session with no conversation yet has one pending; a
342
- # resumed session must not replay its last turn. Taken once.
343
- # @return [String, nil]
344
- def take_initial_prompt
345
- return nil if @initial_prompt_taken
346
-
347
- @initial_prompt_taken = true
348
- # A context note may have come before the first prompt ran.
349
- return nil unless @session.messages.all? { |m| ContextNote.note?(m) } && !@session.last_prompt.to_s.strip.empty?
350
-
351
- prompt = @session.last_prompt
352
- @session.last_prompt = ""
353
- save_or_log(:initial_prompt) { @session.save(state_dir: @state_dir) }
354
- prompt
355
- end
356
-
357
- # A session command sent as a message (chi send -m "/model x", a web
358
- # page's first message) runs as the command, as the Bridge does for a
359
- # POST /turn; an unknown /word stays a prompt. Queued: the loop's next
360
- # pass runs it.
361
- # @return [Boolean] whether +text+ was one
362
- def queue_as_command(text, origin)
363
- return false unless @engine.command_registry.command?(text.to_s)
364
-
365
- @bridge.queue_command(text, client_id: origin&.dig(:client_id))
366
- true
367
- end
368
-
369
- # The first prompt as a command: the session was saved as running for a
370
- # turn that won't run.
371
- # @return [Boolean] whether it was one
372
- def initial_command(prompt)
373
- return false unless queue_as_command(prompt, nil)
374
-
375
- @session.status = Session::STATUS_IDLE
376
- save_or_log(:initial_command) { save_session }
377
- true
378
- end
379
-
380
- # Add the queued context notes to the conversation (between turns only,
381
- # on this thread), save, then delete their files: a crash before the
382
- # delete leaves them claimed, and Engine#add_context_note skips a note
383
- # the saved conversation already holds. A failed save keeps the files
384
- # too: the next pass claims them again, finds them in the conversation
385
- # and saves again. Not activity: a note alone
386
- # neither starts a turn nor keeps an idle worker up.
387
- def absorb_notes
388
- files = SessionInbox.find_new_note_files(@session_dir)
389
- return if files.empty? || stopped_on_disk?
390
-
391
- claimed = files.filter_map { |file| SessionInbox.claim_note_file(file) }
392
- claimed.each do |file|
393
- note = SessionInbox.read_note(file)
394
- @engine.add_context_note(@session, note) if note
395
- end
396
- return unless save_or_log(:notes) { @session.save(state_dir: @state_dir) }
397
-
398
- claimed.each { |file| FileUtils.rm_f(file) }
399
- end
400
-
401
- # The attached context's notes (ContextAbsorber), between turns. Not
402
- # into a session with no turn yet (+before_turn+: one is about to
403
- # run): auto-attached context alone doesn't make a session worth
404
- # keeping. Saved, then the subscriptions written (a crash between the
405
- # two re-delivers; the note ids dedupe).
406
- # +wake+: the session is idle with nothing queued, so an update whose
407
- # source asked to wake may start a turn (#context_wake_for): its note
408
- # goes in as the turn's first message.
380
+ # The attached context's notes (WorkerInbound#absorb_context), and the
381
+ # context wake turn one may start. A failure to read or write them (or
382
+ # in that turn) is logged: the next pass tries again.
409
383
  # @return [Boolean] whether a wake turn ran
410
384
  def absorb_context(before_turn: false, wake: false)
411
- return false unless before_turn || conversation_started?
412
- return false if stopped_on_disk?
413
-
414
- batch = @context_absorber.pending
415
- return false unless batch
385
+ context_wake = @inbound.absorb_context(before_turn: before_turn, wake: wake)
386
+ return false unless context_wake
416
387
 
417
- waking = wake ? context_wake_for(batch) : nil
418
- turn_id = SecureRandom.uuid if waking
419
- batch.deliveries.each do |delivery|
420
- next unless delivery.note
421
-
422
- note = delivery == waking ? delivery.wake_note.merge(turn_start: true, turn_id: turn_id) : delivery.note
423
- @engine.add_context_note(@session, note)
424
- end
425
- # Saved even when every note was there already: after a failed save
426
- # they are in memory only.
427
- return false if batch.notes.any? && !save_or_log(:context) { @session.save(state_dir: @state_dir) }
428
-
429
- @context_absorber.commit(waking ? woken(batch, waking) : batch)
430
- return false unless waking
431
-
432
- run_context_wake_turn(waking, turn_id)
388
+ run_context_wake_turn(context_wake)
433
389
  true
434
390
  rescue SystemCallError, IOError => e
435
391
  Log.exception(:worker, "context_failed", e)
@@ -439,123 +395,59 @@ module Samagotchi
439
395
  # No input file or queued command waits: the loop would go idle.
440
396
  def nothing_queued? = @command_queue.empty? && SessionInbox.find_new_input_files(@session_dir).empty?
441
397
 
442
- # The update in +batch+ that may start a wake turn now: its source
443
- # asked (wake: true), context.wake is on, the shared wake step's
444
- # conditions hold (no continue offer, wakes not paused by a failed one,
445
- # under session.max_wakes in a row with no human input, past the start
446
- # grace) and the source hasn't woken the session in the last
447
- # CONTEXT_WAKE_WINDOW. The others stay plain notes. Within the start
448
- # grace a change is a note: it came while the session was away.
449
- def context_wake_for(batch, now: Time.now)
450
- candidates = batch.deliveries.select(&:wake_note)
451
- return nil if candidates.empty? || !context_wakes_on?
452
- return nil if @wakes_paused || @turn_flow.awaiting_continue? || wake_grace_left.positive?
453
-
454
- if @wakes_in_a_row >= max_wakes
455
- Log.info(:worker, "context_wake_held", reason: "max_wakes", names: candidates.map(&:name).join(","))
456
- return nil
457
- end
458
- candidates.find { |delivery| !woke_lately?(delivery.subscription, now) }
459
- end
460
-
461
- def context_wakes_on? = Config.get("context.wake") != false
462
-
463
- def woke_lately?(subscription, now)
464
- at = subscription&.wakes_at && Time.iso8601(subscription.wakes_at)
465
- at ? now - at < CONTEXT_WAKE_WINDOW : false
466
- rescue ArgumentError
467
- false
468
- end
469
-
470
- # +batch+ with +waking+'s subscription recording the wake (the 10-minute window).
471
- def woken(batch, waking)
472
- stamped = waking.with(subscription: waking.subscription.with(wakes_at: Time.now.iso8601))
473
- batch.with(deliveries: batch.deliveries.map { |delivery| delivery == waking ? stamped : delivery })
474
- end
475
-
476
398
  # A turn nobody typed for a changed source (a context wake): a continue
477
399
  # turn from context:<name> whose first message is the wake note already
478
400
  # in the conversation (named by +turn_id+), on the same rules as a
479
401
  # delegate report's wake turn: it counts toward session.max_wakes, and
480
402
  # a failure goes back to before the turn (the note stays, as a plain
481
- # one: #unmark_wake_note) and pauses wakes until a human's input.
482
- # +waking+: the ContextAbsorber::Delivery that woke it.
483
- def run_context_wake_turn(waking, turn_id)
484
- name = waking.name
485
- @wakes_in_a_row += 1
486
- Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes_in_a_row)
403
+ # one: WorkerInbound#unmark_wake) and pauses wakes until a human's input.
404
+ # +context_wake+: a WorkerInbound::ContextWake.
405
+ def run_context_wake_turn(context_wake)
406
+ name = context_wake.name
407
+ Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!)
487
408
  @turn_flow.before_prompt_turn
488
- run_engine_turn(nil, continue: true, origin: { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" },
489
- id: turn_id, max_iterations: IterationLimit.for) do |result, error|
409
+ run_engine_turn(nil, continue: true, origin: context_wake.origin,
410
+ id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
490
411
  if error
491
412
  note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
492
413
  wake: "the change in attached context #{name}")
493
414
  @turn_flow.prompt_turn_failed(note: note)
494
- unmark_wake_note(turn_id, waking.note)
495
- @wakes_paused = true
415
+ @inbound.unmark_wake(context_wake)
416
+ @wakes.pause!
496
417
  else
497
418
  @continue_offer.after_turn(result)
498
419
  end
499
420
  end
500
421
  end
501
422
 
502
- # A failed wake turn kept nothing but its note: the update goes back to
503
- # a plain note (no turn start, the background wording), so a reload
504
- # draws no empty turn for it.
505
- def unmark_wake_note(turn_id, note)
506
- messages = @engine.messages_checkpoint
507
- index = messages.index { |m| m[:turn_start] && m[:turn_id] == turn_id }
508
- return unless index
509
-
510
- messages[index] = ContextNote.message(**note)
511
- @engine.rollback_to(messages)
512
- end
513
-
514
- # Whether the session has had a turn: a user or assistant message.
515
- def conversation_started?
516
- Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
517
- end
518
-
519
423
  # The first prompt (spawn_session's): a delegate child's task, which is
520
424
  # its parent's although no client sent it.
521
425
  def run_initial_prompt(prompt)
522
426
  @initial_turn = true
523
- run_prompt(prompt, nil)
427
+ run_prompt(WorkerInbound::Prompt.new(text: prompt))
524
428
  ensure
525
429
  @initial_turn = false
526
430
  end
527
431
 
528
432
  def run_input_file(input_file)
529
- claimed_file = SessionInbox.claim_input_file(input_file)
530
- return unless claimed_file
531
-
532
- begin
533
- message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
534
- return if message.to_s.strip.empty?
535
- return if Array(images).empty? && queue_as_command(message, origin)
536
-
537
- run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || [])
538
- ensure
539
- FileUtils.rm_f(claimed_file)
540
- end
433
+ @inbound.take_input(input_file) { |prompt| run_prompt(prompt) }
541
434
  end
542
435
 
543
- # @param no_interrupt [Boolean] an offer this turn makes keeps it for
544
- # its continue turn
545
- # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
546
- def run_prompt(prompt, origin, no_interrupt: false, images: [])
436
+ # @param prompt [WorkerInbound::Prompt]
437
+ def run_prompt(prompt)
438
+ origin = prompt.origin
547
439
  # A first turn sees the context attached before it.
548
440
  absorb_context(before_turn: true)
549
441
  user_input(origin&.dig(:client_id))
550
442
  @continue_offer.drop(origin)
551
443
  @turn_flow.before_prompt_turn
552
- run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
553
- images: images) do |result, error|
444
+ run_engine_turn(prompt.text, origin: origin, max_iterations: max_iterations(prompt.no_interrupt),
445
+ images: prompt.images) do |result, error|
554
446
  if error
555
447
  # The Engine announced :turn_failed (with the error's one line).
556
- restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: error)
448
+ restore_failed_turn([[prompt.text, origin, prompt.images], *@merged_this_turn], error: error)
557
449
  else
558
- @continue_offer.after_turn(result, no_interrupt: no_interrupt)
450
+ @continue_offer.after_turn(result, no_interrupt: prompt.no_interrupt)
559
451
  end
560
452
  end
561
453
  end
@@ -587,10 +479,8 @@ module Samagotchi
587
479
  # the budget stops it; nil otherwise.
588
480
  def wake_state
589
481
  return nil unless @child_reports && ChildRing.mode == "wake"
590
- return nil if @wakes_paused || @turn_flow&.awaiting_continue?
591
- return nil unless @child_reports.waiting?
592
482
 
593
- @wakes_in_a_row < max_wakes ? :due : :budget
483
+ @wakes.delegate_state(awaiting_continue: @turn_flow&.awaiting_continue?) { @child_reports.waiting? }
594
484
  end
595
485
 
596
486
  # Run a turn nobody typed for the reports the rings bring (a wake turn):
@@ -600,7 +490,7 @@ module Samagotchi
600
490
  # doesn't loop).
601
491
  # @return [Boolean] whether a wake turn ran
602
492
  def run_wake_turn
603
- return false if wake_grace_left.positive?
493
+ return false if @wakes.in_grace?
604
494
 
605
495
  state = wake_state
606
496
  budget_notice if state == :budget
@@ -611,9 +501,8 @@ module Samagotchi
611
501
  # exact list at its first boundary.
612
502
  return false if reports.empty?
613
503
 
614
- @wake_reports = reports
615
- @wakes_in_a_row += 1
616
- Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes_in_a_row)
504
+ @wakes.hand_over(reports)
505
+ Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes.count!)
617
506
  @turn_flow.before_prompt_turn
618
507
  run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
619
508
  if error
@@ -623,44 +512,29 @@ module Samagotchi
623
512
  kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
624
513
  "again with the user's next message.")
625
514
  @turn_flow.prompt_turn_failed(note: note)
626
- @wakes_paused = true
515
+ @wakes.pause!
627
516
  else
628
517
  @continue_offer.after_turn(result)
629
518
  end
630
519
  end
631
520
  true
632
521
  ensure
633
- @wake_reports = nil
634
- end
635
-
636
- def wake_grace_left
637
- WAKE_START_GRACE - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
522
+ @wakes.drop_handed
638
523
  end
639
524
 
640
525
  # The idle loop's sleep: the fallback tick, or less while reports wait
641
526
  # out the start grace.
642
- def idle_wait
643
- left = wake_grace_left
644
- left.positive? && wake_state == :due ? [left, @poll_interval].min : @poll_interval
645
- end
646
-
647
- def max_wakes
648
- value = Integer(Config.get(ChildRing::MAX_WAKES_KEY), exception: false)
649
- value&.positive? ? value : ChildRing::MAX_WAKES_DEFAULT
650
- rescue StandardError
651
- ChildRing::MAX_WAKES_DEFAULT
652
- end
527
+ def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
653
528
 
654
529
  # The wake budget is spent: the parent's UIs hear once that reports
655
530
  # wait for the next message.
656
531
  def budget_notice
657
- return if @budget_noticed
532
+ return unless @wakes.notice_budget!
658
533
 
659
- @budget_noticed = true
660
534
  count = SessionInbox.find_ring_files(@session_dir).map { |f| SessionInbox.read_ring(f)&.dig(:child_id) }.uniq.size
661
535
  @engine.announce(type: :hook_notice, hook: "delegate", level: :info, between_turns: true,
662
536
  text: "#{count} delegate report#{"s" if count != 1} waiting; #{count == 1 ? "it joins" : "they join"} " \
663
- "your next message (#{max_wakes} turn#{"s" if max_wakes != 1} ran for reports in a row, #{ChildRing::MAX_WAKES_KEY})")
537
+ "your next message (#{@wakes.max} turn#{"s" if @wakes.max != 1} ran for reports in a row, #{ChildRing::MAX_WAKES_KEY})")
664
538
  end
665
539
 
666
540
  def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
@@ -849,19 +723,8 @@ module Samagotchi
849
723
  @asked_parent = true
850
724
  end
851
725
 
852
- # A save between turns (the turn's own, a command's, the notes', the
853
- # first prompt's): what it saves is in memory, so one that fails (disk
854
- # full, permissions; the next save writes it) is logged and must not
855
- # take the worker down with it.
856
- # @param at [Symbol] which save, for the log
857
- # @return [Boolean] whether it saved
858
- def save_or_log(at)
859
- yield
860
- true
861
- rescue SystemCallError, IOError => e
862
- Log.exception(:worker, "save_failed", e, at: at)
863
- false
864
- end
726
+ # WorkerInbound.save_or_log: a failed save between turns is logged.
727
+ def save_or_log(at, &) = WorkerInbound.save_or_log(at, &)
865
728
 
866
729
  # Back to the conversation before the failed turn, as the REPL does (so
867
730
  # failed prompts don't pile up as consecutive user messages), and each
@@ -942,9 +805,7 @@ module Samagotchi
942
805
  # @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
943
806
  # and the mark of their message
944
807
  def take_child_reports
945
- if @wake_reports
946
- reports = @wake_reports
947
- @wake_reports = nil
808
+ if (reports = @wakes.take_handed)
948
809
  return [reports, { turn_start: true, turn_id: @turn_id }.compact]
949
810
  end
950
811
 
@@ -1057,9 +918,7 @@ module Samagotchi
1057
918
  return unless ClientId.human?(client_id)
1058
919
 
1059
920
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1060
- @wakes_in_a_row = 0
1061
- @wakes_paused = false
1062
- @budget_noticed = false
921
+ @wakes.human_input!
1063
922
  end
1064
923
 
1065
924
  def stopped_on_disk?