samagotchi 0.33.0 → 0.35.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +6 -1
  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 +5 -0
  9. data/docs/guardrails.md +35 -3
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +17 -1
  12. data/docs/plugins.md +78 -3
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +15 -9
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  18. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  19. data/lib/samagotchi/broadcast/tags.rb +154 -0
  20. data/lib/samagotchi/broadcast_command.rb +228 -0
  21. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  22. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  23. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -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/delegated.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  35. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  36. data/lib/samagotchi/children_status.rb +143 -0
  37. data/lib/samagotchi/config.rb +6 -0
  38. data/lib/samagotchi/cut_policy.rb +58 -0
  39. data/lib/samagotchi/engine.rb +45 -108
  40. data/lib/samagotchi/engine_peers.rb +30 -0
  41. data/lib/samagotchi/git_head.rb +24 -0
  42. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  43. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  44. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  45. data/lib/samagotchi/guardrails/context.rb +32 -0
  46. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  47. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +11 -5
  50. data/lib/samagotchi/kernel_loop.rb +9 -22
  51. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  52. data/lib/samagotchi/log_subscriber.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/builder.rb +14 -1
  54. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  55. data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
  56. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  57. data/lib/samagotchi/memory_paths.rb +10 -0
  58. data/lib/samagotchi/note_command.rb +13 -13
  59. data/lib/samagotchi/note_delivery.rb +42 -0
  60. data/lib/samagotchi/plugin/api.rb +2 -2
  61. data/lib/samagotchi/plugin/context.rb +4 -4
  62. data/lib/samagotchi/plugin/sessions.rb +45 -1
  63. data/lib/samagotchi/process_group.rb +2 -5
  64. data/lib/samagotchi/session_manager.rb +4 -3
  65. data/lib/samagotchi/sessions_command.rb +3 -2
  66. data/lib/samagotchi/steer_cut.rb +125 -0
  67. data/lib/samagotchi/system_prompt.rb +21 -8
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  69. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  70. data/lib/samagotchi/terminal_ui.rb +14 -0
  71. data/lib/samagotchi/tool_declarations.rb +9 -5
  72. data/lib/samagotchi/tools/builtins.rb +2 -2
  73. data/lib/samagotchi/tools/delegate.rb +103 -14
  74. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  75. data/lib/samagotchi/tools/send_note.rb +5 -11
  76. data/lib/samagotchi/turn_note.rb +9 -1
  77. data/lib/samagotchi/version.rb +1 -1
  78. data/lib/samagotchi/web/app.rb +3 -0
  79. data/lib/samagotchi/web/capabilities.rb +19 -0
  80. data/lib/samagotchi/web/public/activity.js +26 -6
  81. data/lib/samagotchi/web/public/app.js +515 -288
  82. data/lib/samagotchi/web/public/card.js +12 -3
  83. data/lib/samagotchi/web/public/command_view.js +2 -3
  84. data/lib/samagotchi/web/public/families_view.js +171 -0
  85. data/lib/samagotchi/web/public/format.js +52 -0
  86. data/lib/samagotchi/web/public/index.html +94 -21
  87. data/lib/samagotchi/web/public/live_timing.js +154 -0
  88. data/lib/samagotchi/web/public/live_turn.js +44 -0
  89. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  90. data/lib/samagotchi/web/public/strip.js +5 -2
  91. data/lib/samagotchi/web/public/turn_html.js +107 -0
  92. data/lib/samagotchi/web/public/turn_model.js +5 -4
  93. data/lib/samagotchi/web/public/turn_view.js +43 -129
  94. data/lib/samagotchi/web/session_summary.rb +4 -0
  95. data/lib/samagotchi/worker.rb +173 -309
  96. data/lib/samagotchi/worker_inbound.rb +251 -0
  97. data/lib/samagotchi/worker_wakes.rb +120 -0
  98. metadata +24 -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,122 +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
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
412
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
413
+ wake: "the change in attached context #{name}")
492
414
  @turn_flow.prompt_turn_failed(note: note)
