samagotchi 0.62.0 → 0.64.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +12 -2
  6. data/docs/cli.md +20 -9
  7. data/docs/configuration.md +16 -9
  8. data/docs/context.md +13 -3
  9. data/docs/hooks.md +6 -3
  10. data/docs/internals/engine.md +10 -3
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +96 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +11 -8
  15. data/docs/sub-agent.md +27 -4
  16. data/docs/testing.md +13 -4
  17. data/lib/samagotchi/answer_command.rb +5 -1
  18. data/lib/samagotchi/answer_refs.rb +18 -26
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +41 -5
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  29. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  34. data/lib/samagotchi/child_reports.rb +7 -2
  35. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  36. data/lib/samagotchi/composer_route.rb +90 -0
  37. data/lib/samagotchi/config.rb +6 -3
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/continue_offer.rb +17 -3
  40. data/lib/samagotchi/delivery.rb +13 -8
  41. data/lib/samagotchi/engine.rb +2 -0
  42. data/lib/samagotchi/host_config.rb +6 -6
  43. data/lib/samagotchi/host_model.rb +2 -2
  44. data/lib/samagotchi/idle_recap.rb +2 -3
  45. data/lib/samagotchi/image_store.rb +8 -0
  46. data/lib/samagotchi/interrupted_turn.rb +7 -3
  47. data/lib/samagotchi/last_turn.rb +5 -2
  48. data/lib/samagotchi/pending_input_queue.rb +10 -3
  49. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  50. data/lib/samagotchi/plugin/sessions.rb +27 -5
  51. data/lib/samagotchi/recap_setup.rb +3 -3
  52. data/lib/samagotchi/reply_wait.rb +171 -6
  53. data/lib/samagotchi/row_refs.rb +3 -4
  54. data/lib/samagotchi/send_command.rb +63 -19
  55. data/lib/samagotchi/session.rb +12 -0
  56. data/lib/samagotchi/session_commands.rb +1 -1
  57. data/lib/samagotchi/session_inbox.rb +63 -7
  58. data/lib/samagotchi/session_manager.rb +49 -20
  59. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
  61. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  62. data/lib/samagotchi/terminal_ui.rb +103 -68
  63. data/lib/samagotchi/tool_activity.rb +5 -5
  64. data/lib/samagotchi/tools/delegate.rb +28 -1
  65. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  66. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  67. data/lib/samagotchi/tools/execute.rb +33 -8
  68. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +6 -4
  71. data/lib/samagotchi/web/public/activity.js +2 -2
  72. data/lib/samagotchi/web/public/app.js +53 -91
  73. data/lib/samagotchi/web/public/card.js +73 -23
  74. data/lib/samagotchi/web/public/chains.js +6 -4
  75. data/lib/samagotchi/web/public/command_view.js +39 -35
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/copy.js +7 -15
  79. data/lib/samagotchi/web/public/diff_view.js +20 -23
  80. data/lib/samagotchi/web/public/format.js +0 -48
  81. data/lib/samagotchi/web/public/images.js +12 -16
  82. data/lib/samagotchi/web/public/question_cards.js +3 -2
  83. data/lib/samagotchi/web/public/read_view.js +10 -9
  84. data/lib/samagotchi/web/public/scope.js +23 -0
  85. data/lib/samagotchi/web/public/timing.js +2 -8
  86. data/lib/samagotchi/web/public/turn_html.js +119 -59
  87. data/lib/samagotchi/web/public/turn_model.js +13 -3
  88. data/lib/samagotchi/web/public/turn_view.js +73 -116
  89. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  90. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  91. data/lib/samagotchi/worker.rb +53 -35
  92. data/lib/samagotchi/worker_inbound.rb +12 -11
  93. metadata +9 -1
@@ -25,6 +25,7 @@ require_relative "tools/memory"
25
25
  require_relative "output_formatter"
26
26
  require_relative "turn_flow"
27
27
  require_relative "session_commands"
28
+ require_relative "composer_route"
28
29
  require_relative "terminal_ui/attached_view"
29
30
  require_relative "terminal_ui/between_turns"
30
31
  require_relative "terminal_ui/event_renderer"
@@ -121,6 +122,7 @@ module Samagotchi
121
122
 
122
123
  claim_session!(session_id) if session_id
