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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -1
- data/bin/chi +28 -3
- data/docs/architecture.md +27 -5
- data/docs/bundle-authoring.md +36 -36
- data/docs/cli.md +4 -1
- data/docs/configuration.md +5 -3
- data/docs/internals/background-tasks.md +27 -2
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +2 -2
- data/docs/internals/prompt-caching.md +4 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/client_event.rb +15 -7
- data/lib/samagotchi/bridge.rb +7 -2
- data/lib/samagotchi/bundle_command/try/trace.rb +25 -8
- data/lib/samagotchi/bundle_command.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +5 -0
- data/lib/samagotchi/client_id.rb +3 -0
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_note.rb +13 -6
- data/lib/samagotchi/engine.rb +9 -3
- data/lib/samagotchi/last_turn.rb +3 -2
- data/lib/samagotchi/llm/model_result.rb +2 -2
- data/lib/samagotchi/task_finish_note.rb +90 -0
- data/lib/samagotchi/task_watch.rb +185 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/tool_declarations.rb +16 -0
- data/lib/samagotchi/tool_runner.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +5 -1
- data/lib/samagotchi/tools/task_record.rb +9 -0
- data/lib/samagotchi/tools/task_wait.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +4 -4
- data/lib/samagotchi/web/public/turn_events.js +16 -8
- data/lib/samagotchi/worker.rb +177 -7
- data/lib/samagotchi/worker_idle_exit.rb +11 -3
- data/lib/samagotchi/worker_inbound.rb +18 -4
- data/lib/samagotchi/worker_wakes.rb +14 -4
- 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
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
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)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
12
|
-
|
|
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
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|
|
375
|
-
# the worker leaves when the idle exit or a client's
|
|
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
|
|
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
|
|
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 :
|
|
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(
|
|
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] ==
|
|
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(**
|
|
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
|
|
10
|
-
# most session.max_wakes in a row with no
|
|
11
|
-
# failed one until a human's input (a
|
|
12
|
-
# Shared by
|
|
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.
|
|
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
|