samagotchi 0.91.0 → 0.92.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +16 -12
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +2 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/tool-guardrails.md +10 -2
  15. data/docs/memory.md +6 -0
  16. data/docs/plugins.md +30 -13
  17. data/docs/releasing.md +3 -0
  18. data/docs/sessions.md +3 -3
  19. data/lib/samagotchi/bridge.rb +8 -9
  20. data/lib/samagotchi/bundle_command/check.rb +43 -12
  21. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  22. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  23. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  24. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  25. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  27. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  28. data/lib/samagotchi/bundle_command.rb +11 -213
  29. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  30. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  32. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  33. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  34. data/lib/samagotchi/child_env.rb +50 -0
  35. data/lib/samagotchi/engine.rb +22 -12
  36. data/lib/samagotchi/hooks/registry.rb +2 -0
  37. data/lib/samagotchi/kernel_loop.rb +14 -3
  38. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  39. data/lib/samagotchi/llm/errors.rb +4 -4
  40. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  41. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  42. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  43. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  44. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  45. data/lib/samagotchi/parent_report.rb +12 -0
  46. data/lib/samagotchi/reply_wait.rb +6 -2
  47. data/lib/samagotchi/sandbox_layout.rb +66 -0
  48. data/lib/samagotchi/send_command.rb +3 -3
  49. data/lib/samagotchi/session_manager.rb +12 -20
  50. data/lib/samagotchi/task_watch.rb +64 -21
  51. data/lib/samagotchi/testing/bundle.rb +32 -19
  52. data/lib/samagotchi/testing/install.rb +26 -17
  53. data/lib/samagotchi/testing/sandbox.rb +45 -37
  54. data/lib/samagotchi/testing/waiting.rb +5 -7
  55. data/lib/samagotchi/tool_response.rb +28 -6
  56. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  57. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/public/app.js +24 -14
  60. data/lib/samagotchi/web/public/cards_place.js +4 -1
  61. data/lib/samagotchi/web/public/images.js +6 -0
  62. data/lib/samagotchi/web/public/model_pick.js +10 -0
  63. data/lib/samagotchi/web/public/toasts.js +17 -9
  64. data/lib/samagotchi/web/public/unqueue.js +20 -4
  65. data/lib/samagotchi/worker.rb +77 -185
  66. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  67. data/lib/samagotchi/worker_inbound.rb +5 -5
  68. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  69. data/lib/samagotchi/worker_wakes.rb +0 -3
  70. metadata +6 -1
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "securerandom"
5
4
 
6
5
  require_relative "client_id"
7
6
  require_relative "config"
@@ -16,6 +15,7 @@ require_relative "worker_idle_exit"
16
15
  require_relative "worker_wakes"
17
16
  require_relative "worker_inbound"
18
17
  require_relative "worker_signals"
18
+ require_relative "worker_task_wakes"
19
19
  require_relative "session_manager"
20
20
  require_relative "archive_store"
21
21
  require_relative "log"
@@ -32,8 +32,6 @@ require_relative "child_reports"
32
32
  require_relative "child_move"
33
33
  require_relative "context_absorber"
34
34
  require_relative "context_poller"
35
- require_relative "kernel_loop"
36
- require_relative "task_finish_note"
37
35
  require_relative "task_watch"
38
36
  require_relative "tools/task_runtime"
39
37
 
@@ -248,7 +246,7 @@ module Samagotchi
248
246
  # a command queued mid-turn (/thinking, /model, /llm-context) changes
249
247
  # what it starts with: no turn-end warm-up then.
250
248
  @engine.next_turn_waiting = lambda {
251
- !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due? || task_wake_due?
249
+ !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due? || @task_wakes.due?
252
250
  }
253
251
  @turn_flow = TurnFlow.new(engine: @engine)
254
252
  # Its question's answer is queued as the matching /continue (the
@@ -266,7 +264,10 @@ module Samagotchi
266
264
  @turn_end_seq = 0
267
265
  # The background tasks this session started and hasn't seen finish:
268
266
  # one that finishes unseen wakes the idle session (#run_task_wake).