123
124
  @resume_session = session_id ? Session.load(session_id) : nil
125
+ drop_dead_question(@resume_session) if @resume_session
124
126
  if @resume_session
125
127
  # --model overrides resumed session's model (runtime only, default unchanged)
126
128
  if flag_model_name
@@ -186,7 +188,6 @@ module Samagotchi
186
188
  memories: @requested_memories,
187
189
  muted_memories: @muted_memory_names,
188
190
  appended_prompt_files: @appended_prompt_files,
189
- recap: scratch ? nil : recap_config,
190
191
  scratch: scratch,
191
192
  reminders: {
192
193
  callback: lambda { |due_names|
@@ -249,7 +250,8 @@ module Samagotchi
249
250
  # post-turn conversation.
250
251
  # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
251
252
  # turn failed, :empty_answer when a -p --non-interactive turn ended
252
- # with no answer (bin/chi exits 1 on both), :interrupted when Ctrl-C
253
+ # with no answer, :iteration_limit when the iteration limit cut that
254
+ # turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
253
255
  # cancelled that turn (bin/chi exits 130)
254
256
  def run
255
257
  # --non-interactive with no --prompt is a harmless no-op exit: build
@@ -295,11 +297,11 @@ module Samagotchi
295
297
 
296
298
  if @prompt && @non_interactive
297
299
  # A /cut TEXT / /queue TEXT prompt runs its text as the turn's
298
- # prompt, the same as the idle REPL does (Formatting#steer_choice);
300
+ # prompt, the same as the idle REPL does (ComposerRoute.steer_choice);
299
301
  # a bare /cut / /queue (no text) is refused above as a UI command,
300
302
  # so only the text here. The images are the text's, not the words'.
301
303
  one_shot_prompt =
302
- if (choice = steer_choice(@prompt)) && !choice.last.empty?
304
+ if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
303
305
  choice.last
304
306
  else
305
307
  @prompt
@@ -309,12 +311,13 @@ module Samagotchi
309
311
  # Retry lines and hook notices on stderr; stdout gets the answer below.
310
312
  sink = OneShotSink.new
311
313
  save_before_first_turn(session)
314
+ max_iterations = IterationLimit.for(no_interrupt: true)
312
315
  begin
313
316
  result = @engine.run_turn(
314
317
  session,
315
318
  one_shot_prompt,
316
319
  on_event: sink,
317
- max_iterations: IterationLimit.for(no_interrupt: true),
320
+ max_iterations: max_iterations,
318
321
  cancel_controller: nil,
319
322
  images: ImageInput.extract(one_shot_prompt)
320
323
  )
@@ -337,6 +340,11 @@ module Samagotchi
337
340
  session.save
338
341
  # The after_turn hooks' notices, after the answer as the REPL shows them.
339
342
  sink.flush
343
+ # The limit cut the turn short: what came so far is no answer, so say
344
+ # so and fail (bin/chi exits 1), as the REPL's "iteration limit
345
+ # reached" line does there.
346
+ return one_shot_exhausted(session, max_iterations) if result.resumable?
347
+
340
348
  return
341
349
  end
342
350
 
@@ -390,6 +398,16 @@ module Samagotchi
390
398
  :turn_failed
391
399
  end
392
400
 
401
+ # A -p --non-interactive turn the iteration limit cut short: a line on
402
+ # stderr, with how to go on unless the session goes with this run.
403
+ # @return [Symbol] :iteration_limit
404
+ def one_shot_exhausted(session, max_iterations)
405
+ line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
406
+ line += "; continue it with: chi --resume #{session.id}" unless @scratch
407
+ warn line
408
+ :iteration_limit
409
+ end
410
+
393
411
  # `chi -p "/models" --non-interactive`: the command's output; a refused
394
412
  # one fails the run (bin/chi exits 1). The session is saved only when
395
413
  # the command changed it. Nobody is there to answer a confirm: a
@@ -518,6 +536,17 @@ module Samagotchi
518
536
  @pending_card_handle = @engine.subscribe(observer: pending)
519
537
  end
520
538
 
539
+ # A question the session's last owner saved and died with (a worker's,
540
+ # or another REPL's): this TUI owns the session now, so nobody waits on
541
+ # it. Dropped and saved, as the worker does (Worker#drop_dead_question).
542
+ def drop_dead_question(session)
543
+ id = session.drop_dead_question
544
+ return unless id
545
+
546
+ Log.info(:repl, "dead_question_dropped", id: id)
547
+ session.save
548
+ end
549
+
521
550
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
522
551
  # its own Engine, so no worker may run the session meanwhile.
523
552
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -806,34 +835,35 @@ module Samagotchi
806
835
  return if input.empty?
807
836
 
808
837
  # /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
809
- # the prompt both run the text as a normal turn, as their usage says.
810
- if (choice = steer_choice(input))
811
- return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
838
+ # the prompt both run the text as a normal turn, as their usage says
839
+ # (the route is the text's).
840
+ route = ComposerRoute.call(input, running: false, registry: command_registry)
841
+ return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
812
842
 
813
- input = choice.last
814
- end
843
+ input = route.text if route.choice
815
844
 
816
845
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
817
846
  # an anytime command's cards print as it shows them (btw's
818
847
  # "thinking…" before the answer). Piped input has nobody to answer a
819
848
  # confirm (the next line is already written): a costly /thinking or
820
849
  # /reasoning-history applies at once.
821
- confirm = !piped_input?
822
- command = if command_registry.anytime?(input)
823
- @engine.running_anytime { @commands.run(input, confirm: confirm) }
824
- else
825
- @commands.run(input, confirm: confirm)
826
- end
827
- if command
828
- show_command_result(command)
829
- persist_recent_history(input) if command.shell
830
- return
831
- end
832
- return if show_local_command(input)
833
-
850
+ case route.kind
851
+ when :command
852
+ confirm = !piped_input?
853
+ command = if command_registry.anytime?(input)
854
+ @engine.running_anytime { @commands.run(input, confirm: confirm) }
855
+ else
856
+ @commands.run(input, confirm: confirm)
857
+ end
858
+ if command
859
+ show_command_result(command)
860
+ persist_recent_history(input) if command.shell
861
+ return
862
+ end
863
+ when :local then return if show_local_command(input)
834
864
  # A typo of a command (/modle) is not a prompt: say so, send nothing.
835
- hint = command_registry.unknown_command_hint(input)
836
- return @surface.commit(hint) if hint
865
+ when :typo then return @surface.commit(route.reply)
866
+ end
837
867
 
838
868
  @turn_flow.before_prompt_turn
839
869
  persist_recent_history(input)
@@ -1136,29 +1166,42 @@ module Samagotchi
1136
1166
  #
1137
1167
  # A command that waits for the turn's end (/model X, !cmd) and every line
1138
1168
  # after it wait in @after_turn, in the order typed (D1), with the image
1139
- # lines (which never steer): once the turn ends they come back after the
1140
- # steering it didn't merge (typed before them), ahead of anything sent
1141
- # after Ctrl-C.
1169
+ # lines (which never steer) and /queue's: once the turn ends they come
1170
+ # back with the steering it didn't merge, in the order sent (each line
1171
+ # the turn takes gets the next @input_seq), ahead of anything sent after
1172
+ # Ctrl-C.
1142
1173
  def with_steering(&)
1143
1174
  return yield unless @repl_input
1144
1175
 
1145
1176
  @after_turn = []
1146
1177
  @command_waits = false
1147
- @repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain + lines_after_turn }, &)
1178
+ @input_seq = 0
1179
+ @repl_input.during_turn(method(:steer_line), leftovers: -> { leftover_lines }, &)
1148
1180
  end
