samagotchi 0.89.0 → 0.91.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +28 -3
  4. data/docs/architecture.md +27 -5
  5. data/docs/bundle-authoring.md +36 -36
  6. data/docs/cli.md +4 -1
  7. data/docs/configuration.md +5 -3
  8. data/docs/internals/background-tasks.md +27 -2
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -0
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge/client_event.rb +15 -7
  14. data/lib/samagotchi/bridge.rb +7 -2
  15. data/lib/samagotchi/bundle_command/try/trace.rb +25 -8
  16. data/lib/samagotchi/bundle_command.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +5 -0
  19. data/lib/samagotchi/client_id.rb +3 -0
  20. data/lib/samagotchi/config.rb +3 -0
  21. data/lib/samagotchi/context_note.rb +13 -6
  22. data/lib/samagotchi/engine.rb +9 -3
  23. data/lib/samagotchi/last_turn.rb +3 -2
  24. data/lib/samagotchi/llm/model_result.rb +2 -2
  25. data/lib/samagotchi/task_finish_note.rb +90 -0
  26. data/lib/samagotchi/task_watch.rb +185 -0
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
  28. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  29. data/lib/samagotchi/tool_declarations.rb +16 -0
  30. data/lib/samagotchi/tool_runner.rb +1 -0
  31. data/lib/samagotchi/tools/builtins.rb +5 -1
  32. data/lib/samagotchi/tools/task_record.rb +9 -0
  33. data/lib/samagotchi/tools/task_wait.rb +10 -3
  34. data/lib/samagotchi/version.rb +1 -1
  35. data/lib/samagotchi/web/public/notify.js +4 -4
  36. data/lib/samagotchi/web/public/turn_events.js +16 -8
  37. data/lib/samagotchi/worker.rb +177 -7
  38. data/lib/samagotchi/worker_idle_exit.rb +11 -3
  39. data/lib/samagotchi/worker_inbound.rb +18 -4
  40. data/lib/samagotchi/worker_wakes.rb +14 -4
  41. metadata +3 -1
@@ -185,13 +185,17 @@ module Samagotchi
185
185
  # description parameter (execute.description off)
186
186
  # @param policy [String, nil] llm_context.policy, the sentence
187
187
  # forget_outputs' description carries
188
+ # @param task_finish_notes [Boolean] a worker's session whose finish
189
+ # wake reports a task left running (session.task_wakes not off):
190
+ # execute and task_create say so (ToolDeclarations::TASK_FINISH_LINE)
188
191
  # @return [Registry] a new registry with the built-ins, in
189
192
  # TOOL_SCHEMAS order (an Engine's own, which bundles add to), then
190
193
  # forget_outputs, offered only under the forget layer
191
- def registry(command_description: true, policy: ToolDeclarations::FORGET_OUTPUTS_POLICY)
194
+ def registry(command_description: true, policy: ToolDeclarations::FORGET_OUTPUTS_POLICY, task_finish_notes: false)
192
195
  by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
193
196
  registry = ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, built|
194
197
  schema = ToolDeclarations.without_command_description(schema) unless command_description
198
+ schema = ToolDeclarations.with_task_finish_line(schema) if task_finish_notes
195
199
  klass = by_name.fetch(schema[:name])
196
200
  handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
197
201
  built.register(klass::NAME, schema: schema, handler: handler)
@@ -44,6 +44,15 @@ module Samagotchi
44
44
  to_h.reject { |key, value| value.nil? && self.class::OPTIONAL.include?(key) }.transform_keys(&:to_s)
45
45
  end
46
46
 
47
+ # A task id as people read it: the random part a task_create id ends
48
+ # with (TaskRuntime.generate_task_id starts it with the time, the same
49
+ # for every task that second), else its first 8 chars. The web's
50
+ # turn_events.js has the JS twin (shortTaskId).
51
+ def self.short_id(id)
52
+ id = id.to_s
53
+ id[/-(\h{8})\z/, 1] || id[0, 8]
54
+ end
55
+
47
56
  def running? = status == "running"
48
57
 
49
58
  def stopped_by_user? = stop_reason == "stopped_by_user"
@@ -80,12 +80,19 @@ module Samagotchi
80
80
  end
81
81
  private_class_method :cancelled_response
82
82
 