269
- @task_watch = TaskWatch.new
267
+ @task_wakes = WorkerTaskWakes.new(engine: @engine, session: @session, wakes: @wakes,
268
+ awaiting_continue: -> { @turn_flow.awaiting_continue? },
269
+ idle: -> { nothing_queued? && !@exit_requested },
270
+ save: -> { save_or_log(:task_notes) { save_session } })
270
271
  @engine.subscribe(observer: lambda { |event|
271
272
  if Events::TURN_END.include?(event[:type])
272
273
  @turn_end_seq = event[:event_seq]
@@ -278,7 +279,7 @@ module Samagotchi
278
279
  @turn_ending = false
279
280
  end
280
281
  ring_question(event) if event[:type] == :question_requested
281
- watch_tasks(event)
282
+ @task_wakes.observe(event)
282
283
  })
283
284
  # This session's own delegate children's news (rings in children/).
284
285
  @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir,
@@ -327,8 +328,11 @@ module Samagotchi
327
328
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
328
329
  running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) },
329
330
  # Only when a finish would wake the session (D3): with queue its end
330
- # is just a note, not worth keeping the worker up for.
331
- watched_tasks: -> { TaskWatch.mode == "wake" ? @task_watch.running : [] }
331
+ # is just a note, not worth keeping the worker up for. One that
332
+ # finished since the pass looked holds too, until the next pass
333
+ # wakes for it (#run_task_wake).
334
+ watched_tasks: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.holding : [] },
335
+ watched_running: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.running : [] }
332
336
  )
333
337
  end
334
338
 
@@ -531,26 +535,14 @@ module Samagotchi
531
535
  # A turn nobody typed for a changed source (a context wake): a continue
532
536
  # turn from context:<name> whose first message is the wake note already
533
537
  # in the conversation (named by +turn_id+), on the same rules as a
534
- # delegate report's wake turn: it counts toward session.max_wakes (a
535
- # peer's answer toward context.peer_turns too, unless it agrees), and a failure goes back
536
- # to before the turn (the note stays, as a plain one:
537
- # WorkerInbound#unmark_wake) and pauses wakes until a human's input.
538
+ # delegate report's wake turn (#run_wake): it counts toward
539
+ # session.max_wakes (a peer's answer toward context.peer_turns too,
540
+ # unless it agrees).
538
541
  # +context_wake+: a WorkerInbound::ContextWake.
539
542
  def run_context_wake_turn(context_wake)
540
543
  name = context_wake.name
541
544
  Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer_turn?))
542
- @turn_flow.before_prompt_turn
543
- run_engine_turn(nil, continue: true, origin: context_wake.origin,
544
- id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
545
- if error
546
- kept = @turn_flow.prompt_turn_failed(error: error, wake: "the change in attached context #{name}") == :kept
547
- # A turn that got somewhere stays, its wake mark with it.
548
- @inbound.unmark_wake(context_wake) unless kept
549
- @wakes.pause!
550
- else
551
- @continue_offer.after_turn(result)
552
- end
553
- end
545
+ run_wake(origin: context_wake.origin, failed: "the change in attached context #{name}", mark: context_wake)
554
546
  end
555
547
 
556
548
  # The first prompt (spawn_session's): a delegate child's task, which is
@@ -572,7 +564,7 @@ module Samagotchi
572
564
  # A first turn sees the context attached before it, and any turn the
573
565
  # background tasks that finished since the last look.
574
566
  absorb_context(before_turn: true)
575
- absorb_task_notes
567
+ @task_wakes.absorb
576
568
  user_input(origin&.dig(:client_id))
577
569
  @continue_offer.drop(origin)
578
570
  @turn_flow.before_prompt_turn
@@ -640,177 +632,68 @@ module Samagotchi
640
632
 
641
633
  @wakes.hand_over(reports)
642
634
  Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes.count!)
643
- @turn_flow.before_prompt_turn
644
- restores = @turn_flow.restores
645
- run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
646
- if error
647
- # Rolled back, the rings stay (settle_child_reports): the user's
648
- # next turn takes them. A turn that got somewhere stays, and read them.
649
- @turn_flow.prompt_turn_failed(
650
- error: error, wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
651
- wake_kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
652
- "again with the user's next message."
653
- )
654
- @wakes.pause!
655
- else
656
- @continue_offer.after_turn(result)
657
- # Stopped before it got anywhere (a Stop or a before_turn hook's
658
- # cancel before the model call) and restored: its rings stay, and
659
- # wait for the user's next message as after a failure, rather
660
- # than waking again at once.
661
- @wakes.pause! if result.canceled? && @turn_flow.restores != restores
662
- end
663
- end
635
+ # Rolled back, the rings stay (settle_child_reports): the user's next
636
+ # turn takes them. A turn that got somewhere stays, and read them.
637
+ run_wake(origin: reports.first.origin,
638
+ failed: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
639
+ kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
640
+ "again with the user's next message.")
664
641
  true