1149
1181
 
1150
- # The lines that waited for the turn's end, taken from @after_turn. After
1151
- # a canceled or failed turn (Ctrl-C raises through here; a failure too)
1152
- # a !cmd among them is dropped (D4): running it would close the
1153
- # !rollback the turn left.
1182
+ # The turn's leftovers in send order: steering it didn't merge and the
1183
+ # lines that waited for its end.
1184
+ def leftover_lines
1185
+ (@pending_input_queue.drain_entries + lines_after_turn).sort_by { |seq, _| seq || 0 }.map(&:last)
1186
+ end
1187
+
1188
+ # The next line's place in send order (#leftover_lines).
1189
+ def next_input_seq
1190
+ @input_seq = (@input_seq || 0) + 1
1191
+ end
1192
+
1193
+ # The [seq, line] entries that waited for the turn's end, taken from
1194
+ # @after_turn. After a canceled or failed turn (Ctrl-C raises through
1195
+ # here; a failure too) a !cmd among them is dropped (D4): running it
1196
+ # would close the !rollback the turn left.
1154
1197
  def lines_after_turn
1155
- lines = @after_turn.slice!(0..)
1198
+ entries = @after_turn.slice!(0..)
1156
1199
  ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
1157
1200
  elsif $! then "failed"
1158
1201
  end