83
- # Always give the log's last lines: a test run's summary, an error. A
84
- # task the user stopped also says so.
85
- def self.finished_response(record, tail_lines)
83
+ # A finished task's text, as task_wait gives it: its status lines and
84
+ # always the log's last lines (a test run's summary, an error), then a
85
+ # stderr_tail when the record has a non-empty one. A task the user
86
+ # stopped also says so. Public: a task finish wake's note
87
+ # (TaskFinishNote) carries the same text.
88
+ # @param record [TaskRecord] a finished one
89
+ # @return [String] a RefText of its output_path when it has one
90
+ def self.finished_text(record, tail_lines: TAIL_LINES_DEFAULT)
86
91
  extra = record.stopped_by_user? ? [TaskRuntime::USER_STOP_NOTE] : []
87
92
  format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines, extra: extra)
88
93
  end
94
+
95
+ def self.finished_response(record, tail_lines) = finished_text(record, tail_lines: tail_lines)
89
96
  private_class_method :finished_response
90
97
 
91
98
  def self.output_tail(record, tail_lines)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.89.0"
4
+ VERSION = "0.91.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -15,11 +15,11 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
15
15
  }
16
16
 
17
17
  // A finished turn shorter than this was watched: no "done". Not a turn
18
- // chi ran for a delegate child's report (last_turn origin delegate_report)
19
- // or an attached context change (context): nobody started it, so nobody
20
- // watched it.
18
+ // chi ran for a delegate child's report (last_turn origin delegate_report),
19
+ // an attached context change (context) or a finished background task
20
+ // (task): nobody started it, so nobody watched it.
21
21
  const DONE_MIN_SECONDS = 10;
22
- const UNWATCHED_ORIGINS = ["delegate_report", "context"];
22
+ const UNWATCHED_ORIGINS = ["delegate_report", "context", "task"];
23
23
 
24
24
  // The step-limit question (kind "continue": a turn ran out of iterations
25
25
  // and asks whether to go on) has its own reason: "out of steps".
@@ -3,15 +3,11 @@
3
3
 
4
4
  import { ACTIVITY_FIELDS, EVENT_FIELDS, pickFields } from "./activity.js";
5
5
 
6
- // The answer text of a :turn_completed event. `turn_summary.output` is the
7
- // contract; `result` is the KernelLoop result, which reaches the wire as its
8
- // string form (older servers, other clients' fixtures).
6
+ // Read turn_summary.output; Bridge::ClientEvent omits result for clients.
9
7
  export function turnOutput(data) {
10
8
  if (!data) return "";
11
- const summary = data.turn_summary?.output;
12
- const result = typeof data.result === "string" ? data.result : data.result?.output;
13
- const out = [summary, result, data.content, data.output].find((v) => typeof v === "string" && v.trim());
14
- return out ? out.trim() : "";
9
+ const output = data.turn_summary?.output;
10
+ return typeof output === "string" ? output.trim() : "";
15
11
  }
16
12
 
17
13
  // The end line for a turn whose worker went away mid-turn (the footer's
