samagotchi 0.91.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -36,19 +36,26 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
36
36
  entry.el);
37
37
  }
38
38
 
39
+ function item(text, action, sticky) {
40
+ const entry = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
+ entry.el.className = "toast-item";
42
+ draw(entry, text, action, sticky);
43
+ entry.key = entry.el.querySelector(".toast-message").textContent;
44
+ return entry;
45
+ }
46
+
39
47
  function show(text, action = null, { sticky = false } = {}) {
40
- const candidate = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
- candidate.el.className = "toast-item";
42
- draw(candidate, text, action, sticky);
43
- candidate.key = candidate.el.querySelector(".toast-message").textContent;
48
+ // Words are their own key; a template's key is its drawn text, so only
49
+ // a template is drawn before the look-up.
50
+ const candidate = typeof text === "string" ? null : item(text, action, sticky);
51
+ const key = candidate ? candidate.key : text;
44
52
 
45
- const duplicate = entries.find((entry) => entry.key === candidate.key);
53
+ const duplicate = entries.find((entry) => entry.key === key);
46
54
  if (duplicate) {
47
55
  if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
56
  duplicate.timer = null;
49
57
  duplicate.sticky = sticky || duplicate.sticky;
50
58
  duplicate.action = action || duplicate.action;
51
- duplicate.key = candidate.key;
52
59
  draw(duplicate, text, duplicate.action, duplicate.sticky);
53
60
  entries.push(...entries.splice(entries.indexOf(duplicate), 1));
54
61
  el.appendChild(duplicate.el);
@@ -60,9 +67,10 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
60
67
  if (entries.length === MAX_TOASTS) {
61
68
  remove(entries.find((entry) => !entry.sticky) || entries[0], false);
62
69
  }
63
- entries.push(candidate);
64
- el.appendChild(candidate.el);
65
- if (!sticky) candidate.timer = setTimeout(() => remove(candidate), TOAST_MS);
70
+ const entry = candidate || item(text, action, sticky);
71
+ entries.push(entry);
72
+ el.appendChild(entry.el);
73
+ if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
66
74
  sync();
67
75
  }
68
76
 
@@ -4,8 +4,9 @@
4
4
  // goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
5
5
  // here at the 200 already. Its text comes back (edit): into an empty
6
6
  // composer, to change and send again; over a draft only in a toast (the
7
- // draft stays). Its images don't come back. A refusal keeps it and says why
8
- // in a toast. No confirm: nothing is lost. Pure but for the
7
+ // draft stays), and after a switch to another session meanwhile only in a
8
+ // sticky toast naming its session, with Copy. Its images don't come back. A refusal keeps it
9
+ // and says why in a toast. No confirm: nothing is lost. Pure but for the
9
10
  // injected calls, so `node --test` covers it; app.js binds onClick to the
10
11
  // turn roots.
11
12
  import { dequeueTurn as postDequeue } from "./data.js";
@@ -28,8 +29,9 @@ function clipped(text) {
28
29
 
29
30
  // +sessionId+ is read at each click (the open session); +clientId+ is this
30
31
  // tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
31
- // composer when it is empty and says whether it did.
32
- export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false }) {
32
+ // composer when it is empty and says whether it did; +copy(text)+ says
33
+ // whether the clipboard took it; +toast(text, action, options)+ as showToast.
34
+ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false, copy = null }) {
33
35
  // A click on a queued bubble's ×; any other click is left alone.
34
36
  async function onClick(e) {
35
37
  const button = e.target?.closest?.("button.unqueue");
@@ -44,6 +46,12 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
44
46
  const result = await dequeue(id, enqueuedId, { clientId });
45
47
  removeQueuedBubble(bubble);
46
48
  const text = String(result?.prompt || "");
49
+ // Another session opened meanwhile: its composer isn't where this
50
+ // text goes, so only the toast has it.
51
+ if (text.trim() && sessionId() !== id) {
52
+ movedOn(id, text);
53
+ return result;
54
+ }
47
55
  if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
48
56
  return result;
49
57
  } catch (error) {
@@ -52,5 +60,13 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
52
60
  return null;
53
61
  }
54
62
  }
63
+ // The removed text after a switch: in a sticky toast, its Copy takes the
64
+ // whole text (the toast clips it); a failed copy shows the toast again.
65
+ function movedOn(id, text) {
66
+ const line = `Removed from ${id.slice(0, 8)}: ${clipped(text)}`;
67
+ const show = () => toast(line, copy ? action : null, { sticky: true });
68
+ const action = { label: "Copy", run: async () => { if (await copy(text)) toast("Copied"); else show(); } };
69
+ show();
70
+ }
55
71
  return { onClick };
56
72
  }
@@ -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