1159
- return lines unless ended
1202
+ return entries unless ended
1160
1203
 
1161
- lines.reject do |line|
1204
+ entries.reject do |_seq, line|
1162
1205
  next false unless command_registry.lookup(line)&.id == :shell
1163
1206
 
1164
1207
  @surface.commit("turn #{ended}: #{line.strip} not run; send it again")
@@ -1179,28 +1222,32 @@ module Samagotchi
1179
1222
  # to end and runs as a turn of its own.
1180
1223
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1181
1224
  def steer_line(line)
1182
- local = local_command(line) unless line.nil?
1225
+ route = ComposerRoute.call(line, running: true, registry: command_registry) unless line.nil?
1226
+ local = route&.local
1183
1227
  return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
1184
1228
  return archive_after_turn if local == :archive
1185
1229
  return detach_note if local == :detach
1186
1230
  return false if @active_cancel_controller&.cancelled?
1187
1231
 
1188
- # Before every command check: /cut and /queue are the UIs' own words
1189
- # for a delivery (they are listed for Tab and /help, never run).
1190
- if (choice = steer_choice(line))
1191
- return submit_steer_choice(*choice)
1232
+ case route.kind
1233
+ # Before every command check (ComposerRoute): /cut and /queue are the
1234
+ # UIs' own words for a delivery (listed for Tab and /help, never run).
1235
+ when :usage
1236
+ @surface.commit(STEER_CHOICE_USAGE)
1237
+ return true
1238
+ when :deliver then return submit_steer_choice(route.delivery, route.text)
1239
+ when :command
1240
+ policy = command_registry.mid_turn(line)
1241
+ return start_anytime_command(line) if policy == :anytime
1242
+ return command_queued(line) if policy == :queue
1192
1243
  end
1193
-
1194
- policy = command_registry.mid_turn(line)
1195
- return start_anytime_command(line) if policy == :anytime
1196
- return command_queued(line) if policy == :queue
1197
1244
  # A command, never steering text.
1198
- return command_during_turn(line) if local || command_registry.command?(line)
1199
- return true if line.strip.empty?
1245
+ return command_during_turn(line) if %i[local command].include?(route.kind)
1246
+ return true if route.kind == :empty
1200
1247
 
1201
1248
  # A typo of a command (/modle) is not steering text: say so, take it.
1202
- if (hint = command_registry.unknown_command_hint(line))
1203
- @surface.commit(hint)
1249
+ if route.kind == :typo
1250
+ @surface.commit(route.reply)
1204
1251
  return true
1205
1252
  end
1206
1253
  # Steering merges text only: a line with images runs as the next turn.
@@ -1208,7 +1255,7 @@ module Samagotchi
1208
1255
  # Behind a command that waits for the turn's end, a line waits too.
1209
1256
  return wait_for_turn_end(line) if @command_waits
1210
1257
 
1211
- @pending_input_queue.push(line.strip)
1258
+ @pending_input_queue.push(route.text, seq: next_input_seq)
1212
1259
  @surface.commit(NEXT_STEP_NOTE)
1213
1260
  true
1214
1261
  end
@@ -1217,25 +1264,21 @@ module Samagotchi
1217
1264
  # otherwise read as prompts (SessionCommands registers them for Tab and
1218
1265
  # help only). A cut asks the engine to cut the generation only thinking
1219
1266
  # too long; a queue waits for the turn's end (the REPL's own after-turn
1220
- # list). Empty text gets the usage line. A line with images never steers
1221
- # (as a plain one doesn't).
1267
+ # list). A line with images never steers (as a plain one doesn't).
1268
+ # @param delivery [String] Delivery::CUT or Delivery::QUEUE (ComposerRoute)
1222
1269
  # @return [Boolean] whether the turn took it