@@ -111,13 +107,25 @@ export function isReportClient(clientId) {
111
107
 
112
108
  // A turn chi ran because an attached context source changed: context:<name>.
113
109
  const CONTEXT_CLIENT = "context:";
110
+ // A turn chi ran because a background task the session started finished
111
+ // unseen: task:<task id> (the Worker's finish wake).
112
+ const TASK_CLIENT = "task:";
113
+
114
+ // A task id as people read it: the random part a task_create id ends with
115
+ // (it starts with the time), else its first 8 chars (TaskRecord.short_id).
116
+ function shortTaskId(id) {
117
+ const m = /-([0-9a-f]{8})$/i.exec(id);
118
+ return m ? m[1] : id.slice(0, 8);
119
+ }
114
120
 
115
121
  // The label on another client's prompt, by its client_id prefix; a context
116
- // change's turn as "context <name> changed".
122
+ // change's turn as "context <name> changed", a finished task's as
123
+ // "task <id8> finished".
117
124
  export function clientLabel(clientId) {
118
125
  if (!clientId) return null;
119
126
  const id = String(clientId);
120
127
  if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
128
+ if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
121
129
  const colon = id.indexOf(":");
122
130
  return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
123
131
  }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
4
5
 
5
6
  require_relative "client_id"
6
7
  require_relative "config"
@@ -31,6 +32,9 @@ require_relative "child_reports"
31
32
  require_relative "child_move"
32
33
  require_relative "context_absorber"
33
34
  require_relative "context_poller"
35
+ require_relative "kernel_loop"
36
+ require_relative "task_finish_note"
37
+ require_relative "task_watch"
34
38
  require_relative "tools/task_runtime"
35
39
 
36
40
  module Samagotchi
@@ -74,6 +78,8 @@ module Samagotchi
74
78
  # How much of a command's output goes into its :command_ran.
75
79
  COMMAND_OUTPUT_LIMIT = 4096
76
80
  BUSY_OUTPUT = "busy: wait for the turn to end"
81
+ # /exit held by background tasks a finish wake waits for: how to end them.
82
+ TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
77
83
  # !rollback mid-turn would mean "the turn I'm in".
78
84
  ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
79
85
  # How long a signalled worker's turn has to end (it is canceled at once,
@@ -242,7 +248,7 @@ module Samagotchi
242
248
  # a command queued mid-turn (/thinking, /model, /llm-context) changes
243
249
  # what it starts with: no turn-end warm-up then.
244
250
  @engine.next_turn_waiting = lambda {
245
- !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due?
251
+ !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due? || task_wake_due?
246
252
  }
247
253
  @turn_flow = TurnFlow.new(engine: @engine)
248
254
  # Its question's answer is queued as the matching /continue (the
@@ -258,6 +264,9 @@ module Samagotchi
258
264
  # The seq of the last turn's end event: a command queued before it was
259
265
  # queued while that turn ran.
260
266
  @turn_end_seq = 0
267
+ # The background tasks this session started and hasn't seen finish:
268
+ # one that finishes unseen wakes the idle session (#run_task_wake).
269
+ @task_watch = TaskWatch.new
261
270
  @engine.subscribe(observer: lambda { |event|
262
271
  if Events::TURN_END.include?(event[:type])
263
272
  @turn_end_seq = event[:event_seq]
@@ -269,6 +278,7 @@ module Samagotchi
269
278
  @turn_ending = false
270
279
  end
271
280
  ring_question(event) if event[:type] == :question_requested
281
+ watch_tasks(event)
272
282
  })
273
283
  # This session's own delegate children's news (rings in children/).
274
284
  @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir,
@@ -315,7 +325,10 @@ module Samagotchi
315
325
  timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
316
326
  input_pending: -> { !@command_queue.empty? || !SessionInbox.find_new_input_files(@session_dir).empty? },
317
327
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
318
- running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) }
328
+ running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) },
329
+ # 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 : [] }
319
332
  )
320
333
  end
321
334
 
@@ -371,12 +384,13 @@ module Samagotchi
371
384
  run_input_files(input_files)
372
385
  end
373
386
 
374
- # Nothing queued: a delegate wake turn or a due reminder runs, else
375
- # the worker leaves when the idle exit or a client's exit is due, else
376
- # it sleeps until woken or the fallback tick.
387
+ # Nothing queued: a delegate wake turn, a task finish wake turn or a due
388
+ # reminder runs, else the worker leaves when the idle exit or a client's
389
+ # exit is due, else it sleeps until woken or the fallback tick.
377
390
  # @return [Symbol, nil] as #pass
378
391
  def idle_pass
379
392
  return if run_wake_turn
393
+ return if run_task_wake
380
394
  return if run_due_reminders
381
395
  return left(:idle_exit) if @idle_exit.due? && leave_idle
382
396
  return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
@@ -441,6 +455,9 @@ module Samagotchi
441
455
  memories: @session.preloaded_memory_names,
442
456
  muted_memories: @session.muted_memory_names,
443
457
  appended_prompt_files: @session.appended_prompt_files,
458
+ # The finish wake reports a task the model leaves running: the tool
459
+ # descriptions say so, fixed for this worker (a stable prompt prefix).
460
+ task_finish_notes: TaskWatch.mode != "off",
444
461
  reminders: {
445
462
  callback: lambda { |due_names|
446
463
  # A reminder is due: the loop runs a reminder turn for it once
@@ -552,8 +569,10 @@ module Samagotchi
552
569
  # @param prompt [WorkerInbound::Prompt]
553
570
  def run_prompt(prompt)
554
571
  origin = prompt.origin
555
- # A first turn sees the context attached before it.
572
+ # A first turn sees the context attached before it, and any turn the
573
+ # background tasks that finished since the last look.
556
574
  absorb_context(before_turn: true)
575
+ absorb_task_notes
557
576
  user_input(origin&.dig(:client_id))
558
577
  @continue_offer.drop(origin)
559
578
  @turn_flow.before_prompt_turn
@@ -647,6 +666,153 @@ module Samagotchi
647
666
  @wakes.drop_handed
648
667
  end
649
668
 
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!)
784
+ @turn_flow.before_prompt_turn
785
+ restores = @turn_flow.restores
786
+ run_engine_turn(nil, continue: true, origin: task_wake.origin, id: task_wake.turn_id,
787
+ max_iterations: IterationLimit.for) do |result, error|
788
+ 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
794
+ @wakes.pause!
795
+ else
796
+ @continue_offer.after_turn(result)
797
+ if result.canceled? && @turn_flow.restores != restores
798
+ @inbound.unmark_wake(task_wake)
799
+ @wakes.pause!
800
+ end
801
+ end
802
+ end
803
+ end
804
+
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!
809
+
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})")
814
+ end
815
+
650
816
  # The idle loop's sleep: the fallback tick, or less while reports wait