665
642
  ensure
666
643
  @wakes.drop_handed
667
644
  end
668
645
 
669
- # The Engine's raw events feed the task watch (session.task_wakes not
670
- # off): a result that started a task or showed its end, a turn's start,
671
- # a user's Stop of it (M5).
672
- def watch_tasks(event)
673
- case event[:type]
674
- when :tool_call_completed then @task_watch.observe(event) unless TaskWatch.mode == "off"
675
- when :turn_started then @task_watch.turn_started
676
- when :turn_canceled then @task_watch.turn_canceled(event[:cancellation_reason])
677
- end
678
- end
679
-
680
- # Whether a finished task would start a wake turn now: the Engine skips
681
- # the turn-end warm-up then (next_turn_waiting).
682
- def task_wake_due?
683
- TaskWatch.mode == "wake" && @task_watch.any? && @wakes.task_open?(awaiting_continue: @turn_flow&.awaiting_continue?) &&
684
- @task_watch.finished?
685
- end
686
-
687
- # The background tasks this session started that finished unseen
688
- # (TaskWatch#take_finished), each as a TASK FINISHED note
689
- # (TaskFinishNote): the first one not note-only starts a wake turn from
690
- # task:<id> when session.task_wakes is wake, nothing is queued, no exit
691
- # was asked for and the wake budget is open (WorkerWakes#task_open?);
692
- # the others, and every one otherwise, are plain notes for the next
693
- # turn (M2). A spent budget is announced once.
694
- # @return [Boolean] whether a wake turn ran
695
- def run_task_wake
696
- return false if @wakes.in_grace? || !@task_watch.any?
697
-
698
- finished = take_finished_tasks
699
- return false if finished.empty?
700
-
701
- waking = finished.find { |entry, _| !entry.note_only } if task_wake_open?
702
- task_wake = note_finished_tasks(finished, waking: waking)
703
- return false if task_wake == :failed
704
-
705
- save_or_log(:task_notes) { save_session }
706
- unless task_wake
707
- task_budget_notice(finished.count { |entry, _| !entry.note_only }) if TaskWatch.mode == "wake" && @wakes.spent?
708
- return false
709
- end
710
-
711
- run_task_wake_turn(task_wake)
712
- true
713
- end
714
-
715
- # Before a prompt's turn: the finished tasks join it as plain notes.
716
- def absorb_task_notes
717
- return unless @task_watch.any?
718
-
719
- finished = take_finished_tasks
720
- return if finished.empty?
721
- return if note_finished_tasks(finished) == :failed
722
-
723
- save_or_log(:task_notes) { save_session }
724
- end
725
-
726
- # Each finished task's note into the conversation: +waking+'s as the
727
- # wake turn's start, the others plain. A note that can't be written (a
728
- # log that can't be read, say) puts it and the ones after it back in
729
- # the watch for the next look.
730
- # @return [WorkerInbound::TaskWake, nil, :failed]
731
- def note_finished_tasks(finished, waking: nil)
732
- written = []
733
- (finished - [waking]).each do |entry, record|
734
- add_task_note(entry, record)
735
- written << entry
736
- end
737
- waking && add_task_wake_note(*waking)
738
- rescue StandardError => e
739
- Log.warn(:worker, "task_note_failed", error: e.class.name, msg: e.message)
740
- @task_watch.put_back(finished.map(&:first) - written)
741
- :failed
742
- end
743
-
744
- # Only the tasks the conversation still holds (a !rollback, a restored
745
- # turn may have dropped the result that started one). A failure to
746
- # read them is logged: they wait for the next look.
747
- def take_finished_tasks
748
- @task_watch.take_finished(keep: Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint))
749
- rescue StandardError => e
750
- Log.warn(:worker, "task_watch_failed", error: e.class.name, msg: e.message)
751
- []
752
- end
753
-
754
- def task_wake_open?
755
- TaskWatch.mode == "wake" && nothing_queued? && !@exit_requested &&
756
- @wakes.task_open?(awaiting_continue: @turn_flow.awaiting_continue?)
757
- end
758
-
759
- def task_note(entry, record, wake:, turn_id: nil)
760
- TaskFinishNote.note(record, limit_sec: entry.limit_sec, wake: wake, turn_id: turn_id,
761
- cap: KernelLoop.resolve_output_char_cap(nil))
762
- end
763
-
764
- def add_task_note(entry, record)
765
- @engine.add_context_note(@session, task_note(entry, record, wake: false))
766
- end
767
-
768
- # The wake note, in the conversation as the wake turn's first message
769
- # (before TurnFlow#before_prompt_turn, so a restore keeps it).
770
- # @return [WorkerInbound::TaskWake]
771
- def add_task_wake_note(entry, record)
772
- turn_id = SecureRandom.uuid
773
- @engine.add_context_note(@session, task_note(entry, record, wake: true, turn_id: turn_id))
774
- WorkerInbound::TaskWake.new(ids: [entry.id], turn_id: turn_id, plain_note: task_note(entry, record, wake: false))
775
- end
776
-
777
- # A turn nobody typed for a finished task: a continue turn from
778
- # task:<id> whose first message is the wake note, on the delegate wake's
779
- # rules (counted toward session.max_wakes). A failure that kept nothing,
780
- # or a Stop that restored the conversation, leaves the note as a plain
781
- # one (WorkerInbound#unmark_wake) and pauses wakes until a human's input (M4).
782
- def run_task_wake_turn(task_wake)
783
- Log.info(:worker, "task_wake_turn", task_id: task_wake.ids.first, in_a_row: @wakes.count!)
646
+ # A wake turn (a delegate report's, an attached context change's, a
647
+ # finished task's): a continue turn nobody typed, from +origin+. A
648
+ # failure goes back to before the turn and pauses wakes until a human's
649
+ # input (a provider outage doesn't loop); so does a Stop before it got
650
+ # anywhere (a Stop or a before_turn hook's cancel before the model
651
+ # call, restored by TurnFlow), rather than waking again at once. Either
652
+ # way +mark+ (a WorkerInbound::ContextWake or TaskWake: its wake note,
653
+ # the turn's first message) goes back to a plain note
654
+ # (WorkerInbound#unmark_wake), unless the failed turn kept its steps.
655
+ # +failed+ names the wake in the failed turn's note, +kept+ says what
656
+ # happens to it (TurnFlow#prompt_turn_failed).
657
+ def run_wake(origin:, failed:, kept: nil, mark: nil)
784
658
  @turn_flow.before_prompt_turn