1223
- def submit_steer_choice(choice, text)
1224
- if text.empty?
1225
- @surface.commit(STEER_CHOICE_USAGE)
1226
- return true
1227
- end
1270
+ def submit_steer_choice(delivery, text)
1228
1271
  return image_line_waits(text) if ImageInput.extract(text).any?
1229
1272
 
1230
1273
  # A /queue waits for the turn's end; a /cut behind a command that
1231
1274
  # already waits there goes after it too.
1232
- if choice == :queue || @command_waits
1275
+ if Delivery.queue?(delivery) || @command_waits
1233
1276
  @surface.commit(COMMAND_QUEUED)
1234
1277
  return wait_for_turn_end(text)
1235
1278
  end
1236
1279
 
1237
1280
  epoch = @engine.input_epoch
1238
- @pending_input_queue.push(text)
1281
+ @pending_input_queue.push(text, seq: next_input_seq)
1239
1282
  # With no session running the cut is :off; the text still waits for
1240
1283
  # the running turn's next step.
1241
1284
  @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
@@ -1250,7 +1293,7 @@ module Samagotchi
1250
1293
  # +line+ runs once the turn has ended, in the order typed (#with_steering).
1251
1294
  # @return [true] the turn took it
1252
1295
  def wait_for_turn_end(line)
1253
- @after_turn << line
1296
+ @after_turn << [next_input_seq, line]
1254
1297
  true
1255
1298
  end
1256
1299
 
@@ -1359,14 +1402,6 @@ module Samagotchi
1359
1402
  Log.debug(:repl, "children_counts_failed", error: e.class.name)
1360
1403
  end
1361
1404
 
1362
- # Resolve the recap config (on by default). Returns false when explicitly
1363
- # disabled, nil when nothing is configured, or a Hash when enabled so the
1364
- # Engine can build the detector. Single precedence path via the Config
1365
- # registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
1366
- def recap_config
1367
- ConfigFile.recap_config
1368
- end
1369
-
1370
1405
  # Reset the Engine's shared inactivity clock when a prompt opens (a
1371
1406
  # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
1372
1407
  # single seam the Engine-owned idle recap detector reads, so the idle clock
@@ -211,14 +211,14 @@ module Samagotchi
211
211
  # "stopped" for a
212
212
  # task_wait the user's Stop ended (the task itself runs on), for one
213
213
  # whose task was stopped (the stop-task button, or the model's task_stop),
214
- # and for a command the user's Stop (or a signal to chi) killed or kept
215
- # from starting: a cancel, not a failure (its text still starts "Error:"
216
- # for the model).
214
+ # and for a command the user's Stop (or a signal to chi, a session stop)
215
+ # killed or kept from starting: a cancel, not a failure (its text still
216
+ # starts "Error:" for the model).
217
217
  def tool_activity_status(result, tool_name = nil, command: nil)
218
218
  text = result.to_s
219
- return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
219
+ return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, *Tools::Execute::NOT_RUN.values].include?(text)
220
220
  if tool_name == Tools::Execute::NAME &&
221
- text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
221
+ text.start_with?(Tools::Execute::STOPPED_BY_USER, *Tools::Execute::STOPPED_BY.values)
222
222
  return "stopped"
223
223
  end
224
224
  return "error" if text.start_with?("Error:")
@@ -203,7 +203,9 @@ module Samagotchi
203
203
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
204
204
  state_dir: state_dir)
205
205
  case delivered[:status]
206
- when :accepted then id
206
+ when :accepted
207
+ DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
208
+ id
207
209
  when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
208
210
  when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
209
211
  else "Error: the message to session #{id[0, 8]} could not be written"
@@ -215,6 +217,31 @@ module Samagotchi
215
217
  end
216
218
  private_class_method :follow_up
217
219
 