651
817
  # out the start grace.
652
818
  def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
@@ -1200,12 +1366,16 @@ module Samagotchi
1200
1366
  # +delete+: the session is deleted after (/exit --delete), so no recap.
1201
1367
  # +restart+: a new worker takes over (on the newest chi installed), so
1202
1368
  # WorkerIdleExit#hold_for_restart's rules, and no recap or discard.
1203
- # @return [Symbol, nil] what keeps the worker up, nil when it will leave
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
1204
1372
  def exit_request(client_id, delete: false, restart: false)
1205
1373
  # The Bridge serves before the idle-exit policy exists.
1206
1374
  return :starting unless @idle_exit
1207
1375
 
1208
1376
  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
1209
1379
  return hold if hold
1210
1380
 
1211
1381
  @exit_requested = true
@@ -13,6 +13,8 @@ module Samagotchi
13
13
  # * a reminder is registered. Reminders live only in the Engine and repeat
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
+ # * a background task the worker watches for a finish wake (TaskWatch)
17
+ # still runs: its end would go unnoticed after an exit (D3);
16
18
  # * less than the timeout has passed since the worker started, the Engine's
17
19
  # last activity, or the last client request or disconnect.
18
20
  #
@@ -29,15 +31,18 @@ module Samagotchi
29
31
  # @param awaiting_continue [#call] true while a continue offer waits
30
32
  # @param running_tasks [#call] the background tasks this conversation
31
33
  # 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
32
36
  # @param clock [#call] monotonic seconds, the Engine's and Bridge's clock