785
659
  restores = @turn_flow.restores
786
- run_engine_turn(nil, continue: true, origin: task_wake.origin, id: task_wake.turn_id,
660
+ run_engine_turn(nil, continue: true, origin: origin, id: mark&.turn_id,
787
661
  max_iterations: IterationLimit.for) do |result, error|
788
662
  if error
789
- kept = @turn_flow.prompt_turn_failed(
790
- error: error, wake: "a finished background task (#{task_wake.name.delete_prefix("task ")})",
791
- wake_kept: "Its note stays for the user's next message."
792
- ) == :kept
793
- @inbound.unmark_wake(task_wake) unless kept
663
+ steps_kept = @turn_flow.prompt_turn_failed(error: error, wake: failed, wake_kept: kept) == :kept
664
+ @inbound.unmark_wake(mark) if mark && !steps_kept
794
665
  @wakes.pause!
795
666
  else
796
667
  @continue_offer.after_turn(result)
797
668
  if result.canceled? && @turn_flow.restores != restores
798
- @inbound.unmark_wake(task_wake)
669
+ @inbound.unmark_wake(mark) if mark
799
670
  @wakes.pause!
800
671
  end
801
672
  end
802
673
  end
803
674
  end
804
675
 
805
- # The wake budget is spent: the UIs hear once that finished tasks wait
806
- # as notes (one notice per stretch, shared with the other kinds).
807
- def task_budget_notice(count)
808
- return unless count.positive? && @wakes.notice_budget!
676
+ # The background tasks this session started that finished unseen, as
677
+ # notes (WorkerTaskWakes#take), and the wake turn the first one may
678
+ # start.
679
+ # @return [Boolean] whether a wake turn ran
680
+ def run_task_wake
681
+ task_wake = @task_wakes.take
682
+ return false unless task_wake
809
683
 
810
- @engine.announce(type: :hook_notice, hook: "task", level: :info, between_turns: true,
811
- text: "#{count} finished background task#{"s" if count != 1} noted; the model reads " \
812
- "#{count == 1 ? "it" : "them"} with your next message " \
813
- "(#{@wakes.max} wake turn#{"s" if @wakes.max != 1} in a row, #{ChildRing::MAX_WAKES_KEY})")
684
+ run_task_wake_turn(task_wake)
685
+ true
686
+ end
687
+
688
+ # A turn nobody typed for a finished task: a continue turn from
689
+ # task:<id> whose first message is the wake note, on the delegate wake's
690
+ # rules (#run_wake; counted toward session.max_wakes): a failure that
691
+ # kept nothing, or a Stop that restored the conversation, leaves the
692
+ # note as a plain one (M4).
693
+ def run_task_wake_turn(task_wake)
694
+ Log.info(:worker, "task_wake_turn", task_id: task_wake.id, in_a_row: @wakes.count!)
695
+ run_wake(origin: task_wake.origin, failed: "a finished background task (#{task_wake.name.delete_prefix("task ")})",
696
+ kept: "Its note stays for the user's next message.", mark: task_wake)
814
697
  end
815
698
 
816
699
  # The idle loop's sleep: the fallback tick, or less while reports wait
@@ -1067,7 +950,7 @@ module Samagotchi
1067
950
  # @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
1068
951
  # line that applies it (the TUI's prefill, the web's Apply button)
1069
952
  def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
1070
- TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
953
+ TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id, status: status)
1071
954
  text = output.to_s