493
- unmark_wake_note(turn_id, waking.note)
494
- @wakes_paused = true
415
+ @inbound.unmark_wake(context_wake)
416
+ @wakes.pause!
495
417
  else
496
418
  @continue_offer.after_turn(result)
497
419
  end
498
420
  end
499
421
  end
500
422
 
501
- # A failed wake turn kept nothing but its note: the update goes back to
502
- # a plain note (no turn start, the background wording), so a reload
503
- # draws no empty turn for it.
504
- def unmark_wake_note(turn_id, note)
505
- messages = @engine.messages_checkpoint
506
- index = messages.index { |m| m[:turn_start] && m[:turn_id] == turn_id }
507
- return unless index
508
-
509
- messages[index] = ContextNote.message(**note)
510
- @engine.rollback_to(messages)
511
- end
512
-
513
- # Whether the session has had a turn: a user or assistant message.
514
- def conversation_started?
515
- Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
516
- end
517
-
518
423
  # The first prompt (spawn_session's): a delegate child's task, which is
519
424
  # its parent's although no client sent it.
520
425
  def run_initial_prompt(prompt)
521
426
  @initial_turn = true
522
- run_prompt(prompt, nil)
427
+ run_prompt(WorkerInbound::Prompt.new(text: prompt))
523
428
  ensure
524
429
  @initial_turn = false
525
430
  end
526
431
 
527
432
  def run_input_file(input_file)
528
- claimed_file = SessionInbox.claim_input_file(input_file)
529
- return unless claimed_file
530
-
531
- begin
532
- message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
533
- return if message.to_s.strip.empty?
534
- return if Array(images).empty? && queue_as_command(message, origin)
535
-
536
- run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || [])
537
- ensure
538
- FileUtils.rm_f(claimed_file)
539
- end
433
+ @inbound.take_input(input_file) { |prompt| run_prompt(prompt) }
540
434
  end
541
435
 
542
- # @param no_interrupt [Boolean] an offer this turn makes keeps it for
543
- # its continue turn
544
- # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
545
- def run_prompt(prompt, origin, no_interrupt: false, images: [])
436
+ # @param prompt [WorkerInbound::Prompt]
437
+ def run_prompt(prompt)
438
+ origin = prompt.origin
546
439
  # A first turn sees the context attached before it.
547
440
  absorb_context(before_turn: true)
548
441
  user_input(origin&.dig(:client_id))
549
442
  @continue_offer.drop(origin)
550
443
  @turn_flow.before_prompt_turn
551
- run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
552
- 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|
553
446
  if error
554
447
  # The Engine announced :turn_failed (with the error's one line).
555
- 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)
556
449
  else
557
- @continue_offer.after_turn(result, no_interrupt: no_interrupt)
450
+ @continue_offer.after_turn(result, no_interrupt: prompt.no_interrupt)
558
451
  end
559
452
  end
560
453
  end
@@ -586,10 +479,8 @@ module Samagotchi
586
479
  # the budget stops it; nil otherwise.
587
480
  def wake_state
588
481
  return nil unless @child_reports && ChildRing.mode == "wake"
589
- return nil if @wakes_paused || @turn_flow&.awaiting_continue?
590
- return nil unless @child_reports.waiting?
591
482
 
592
- @wakes_in_a_row < max_wakes ? :due : :budget
483
+ @wakes.delegate_state(awaiting_continue: @turn_flow&.awaiting_continue?) { @child_reports.waiting? }
593
484
  end
594
485
 
595
486
  # Run a turn nobody typed for the reports the rings bring (a wake turn):
@@ -599,7 +490,7 @@ module Samagotchi
599
490
  # doesn't loop).
600
491
  # @return [Boolean] whether a wake turn ran
601
492
  def run_wake_turn
602
- return false if wake_grace_left.positive?
493
+ return false if @wakes.in_grace?
603
494
 
604
495
  state = wake_state
605
496
  budget_notice if state == :budget
@@ -610,52 +501,40 @@ module Samagotchi
610
501
  # exact list at its first boundary.