33
37
  def initialize(engine:, bridge:, timeout_minutes:, input_pending:, awaiting_continue: -> { false },
34
- running_tasks: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
38
+ running_tasks: -> { [] }, watched_tasks: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
35
39
  @engine = engine
36
40
  @bridge = bridge
37
41
  @timeout = timeout_minutes.to_f * 60
38
42
  @input_pending = input_pending
39
43
  @awaiting_continue = awaiting_continue
40
44
  @running_tasks = running_tasks
45
+ @watched_tasks = watched_tasks
41
46
  @clock = clock
42
47
  @started_at = clock.call
43
48
  end
@@ -49,7 +54,8 @@ module Samagotchi
49
54
 
50
55
  # What keeps the worker up, or nil when nothing does.
51
56
  # @return [Symbol, nil] :disabled, :turn_running, :input_queued,
52
- # :continue_offered, :client_connected, :reminders or :recent_activity
57
+ # :continue_offered, :client_connected, :reminders, :background_tasks
58
+ # or :recent_activity
53
59
  def hold
54
60
  return :disabled unless @timeout.positive?
55
61
  return :turn_running if @engine.turn_running?
@@ -57,6 +63,7 @@ module Samagotchi
57
63
  return :continue_offered if @awaiting_continue.call
58
64
  return :client_connected if @bridge && @bridge.open_streams.positive?
59
65
  return :reminders if @engine.reminder_store&.any?
66
+ return :background_tasks if @watched_tasks.call.any?
60
67
  return :recent_activity if idle_seconds < @timeout
61
68
 
62
69
  nil
@@ -71,13 +78,14 @@ module Samagotchi
71
78
  # either (the last check before leaving, when the asker's stream may
72
79
  # still be open, untagged)
73
80
  # @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
74
- # :client_connected or :reminders
81
+ # :client_connected, :reminders or :background_tasks
75
82
  def hold_for_request(requester:, streams: true)
76
83
  return :turn_running if @engine.turn_running?
77
84
  return :input_queued if @input_pending.call
78
85
  return :continue_offered if @awaiting_continue.call
79
86
  return :client_connected if streams && @bridge && @bridge.open_streams_except(requester).positive?
80
87
  return :reminders if @engine.reminder_store&.any?
88
+ return :background_tasks if @watched_tasks.call.any?
81
89
 
82
90
  nil
83
91
  end
@@ -11,6 +11,7 @@ require_relative "context_note"
11
11
  require_relative "log"
12
12
  require_relative "session"
13
13
  require_relative "session_inbox"
14
+ require_relative "tools/task_record"
14
15
  require_relative "worker_wakes"
15
16
 
16
17
  module Samagotchi
@@ -51,6 +52,19 @@ module Samagotchi
51
52
  # that agrees closes the round and counts toward session.max_wakes only.
52
53
  def peer_turn? = peer? && !delivery.agreed
53
54
  def origin = { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" }
55
+ # The plain note #unmark_wake puts back.
56
+ def note = delivery.note
57
+ end
58
+
59
+ # A task finish wake turn to run (TaskWatch, TaskFinishNote): its wake
60
+ # note is in the conversation as the start of the turn +turn_id+;
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}" }
67
+ def note = plain_note
54
68
  end
55
69
 
56
70
  # A save between turns (the turn's own, a command's, the notes', the
@@ -144,13 +158,13 @@ module Samagotchi
144
158
 
145
159
  # A failed wake turn kept nothing but its note: the note goes back to
146
160
  # a plain note (no turn start, the background wording), so a reload
147
- # draws no empty turn for it.
148
- def unmark_wake(context_wake)
161
+ # draws no empty turn for it. +wake+: a ContextWake or a TaskWake.
162
+ def unmark_wake(wake)
149
163
  messages = @engine.messages_checkpoint
150
- index = messages.index { |m| m[:turn_start] && m[:turn_id] == context_wake.turn_id }
164
+ index = messages.index { |m| m[:turn_start] && m[:turn_id] == wake.turn_id }
151
165
  return unless index
152
166
 
153
- messages[index] = ContextNote.message(**context_wake.delivery.note)
167
+ messages[index] = ContextNote.message(**wake.note)
154
168
  @engine.rollback_to(messages)
155
169
  end
156
170
 
@@ -6,10 +6,10 @@ require_relative "child_reports"
6
6
 
7
7
  module Samagotchi
8
8
  # A worker's wake budget: turns nobody typed (a delegate report's wake
9
- # turn, an attached context source's) run only past the start grace, at
10
- # most session.max_wakes in a row with no human input, and not after a
11
- # failed one until a human's input (a provider outage doesn't loop).
12
- # Shared by both kinds of wake; the Worker asks it before each and tells
9
+ # turn, a finished background task's, an attached context source's) run
10
+ # only past the start grace, at most session.max_wakes in a row with no
11
+ # human input, and not after a failed one until a human's input (a
12
+ # provider outage doesn't loop). Shared by every kind of wake; the Worker asks it before each and tells
13
13
  # it what happened. It also holds the reports a delegate wake turn was
14
14
  # started for until the turn's first boundary takes them (#hand_over,
15
15
  # #take_handed).
@@ -110,6 +110,16 @@ module Samagotchi
110
110
  true
111
111
  end
112
112
 
113
+ # For a background task that finished unseen (TaskWatch): no continue
114
+ # offer, not paused, past the start grace and under the budget. #count!
115
+ # when its wake turn starts.
116
+ def task_open?(awaiting_continue:)
117
+ !@paused && !awaiting_continue && !in_grace? && @in_a_row < max
118
+ end
119
+
120
+ # Whether wakes are paused (a failed wake turn) until a human's input.
121
+ def paused? = @paused
122
+
113
123
  # A wake turn starts; +peer+: for a peer's answer, which counts toward
114
124
  # both budgets. @return [Integer] the wake turns in a row now
115
125
  def count!(peer: false)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.89.0
4
+ version: 0.91.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -520,6 +520,8 @@ files:
520
520
  - lib/samagotchi/steer_cut.rb
521
521
  - lib/samagotchi/steer_cut_after.rb
522
522
  - lib/samagotchi/system_prompt.rb
523
+ - lib/samagotchi/task_finish_note.rb
524
+ - lib/samagotchi/task_watch.rb
523
525
  - lib/samagotchi/terminal_ui.rb
524
526
  - lib/samagotchi/terminal_ui/attach_launcher.rb
525
527
  - lib/samagotchi/terminal_ui/attached_loop.rb