1072
955
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
1073
956
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
@@ -1366,17 +1249,19 @@ module Samagotchi
1366
1249
  # +delete+: the session is deleted after (/exit --delete), so no recap.
1367
1250
  # +restart+: a new worker takes over (on the newest chi installed), so
1368
1251
  # WorkerIdleExit#hold_for_restart's rules, and no recap or discard.
1369
- # @return [Symbol, Hash, nil] what keeps the worker up ({reason:
1370
- # :background_tasks, tasks:} names the watched tasks still running),
1371
- # nil when it will leave
1252
+ # @return [WorkerIdleExit::Hold, nil] what keeps the worker up (for
1253
+ # :background_tasks, the watched tasks still running), nil when it
1254
+ # will leave
1372
1255
  def exit_request(client_id, delete: false, restart: false)
1373
1256
  # The Bridge serves before the idle-exit policy exists.
1374
- return :starting unless @idle_exit
1257
+ return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
1375
1258
 
1376
1259
  hold = restart ? @idle_exit.hold_for_restart : @idle_exit.hold_for_request(requester: client_id)
1377
- # /exit names the tasks it waits for (D3).
1378
- return { reason: hold, tasks: @task_watch.running, hint: TASK_STOP_HINT } if hold == :background_tasks && !restart
1379
- return hold if hold
1260
+ if hold == :background_tasks && !restart
1261
+ # /exit names the tasks it waits for (D3).
1262
+ return WorkerIdleExit::Hold.new(reason: hold, tasks: @task_wakes.watch.running, hint: TASK_STOP_HINT)
1263
+ end
1264
+ return WorkerIdleExit::Hold.new(reason: hold) if hold
1380
1265
 
1381
1266
  @exit_requested = true
1382
1267
  @exit_requested_by = client_id
@@ -1423,10 +1308,17 @@ module Samagotchi
1423
1308
  # A restart keeps even an empty session (a new worker takes it over at
1424
1309
  # once) and leaves the recap to the conversation's next real pause.
1425
1310
  def left(result)
1311
+ deleting = result == :exit_requested && @exit_deletes
1312
+ # The watch goes with this process: a task that finished since the
1313
+ # last look (nothing held the exit for it: queue, a note-only task,
1314
+ # /exit) is a plain note for the next turn, not lost. Not into a
1315
+ # session /exit --delete deletes next: its save could land after the
1316
+ # delete and write the session back.
1317
+ @task_wakes.absorb unless deleting
1426
1318
  return result if result == :restart
1427
1319
 