220
+ # The child's turn a delivered follow-up joins, for the wait
221
+ # (DelegateCursor#turn): the running turn's (its checkpoint mark), the
222
+ # follow-up merging into it; else the ack's enqueued_id, the follow-up
223
+ # running as a turn of its own. Only from a worker that names
224
+ # turn_ids; nil otherwise, or when the running turn has no id (a
225
+ # delegate's first turn): the wait takes the next reply.
226
+ # A follow-up steered into a turn that ended before it merged runs as
227
+ # its own turn, yet the wait is on the ended one (LOW, as before).
228
+ # One the child's idle look missed being merged into a turn starting
229
+ # then never runs as its own: ReplyWait falls back to that turn's
230
+ # reply (merged_elsewhere?).
231
+ # @return [String, nil]
232
+ def self.follow_up_turn(id, ack, state_dir:)
233
+ return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
234
+
235
+ child = Session.load(id, state_dir: state_dir)
236
+ return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
237
+
238
+ enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
239
+ enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
240
+ rescue ArgumentError
241
+ nil
242
+ end
243
+ private_class_method :follow_up_turn
244
+
218
245
  # The model as typed (spawn_session stores its resolved ref), else the
219
246
  # parent's. No existence check here: spawn_session checks the host
220
247
  # (check_host!, its UnknownHost comes back as this tool's Error: line)
@@ -10,9 +10,14 @@ module Samagotchi
10
10
  # What a parent was already told about one delegate child: the newest
11
11
  # reply it was given (+reply_file+, ReplyWait's cursor) and the child as
12
12
  # it was then (+messages+, +question_id+, +last_turn+: ReplyWait's
13
- # baseline). nil +messages+ means no baseline.
14
- DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages) do
15
- def self.empty = new(reply_file: nil, last_turn: nil, question_id: nil, messages: nil)
13
+ # baseline). nil +messages+ means no baseline. +turn+: the enqueued_id
14
+ # of the child's turn the last follow-up went to (Delegate.follow_up),
15
+ # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
+ # was reported, or when unknown (a new child, an older worker).
17
+ DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn) do
18
+ def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil) = super
19
+
20
+ def self.empty = new
16
21
 
17
22
  # @param baseline [Hash, nil] ReplyWait.baseline_of's
18
23
  def with_baseline(baseline)
@@ -93,7 +98,7 @@ module Samagotchi
93
98
  return DelegateCursor.empty unless data.is_a?(Hash)
94
99
 
95
100
  DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
96
- question_id: data["question_id"], messages: data["messages"])
101
+ question_id: data["question_id"], messages: data["messages"], turn: data["turn"])
97
102
  end
98
103
 
99
104
  def path(parent_id, state_dir:)
@@ -72,7 +72,8 @@ module Samagotchi
72
72
  started = monotonic
73
73
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
74
74
  poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
75
- owner_grace: owner_grace, interject: others && -> { others.poll })
75
+ owner_grace: owner_grace, interject: others && -> { others.poll },
76
+ turn: cursor.turn)
76
77
  left -= monotonic - started
77
78
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
78
79
  outcomes.concat(relayed_outcomes.delete(key) || [])
@@ -143,7 +144,9 @@ module Samagotchi
143
144
  # gave, and the child as that same look loaded it (a fresh load could
144
145
  # swallow a turn that ended in between), so a later wait looks for a
145
146
  # later turn and a question already reported isn't reported again.
146
- # Nothing moves on a timeout or a cancel.
147
+ # The follow-up's turn (DelegateCursor#turn) is done with once its end
148
+ # was reported; a question keeps it. Nothing moves on a timeout or a
149
+ # cancel.
147
150
  # @param wait [ReplyWait::Result]
148
151
  # @return [DelegateCursor, nil] the new cursor
149
152
  def advance(parent_id, child_id, wait, state_dir:)
@@ -151,6 +154,7 @@ module Samagotchi
151
154
 
152
155
  DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
153
156
  cursor = cursor.with(reply_file: wait.file) if wait.status == :done
157
+ cursor = cursor.with(turn: nil) unless wait.status == :waiting_for_answer
154
158
  cursor.with_baseline(ReplyWait.baseline_of(wait.session))
155
159
  end
156
160
  end
@@ -187,20 +191,28 @@ module Samagotchi
187
191
 
188
192
  # Point the cursor at the newest reply now, so only a later one counts
189
193
  # (a follow-up sent to a child that already answered), and take the
190
- # child's baseline before the follow-up goes in.
194
+ # child's baseline before the follow-up goes in. No turn yet
195
+ # (#mark_turn, once it went in).
191
196
  # @param child [Session] the child as loaded before sending
192
197
  def mark_seen(parent_id, child, state_dir:)
193
198
  newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
194
199
  DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
