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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/README.md +5 -2
- data/bin/chi +8 -8
- data/docs/architecture.md +20 -16
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -5
- data/docs/configuration.md +3 -1
- data/docs/context.md +5 -3
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +27 -8
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bridge.rb +8 -9
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +26 -26
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +7 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +51 -190
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +34 -103
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +60 -35
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/task_watch.rb +64 -21
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +33 -20
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +130 -554
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +77 -185
- data/lib/samagotchi/worker_idle_exit.rb +20 -5
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +0 -3
- 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 ===
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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? ||
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
535
|
-
# peer's answer toward context.peer_turns too,
|
|
536
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
#
|
|
670
|
-
#
|
|
671
|
-
#
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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:
|
|
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
|
-
|
|
790
|
-
|
|
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(
|
|
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
|
|
806
|
-
#
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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 [
|
|
1370
|
-
# :background_tasks,
|
|
1371
|
-
#
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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 ||
|
|
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
|
|
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
|
|
35
|
-
#
|
|
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: -> { [] },
|
|
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 @
|
|
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. +
|
|
63
|
-
# finished
|
|
64
|
-
TaskWake = Data.define(:
|
|
65
|
-
def name = "task #{Tools::TaskRecord.short_id(
|
|
66
|
-
def origin = { client_id: "#{ClientId::TASK_PREFIX}#{
|
|
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
|
|