1428
1320
  @discard = empty_session?
1429
- write_recap_on_leave unless @discard || (result == :exit_requested && @exit_deletes)
1321
+ write_recap_on_leave unless @discard || deleting
1430
1322
  result
1431
1323
  end
1432
1324
 
@@ -14,7 +14,8 @@ module Samagotchi
14
14
  # until the agent cancels them, so an exit would drop them for good;
15
15
  # * a continue offer waits for an answer; it too lives only in memory;
16
16
  # * a background task the worker watches for a finish wake (TaskWatch)
17
- # still runs: its end would go unnoticed after an exit (D3);
17
+ # still runs, or finished and waits for the next pass's wake: its end
18
+ # would go unnoticed after an exit (D3);
18
19
  # * less than the timeout has passed since the worker started, the Engine's
19
20
  # last activity, or the last client request or disconnect.
20
21
  #
@@ -23,6 +24,15 @@ module Samagotchi
23
24
  # the timeout and leaves out the asking client's own stream. A restart
24
25
  # (POST /exit restart: true) has its own rules, #hold_for_restart.
25
26
  class WorkerIdleExit
27
+ # What keeps the worker up when a client asks it to exit (Worker#exit_request,
28
+ # the Bridge's 409): +reason+, a #hold_for_request (or #hold_for_restart)
29
+ # Symbol, or :starting before this policy exists; for :background_tasks
30
+ # the watched tasks still running ({id:, command:}) and a +hint+ on
31
+ # stopping them.
32
+ Hold = Data.define(:reason, :tasks, :hint) do
33
+ def initialize(reason:, tasks: nil, hint: nil) = super
34
+ end
35
+
26
36
  # @param engine [Engine] #turn_running?, #last_activity_at, #reminder_store
27
37
  # @param bridge [Bridge, nil] #open_streams, #last_client_activity_at; nil
28
38
  # when the worker's Bridge failed to start
@@ -31,11 +41,15 @@ module Samagotchi
31
41
  # @param awaiting_continue [#call] true while a continue offer waits
32
42
  # @param running_tasks [#call] the background tasks this conversation
33
43
  # started that still run (Tools::TaskRuntime.running_created_in)
34
- # @param watched_tasks [#call] the ones a finish wake watches that still
35
- # run (TaskWatch#running): they hold the idle exit and /exit too
44
+ # @param watched_tasks [#call] the ones a finish wake would wake for,
45
+ # running or not yet taken (TaskWatch#holding): they hold the idle exit
46
+ # @param watched_running [#call] those of them still running
47
+ # (TaskWatch#running): they hold /exit (one that finished is a note
48
+ # as the worker leaves)
36
49
  # @param clock [#call] monotonic seconds, the Engine's and Bridge's clock