195
- cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
200
+ cursor.with(reply_file: newest, turn: nil).with_baseline(ReplyWait.baseline_of(child))
196
201
  end
197
202
  end
198
203
 
204
+ # The follow-up went in: the child's turn it joins, whose reply the
205
+ # wait takes (DelegateCursor#turn).
206
+ # @param turn [String, nil]
207
+ def mark_turn(parent_id, child_id, turn, state_dir:)
208
+ DelegateCursors.update(parent_id, child_id, state_dir: state_dir) { |cursor| cursor.with(turn: turn) }
209
+ end
210
+
199
211
  # A new child's baseline: as spawned, before its first turn ran.
200
212
  # @param child [Session] the session spawn_session returned
201
213
  def mark_started(parent_id, child, state_dir:)
202
214
  DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
203
- cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
215
+ cursor.with(turn: nil).with_baseline(ReplyWait.baseline_of(child, question_id: nil))
204
216
  end
205
217
  end
206
218
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require_relative "../config"
5
+ require_relative "../log"
5
6
  require_relative "../paths"
6
7
  require_relative "../process_group"
7
8
  require_relative "output_guardrails"
@@ -31,6 +32,10 @@ module Samagotchi
31
32
  # stopped the turn: its cancel reason is :signal.
32
33
  NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
33
34
  STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
35
+ # The same two when a session stop (chi sessions stop, the web's stop,
36
+ # a parent stopping its delegate) canceled the turn: :session_stopped.
37
+ NOT_RUN_ON_SESSION_STOP = "Error: not run, the session was stopped"
38
+ STOPPED_BY_SESSION_STOP = "Error: command stopped as the session was stopped"
34
39
  # The first words of a command killed at the limit in kill mode (hooks
35
40
  # match on it).
36
41
  TIMEOUT_PREFIX = "Error: command timed out after"
@@ -78,7 +83,8 @@ module Samagotchi
78
83
  # @param cancelled [#call] true once the user stopped the turn: the
79
84
  # command is killed (or not started) and its output so far returned
80
85
  # @param stop_reason [#call] the stop's reason (:signal: a signal to
81
- # chi, which the result then names instead of the user)
86
+ # chi, :session_stopped: a session stop, which the result then names
87
+ # instead of the user)
82
88
  # @param env [Hash, nil] added to the command's environment
83
89
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
84
90
  # nil reads execute.on_timeout
@@ -115,16 +121,18 @@ module Samagotchi
115
121
  # a long command, and return whatever it printed before it was killed.
116
122
  stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
117
123
  rescue CommandCancelled => e
118
- first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
124
+ first = STOPPED_BY.fetch(stop_reason.call, STOPPED_BY_USER)
119
125
  stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
120
126
  rescue StandardError => e
121
127
  "Error: #{e.message}"
122
128
  end
123
129
 
124
130
  # What a command the turn's stop kept from starting returns.
125
- def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
131
+ def self.not_run(stop_reason) = NOT_RUN.fetch(stop_reason.call, NOT_RUN_ON_STOP)
126
132
 
127
- def self.signal?(stop_reason) = stop_reason.call == :signal
133
+ # The texts above by the stop's reason; any other is the user's Stop.
134
+ NOT_RUN = { signal: NOT_RUN_ON_SIGNAL, session_stopped: NOT_RUN_ON_SESSION_STOP }.freeze
135
+ STOPPED_BY = { signal: STOPPED_BY_SIGNAL, session_stopped: STOPPED_BY_SESSION_STOP }.freeze
128
136
 
129
137
  # execute.on_timeout, or the caller's: :task (the default) or :kill.
130
138
  def self.on_timeout_mode(value)
@@ -169,10 +177,14 @@ module Samagotchi
169
177
  exit_code_path = File.join(dir, TaskRuntime::EXIT_CODE_FILENAME)
170
178
  begin
171
179
  pid = spawn_logged(command, env: env, cwd: cwd, logs: logs, exit_code_path: exit_code_path)
172
- rescue StandardError
180
+ rescue StandardError => e
181
+ Log.info(:turn, "execute_spawn_failed", id: id, error: e.class.name)
173
182
  FileUtils.rm_rf(dir)
174
183
  raise
175
184
  end