611
502
  return false if reports.empty?
612
503
 
613
- @wake_reports = reports
614
- @wakes_in_a_row += 1
615
- 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!)
616
506
  @turn_flow.before_prompt_turn
617
507
  run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
618
508
  if error
619
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
509
+ # The rings stay (settle_child_reports): the user's next turn takes them.
510
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
511
+ wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
512
+ kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
513
+ "again with the user's next message.")
620
514
  @turn_flow.prompt_turn_failed(note: note)
621
- @wakes_paused = true
515
+ @wakes.pause!
622
516
  else
623
517
  @continue_offer.after_turn(result)
624
518
  end
625
519
  end
626
520
  true
627
521
  ensure
628
- @wake_reports = nil
629
- end
630
-
631
- def wake_grace_left
632
- WAKE_START_GRACE - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
522
+ @wakes.drop_handed
633
523
  end
634
524
 
635
525
  # The idle loop's sleep: the fallback tick, or less while reports wait
636
526
  # out the start grace.
637
- def idle_wait
638
- left = wake_grace_left
639
- left.positive? && wake_state == :due ? [left, @poll_interval].min : @poll_interval
640
- end
641
-
642
- def max_wakes
643
- value = Integer(Config.get(ChildRing::MAX_WAKES_KEY), exception: false)
644
- value&.positive? ? value : ChildRing::MAX_WAKES_DEFAULT
645
- rescue StandardError
646
- ChildRing::MAX_WAKES_DEFAULT
647
- end
527
+ def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
648
528
 
649
529
  # The wake budget is spent: the parent's UIs hear once that reports
650
530
  # wait for the next message.
651
531
  def budget_notice
652
- return if @budget_noticed
532
+ return unless @wakes.notice_budget!
653
533
 
654
- @budget_noticed = true
655
534
  count = SessionInbox.find_ring_files(@session_dir).map { |f| SessionInbox.read_ring(f)&.dig(:child_id) }.uniq.size
656
535
  @engine.announce(type: :hook_notice, hook: "delegate", level: :info, between_turns: true,
657
536
  text: "#{count} delegate report#{"s" if count != 1} waiting; #{count == 1 ? "it joins" : "they join"} " \
658
- "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})")
659
538
  end
660
539
 
661
540
  def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
@@ -844,19 +723,8 @@ module Samagotchi
844
723
  @asked_parent = true
845
724
  end
846
725
 
847
- # A save between turns (the turn's own, a command's, the notes', the
848
- # first prompt's): what it saves is in memory, so one that fails (disk
849
- # full, permissions; the next save writes it) is logged and must not
850
- # take the worker down with it.
851
- # @param at [Symbol] which save, for the log
852
- # @return [Boolean] whether it saved
853
- def save_or_log(at)
854
- yield
855
- true
856
- rescue SystemCallError, IOError => e
857
- Log.exception(:worker, "save_failed", e, at: at)
858
- false
859
- end
726
+ # WorkerInbound.save_or_log: a failed save between turns is logged.
727
+ def save_or_log(at, &) = WorkerInbound.save_or_log(at, &)
860
728
 
861
729
  # Back to the conversation before the failed turn, as the REPL does (so
862
730
  # failed prompts don't pile up as consecutive user messages), and each
@@ -937,9 +805,7 @@ module Samagotchi
937
805
  # @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
938
806
  # and the mark of their message
939
807
  def take_child_reports
940
- if @wake_reports
941
- reports = @wake_reports
942
- @wake_reports = nil
808
+ if (reports = @wakes.take_handed)
943
809
  return [reports, { turn_start: true, turn_id: @turn_id }.compact]
944
810
  end
945
811
 
@@ -1052,9 +918,7 @@ module Samagotchi
1052
918
  return unless ClientId.human?(client_id)
1053
919
 
1054
920
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1055
- @wakes_in_a_row = 0
1056
- @wakes_paused = false
1057
- @budget_noticed = false
921
+ @wakes.human_input!
1058
922
  end
1059
923
 
1060
924
  def stopped_on_disk?