37
50
  def initialize(engine:, bridge:, timeout_minutes:, input_pending:, awaiting_continue: -> { false },
38
- running_tasks: -> { [] }, watched_tasks: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
51
+ running_tasks: -> { [] }, watched_tasks: -> { [] }, watched_running: -> { [] },
52
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
39
53
  @engine = engine
40
54
  @bridge = bridge
41
55
  @timeout = timeout_minutes.to_f * 60
@@ -43,6 +57,7 @@ module Samagotchi
43
57
  @awaiting_continue = awaiting_continue
44
58
  @running_tasks = running_tasks
45
59
  @watched_tasks = watched_tasks
60
+ @watched_running = watched_running
46
61
  @clock = clock
47
62
  @started_at = clock.call
48
63
  end
@@ -85,7 +100,7 @@ module Samagotchi
85
100
  return :continue_offered if @awaiting_continue.call
86
101
  return :client_connected if streams && @bridge && @bridge.open_streams_except(requester).positive?
87
102
  return :reminders if @engine.reminder_store&.any?
88
- return :background_tasks if @watched_tasks.call.any?
103
+ return :background_tasks if @watched_running.call.any?
89
104
 
90
105
  nil
91
106
  end
@@ -59,11 +59,11 @@ module Samagotchi
59
59
  # A task finish wake turn to run (TaskWatch, TaskFinishNote): its wake
60
60
  # note is in the conversation as the start of the turn +turn_id+;
61
61
  # +plain_note+ is the same note without the turn start or the ask, which
62
- # #unmark_wake puts back after a failed or restored wake turn. +ids+: the
63
- # finished tasks it runs for (the first one names it).
64
- TaskWake = Data.define(:ids, :turn_id, :plain_note) do
65
- def name = "task #{Tools::TaskRecord.short_id(ids.first)}"
66
- def origin = { client_id: "#{ClientId::TASK_PREFIX}#{ids.first}" }
62
+ # #unmark_wake puts back after a failed or restored wake turn. +id+: the
63
+ # finished task it runs for (the others finished with it are plain notes).
64
+ TaskWake = Data.define(:id, :turn_id, :plain_note) do
65
+ def name = "task #{Tools::TaskRecord.short_id(id)}"
66
+ def origin = { client_id: "#{ClientId::TASK_PREFIX}#{id}" }
67
67
  def note = plain_note
68
68
  end
69
69
 
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "child_reports"
6
+ require_relative "kernel_loop"
7
+ require_relative "log"
8
+ require_relative "task_finish_note"
9
+ require_relative "task_watch"
10
+ require_relative "tools/task_runtime"
11
+ require_relative "worker_inbound"
12
+ require_relative "worker_wakes"
13
+
14
+ module Samagotchi
15
+ # A worker's finish wake (docs/internals/background-tasks.md, Finish
16
+ # wake): its TaskWatch, fed by the Engine's raw events (#observe), and
17
+ # the TASK FINISHED notes for the tasks that finished unseen. #take
18
+ # writes them and hands back the wake turn to run, which the Worker runs
19
+ # (Worker#run_wake); #absorb gives them to a prompt's turn as plain notes.
20
+ # On the loop's thread, like WorkerInbound.
21
+ class WorkerTaskWakes
22
+ # @return [TaskWatch]
23
+ attr_reader :watch
24
+
25
+ # @param wakes [WorkerWakes] the wake budget a task's wake asks
26
+ # @param awaiting_continue [#call] whether a continue offer waits
27
+ # @param idle [#call] nothing queued and no exit asked for: a finish
28
+ # may start a turn
29
+ # @param save [#call] saves the session (a failure is the callee's to log)
30
+ def initialize(engine:, session:, wakes:, awaiting_continue:, idle:, save:)
31
+ @engine = engine
32
+ @session = session
33
+ @wakes = wakes
34
+ @awaiting_continue = awaiting_continue
35
+ @idle = idle
36
+ @save = save
37
+ @watch = TaskWatch.new
38
+ end
39
+
40
+ # The Engine's raw events feed the task watch (session.task_wakes not
41
+ # off): a result that started a task or showed its end, a turn's start,
42
+ # a user's Stop of it (M5).
43
+ def observe(event)
44
+ case event[:type]
45
+ when :tool_call_completed then @watch.observe(event) unless TaskWatch.mode == "off"
46
+ when :turn_started then @watch.turn_started
47
+ when :turn_canceled then @watch.turn_canceled(event[:cancellation_reason])
48
+ end
49
+ end
50
+
51
+ # Whether a finished task would start a wake turn now: the Engine skips
52
+ # the turn-end warm-up then (next_turn_waiting).
53
+ def due?
54
+ TaskWatch.mode == "wake" && @watch.any? && @wakes.task_open?(awaiting_continue: @awaiting_continue.call) &&
55
+ @watch.finished?
56
+ end
57
+
58
+ # The background tasks this session started that finished unseen
59
+ # (TaskWatch#take_finished), each as a TASK FINISHED note
60
+ # (TaskFinishNote): the first one not note-only starts a wake turn from
61
+ # task:<id> when session.task_wakes is wake, nothing is queued, no exit
62
+ # was asked for and the wake budget is open (WorkerWakes#task_open?);
63
+ # the others, and every one otherwise, are plain notes for the next
64
+ # turn (M2). A spent budget is announced once.
65
+ # @return [WorkerInbound::TaskWake, nil] the wake turn to run now
66
+ def take
67
+ return nil if @wakes.in_grace? || !@watch.any?
68
+
69
+ finished = take_finished
70
+ return nil if finished.empty?
71
+
72
+ waking = finished.find { |entry, _| !entry.note_only } if wake_open?
73
+ task_wake = note_finished(finished, waking: waking)
74
+ return nil if task_wake == :failed
75
+
76
+ @save.call
77
+ return task_wake if task_wake
78
+
79
+ budget_notice(finished.count { |entry, _| !entry.note_only }) if TaskWatch.mode == "wake" && @wakes.spent?
80
+ nil
81
+ end
82
+
83
+ # Before a prompt's turn: the finished tasks join it as plain notes.
84
+ def absorb
85
+ return unless @watch.any?
86
+
87
+ finished = take_finished
88
+ return if finished.empty?
89
+ return if note_finished(finished) == :failed
90
+
91
+ @save.call
92
+ end
93
+
94
+ private
95
+
96
+ # Each finished task's note into the conversation: +waking+'s as the
97
+ # wake turn's start, the others plain. A note that can't be written (a
98
+ # log that can't be read, say) puts it and the ones after it back in
99
+ # the watch for the next look.
100
+ # @return [WorkerInbound::TaskWake, nil, :failed]
101
+ def note_finished(finished, waking: nil)
102
+ written = []
103
+ (finished - [waking]).each do |entry, record|
104
+ add_note(entry, record)
105
+ written << entry
106
+ end
107
+ waking && add_wake_note(*waking)
108
+ rescue StandardError => e
109
+ Log.warn(:worker, "task_note_failed", error: e.class.name, msg: e.message)
110
+ @watch.put_back(finished.map(&:first) - written)
111
+ :failed
112
+ end
113
+
114
+ # Only the tasks the conversation still holds (a !rollback, a restored
115
+ # turn may have dropped the result that started one). A failure to
116
+ # read them is logged and counted (TaskWatch#look_failed: past
117
+ # TaskWatch::MAX_FAILURES they no longer hold the worker up): they wait
118
+ # for the next look.
119
+ def take_finished
120
+ @watch.take_finished(keep: Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint))
121
+ rescue StandardError => e
122
+ Log.warn(:worker, "task_watch_failed", error: e.class.name, msg: e.message)
123
+ @watch.look_failed
124
+ []
125
+ end
126
+
127
+ def wake_open?
128
+ TaskWatch.mode == "wake" && @idle.call && @wakes.task_open?(awaiting_continue: @awaiting_continue.call)
129
+ end
130
+
131
+ def note(entry, record, wake:, turn_id: nil)
132
+ TaskFinishNote.note(record, limit_sec: entry.limit_sec, wake: wake, turn_id: turn_id,
133
+ cap: KernelLoop.resolve_output_char_cap(nil))
134
+ end
135
+
136
+ def add_note(entry, record)
137
+ @engine.add_context_note(@session, note(entry, record, wake: false))
138
+ end
139
+
140
+ # The wake note, in the conversation as the wake turn's first message
141
+ # (before TurnFlow#before_prompt_turn, so a restore keeps it).
142
+ # @return [WorkerInbound::TaskWake]
143
+ def add_wake_note(entry, record)
144
+ turn_id = SecureRandom.uuid
145
+ @engine.add_context_note(@session, note(entry, record, wake: true, turn_id: turn_id))
146
+ WorkerInbound::TaskWake.new(id: entry.id, turn_id: turn_id, plain_note: note(entry, record, wake: false))
147
+ end
148
+
149
+ # The wake budget is spent: the UIs hear once that finished tasks wait
150
+ # as notes (one notice per stretch, shared with the other kinds).
151
+ def budget_notice(count)
152
+ return unless count.positive? && @wakes.notice_budget!
153
+
154
+ @engine.announce(type: :hook_notice, hook: "task", level: :info, between_turns: true,
155
+ text: "#{count} finished background task#{"s" if count != 1} noted; the model reads " \
156
+ "#{count == 1 ? "it" : "them"} with your next message " \
157
+ "(#{@wakes.max} wake turn#{"s" if @wakes.max != 1} in a row, #{ChildRing::MAX_WAKES_KEY})")
158
+ end
159
+ end
160
+ end
@@ -117,9 +117,6 @@ module Samagotchi
117
117
  !@paused && !awaiting_continue && !in_grace? && @in_a_row < max
118
118
  end
119
119
 
120
- # Whether wakes are paused (a failed wake turn) until a human's input.
121
- def paused? = @paused
122
-
123
120
  # A wake turn starts; +peer+: for a peer's answer, which counts toward
124
121
  # both budgets. @return [Integer] the wake turns in a row now
125
122
  def count!(peer: false)