185
+ # Never the command line or its output: a command may carry a
186
+ # secret, and its output is the tool result's.
187
+ Log.debug(:turn, "execute_started", id: id, pid: pid, timeout_sec: timeout_sec, on_timeout: on_timeout)
176
188
  wait_thr = Process.detach(pid)
177
189
  # A stop is done once the shell is reaped and nothing holds the logs:
178
190
  # a child that ignores TERM keeps them, so the stop goes on to KILL.
@@ -195,6 +207,7 @@ module Samagotchi
195
207
  timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
196
208
  if text
197
209
  handed_over = true
210
+ Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
198
211
  raise HandedOver, text
199
212
  end
200
213
  # Not adopted because it ended meanwhile: finished too.
@@ -205,10 +218,11 @@ module Samagotchi
205
218
  status = wait_thr.value
206
219
  rescue HandedOver
207
220
  raise
208
- rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
221
+ rescue Exception => e # rubocop:disable Lint/RescueException -- kill the child, then re-raise
209
222
  # An Interrupt (or anything else) mid-wait: the child is in its own
210
223
  # process group, so it never saw the SIGINT. Kill it.
211
224
  aborted = true
225
+ Log.info(:turn, "execute_aborted", id: id, error: e.class.name)
212
226
  terminate_process_tree(pid, stopped: stopped) if wait_thr.alive?
213
227
  raise
214
228
  ensure
@@ -230,9 +244,18 @@ module Samagotchi
230
244
  end
231
245
  end
232
246
 
233
- raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
234
- raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
247
+ elapsed_ms = ((monotonic_time - started) * 1000).round
248
+ if timed_out
249
+ Log.info(:turn, "execute_timed_out", id: id, after_sec: timeout_sec)
250
+ raise CommandTimedOut.new(stdout_text, stderr_text)
251
+ end
252
+ if was_cancelled
253
+ Log.info(:turn, "execute_stopped", id: id, ms: elapsed_ms)
254
+ raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started)
255
+ end
235
256
 
257
+ Log.debug(:turn, "execute_finished", id: id, exit: status&.exitstatus, ms: elapsed_ms,
258
+ background: held_open || nil)
236
259
  [stdout_text, stderr_text, status, held_open]
237
260
  end
238
261
  private_class_method :run_command
@@ -348,6 +371,8 @@ module Samagotchi
348
371
  Thread.current.report_on_exception = false
349
372
  code = waiter.value&.exitstatus
350
373
  File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
374
+ rescue StandardError => e
375
+ Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
351
376
  end
352
377
  end
353
378
  private_class_method :write_missing_exit_code
@@ -67,6 +67,7 @@ module Samagotchi
67
67
  @boundaries = 0
68
68
  @written_at = nil
69
69
  @started_at = nil
70
+ @enqueued_id = nil
70
71
  end
71
72
 
72
73
  # A turn begins (before Engine#run_turn and the worker's running save):
@@ -74,14 +75,18 @@ module Samagotchi
74
75
  # images, no turn id), marked at iteration 0, so a worker killed before
75
76
  # the loop's first boundary leaves the prompt and a mark the next one
76
77
  # settles. A continue turn (no prompt) is the history, marked.
78
+ # Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
77
79
  # @param prompt [String, nil]
78
- def begin_turn(prompt: nil)
80
+ # @param enqueued_id [String, nil] the input the turn runs
81
+ def begin_turn(prompt: nil, enqueued_id: nil)
79
82
  @boundaries = 0
83
+ @enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
80
84
  @written_at = nil
81
85
  @started_at = Time.now.iso8601(3)
82
86
  messages = Array(@session.messages).dup
83
87
  messages << { role: "user", content: prompt } unless prompt.to_s.empty?
84
- @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
88
+ @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
89
+ enqueued_id: @enqueued_id) }
85
90
  end
86
91
 
87
92
  # The turn ended (or never began): the session saves its own messages.
@@ -104,7 +109,8 @@ module Samagotchi
104
109
  # One level deep: the loop may edit an entry in place later (a
105
110
  # forget stub) while a save on another thread serializes this one.
106
111
  messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
107
- marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
112
+ marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
113
+ enqueued_id: @enqueued_id)
108
114
  @session.turn_checkpoint = { messages: messages, marker: marker }
109
115
  return false unless due? || !merged.empty?
110
116
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.62.0"
4
+ VERSION = "0.64.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end