samagotchi 0.60.0 → 0.62.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 +99 -1
- data/docs/architecture.md +10 -4
- data/docs/cli.md +22 -8
- data/docs/configuration.md +32 -19
- data/docs/context.md +8 -0
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +15 -3
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- data/lib/samagotchi/web/public/chains.js +18 -20
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/index.html +66 -13
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +60 -5
- data/lib/samagotchi/web/public/ref_menu.js +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -4
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +8 -1
|
@@ -22,6 +22,7 @@ require_relative "turn_note"
|
|
|
22
22
|
require_relative "owner_lock"
|
|
23
23
|
require_relative "bridge_client"
|
|
24
24
|
require_relative "worker_sidecar"
|
|
25
|
+
require_relative "worker_signals"
|
|
25
26
|
require_relative "log"
|
|
26
27
|
require_relative "log_path"
|
|
27
28
|
require_relative "model_profile"
|
|
@@ -210,7 +211,8 @@ module Samagotchi
|
|
|
210
211
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
211
212
|
private_class_method def self.spawn_options(session)
|
|
212
213
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
213
|
-
# terminal must not reach them
|
|
214
|
+
# terminal must not reach them (the worker then leaves the process
|
|
215
|
+
# tree too: WORKER_DETACH_ENV).
|
|
214
216
|
opts = { out: File::NULL, err: File::NULL, pgroup: true }
|
|
215
217
|
# The worker's tools (and `!cmd`) run in the session's directory, not
|
|
216
218
|
# in the cwd of whoever woke it (`chi web`, another terminal).
|
|
@@ -253,6 +255,10 @@ module Samagotchi
|
|
|
253
255
|
SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
|
|
254
256
|
# A smoke's fake "newest installed" is for this process's notices only.
|
|
255
257
|
child_env[InstalledVersions::ENV_KEY] = nil
|
|
258
|
+
# A worker process: run_session_loop traps signals, and leaves this
|
|
259
|
+
# process's tree.
|
|
260
|
+
child_env[WORKER_PROCESS_ENV] = "1"
|
|
261
|
+
child_env[WORKER_DETACH_ENV] = "1"
|
|
256
262
|
# A gem-mode worker activates the newest installed chi itself: under
|
|
257
263
|
# `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
|
|
258
264
|
# hold it to the bundle's version, and its badge would never clear.
|
|
@@ -688,8 +694,8 @@ module Samagotchi
|
|
|
688
694
|
# so a resume right after spawns a fresh worker instead of finding the
|
|
689
695
|
# dying one.
|
|
690
696
|
# A turn still running is canceled first (STOP_CANCEL_WAIT), as the
|
|
691
|
-
# web's Cancel does: its
|
|
692
|
-
#
|
|
697
|
+
# web's Cancel does: its worker saves it. The TERM stops (and saves)
|
|
698
|
+
# what still runs (WorkerSignals), with a short grace after the marker.
|
|
693
699
|
# @return [Boolean, nil] with +wait+: whether the owner was gone in time
|
|
694
700
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
695
701
|
def self.stop_session(session_id, state_dir: nil, wait: nil)
|
|
@@ -930,7 +936,8 @@ module Samagotchi
|
|
|
930
936
|
# @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
|
|
931
937
|
# @param poll_interval [Numeric, nil] seconds between the loop's fallback
|
|
932
938
|
# ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
|
|
933
|
-
# A stopped session's worker exits 0
|
|
939
|
+
# A stopped session's worker exits 0 (a signalled one too) and a
|
|
940
|
+
# crashed one 1, there.
|
|
934
941
|
# @return [Symbol] :idle_exit, :exit_requested or :restart
|
|
935
942
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
936
943
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
@@ -942,6 +949,12 @@ module Samagotchi
|
|
|
942
949
|
Log.configure(stderr: false)
|
|
943
950
|
Log.session_id = session_id
|
|
944
951
|
session_dir = Session.session_dir(session_id, state_dir: sd)
|
|
952
|
+
# Read (and taken out of ENV, so nothing the worker runs inherits it)
|
|
953
|
+
# before anything else: set by spawn_options for a spawned worker.
|
|
954
|
+
spawned = ENV.delete(WORKER_PROCESS_ENV) == "1"
|
|
955
|
+
# Out of the spawner's process tree before the lock (it records this
|
|
956
|
+
# process's pid) and before any thread starts.
|
|
957
|
+
detach_from_spawner(session_dir) if ENV.delete(WORKER_DETACH_ENV) == "1"
|
|
945
958
|
# Kept in a class ivar so the lock's File lives as long as the worker.
|
|
946
959
|
@owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
|
|
947
960
|
exit(0) unless @owner_lock
|
|
@@ -949,8 +962,11 @@ module Samagotchi
|
|
|
949
962
|
fallback = ENV.delete(BOOT_FALLBACK_ENV)
|
|
950
963
|
Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
|
|
951
964
|
Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
|
|
965
|
+
# A spawned worker stops its turn on TERM/HUP/INT and saves it; a
|
|
966
|
+
# loop run in-process (specs) traps nothing.
|
|
967
|
+
signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
|
|
952
968
|
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
953
|
-
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
|
|
969
|
+
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals)
|
|
954
970
|
result = begin
|
|
955
971
|
worker.run
|
|
956
972
|
rescue StandardError, ScriptError => e
|
|
@@ -959,11 +975,19 @@ module Samagotchi
|
|
|
959
975
|
raise
|
|
960
976
|
ensure
|
|
961
977
|
@owner_lock.release
|
|
978
|
+
# A signal from here on ends the process as Ruby's default does.
|
|
979
|
+
signals.restore!
|
|
962
980
|
end
|
|
963
981
|
Log.info(:worker, "stop", reason: result)
|
|
964
982
|
# A stopped session is left as it is: no resume for input that came
|
|
965
|
-
# in, no discard.
|
|
983
|
+
# in, no discard. One whose worker a signal stopped isn't discarded
|
|
984
|
+
# either; input that came in (queued behind the turn it stopped)
|
|
985
|
+
# wakes a new worker, as after an idle exit.
|
|
966
986
|
exit(0) if result == :stopped
|
|
987
|
+
if result == :signal
|
|
988
|
+
resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
|
|
989
|
+
exit(0)
|
|
990
|
+
end
|
|
967
991
|
exit(1) if result == :crashed
|
|
968
992
|
# Only after the release: a writer that still saw this worker as the
|
|
969
993
|
# owner may have queued input since the last check. Either it finds no
|
|
@@ -981,6 +1005,31 @@ module Samagotchi
|
|
|
981
1005
|
result
|
|
982
1006
|
end
|
|
983
1007
|
|
|
1008
|
+
# A spawned worker leaves its spawner's process tree: a killer that
|
|
1009
|
+
# walks the tree by parent pid (a terminal closing, a tool's teardown
|
|
1010
|
+
# of a `chi send --wait` it ran in the background) no longer reaches it.
|
|
1011
|
+
# This process (the spawner's child, which its Process.detach waits on)
|
|
1012
|
+
# forks the worker, which starts a session of its own (setsid: no
|
|
1013
|
+
# controlling terminal, no SIGHUP from one) and returns to run the loop;
|
|
1014
|
+
# this one waits until the worker holds the session's OwnerLock, or has
|
|
1015
|
+
# exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
|
|
1016
|
+
# stays alive exactly while the worker starts (#still_starting?). The
|
|
1017
|
+
# worker is reparented to init (launchd), which reaps it.
|
|
1018
|
+
private_class_method def self.detach_from_spawner(session_dir)
|
|
1019
|
+
worker = Process.fork
|
|
1020
|
+
unless worker
|
|
1021
|
+
Process.setsid
|
|
1022
|
+
return
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_START_GRACE
|
|
1026
|
+
until Process.waitpid(worker, Process::WNOHANG) || OwnerLock.owner(session_dir)&.pid == worker ||
|
|
1027
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
1028
|
+
sleep(OwnerLock::RETRY_INTERVAL)
|
|
1029
|
+
end
|
|
1030
|
+
Process.exit!(0)
|
|
1031
|
+
end
|
|
1032
|
+
|
|
984
1033
|
# Delete a session its worker left empty. Checked again now the lock is
|
|
985
1034
|
# free: a note or input may have come in since the worker looked, and a
|
|
986
1035
|
# worker woken meanwhile owns it (delete_session refuses). A `chi send`
|
|
@@ -1024,6 +1073,13 @@ module Samagotchi
|
|
|
1024
1073
|
# format are read by a NEWER chi than the one writing them. Change them
|
|
1025
1074
|
# only additively (spec/worker_contract_spec.rb).
|
|
1026
1075
|
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
1076
|
+
# "1" in a worker spawn_options started: it traps signals (WorkerSignals).
|
|
1077
|
+
# run_session_loop deletes it from ENV as it reads it.
|
|
1078
|
+
WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
|
|
1079
|
+
# "1" in a worker spawn_options started: run_session_loop leaves the
|
|
1080
|
+
# spawner's process tree first (detach_from_spawner); deleted from ENV
|
|
1081
|
+
# as it is read, so an rspec run by the worker's execute never forks.
|
|
1082
|
+
WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
|
|
1027
1083
|
|
|
1028
1084
|
def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
|
|
1029
1085
|
boot = "require 'samagotchi/session_manager'; " \
|
|
@@ -1046,6 +1102,8 @@ module Samagotchi
|
|
|
1046
1102
|
command = worker_command(session.id, state_dir: state_dir)
|
|
1047
1103
|
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
1048
1104
|
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
1105
|
+
# +pid+ is the worker's starter, which exits once the worker it forked
|
|
1106
|
+
# holds the lock (detach_from_spawner): the lock's pid is the worker's.
|
|
1049
1107
|
note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
|
|
1050
1108
|
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
1051
1109
|
pid
|
|
@@ -416,10 +416,20 @@ module Samagotchi
|
|
|
416
416
|
# analytics.json in the session directory. Atomic write; failures are
|
|
417
417
|
# swallowed (analytics must never break the running session).
|
|
418
418
|
# @param state_dir [String, nil]
|
|
419
|
+
# @param running_turn [Boolean] also save the running turn's record so
|
|
420
|
+
# far (a worker's turn checkpoint): as the turn a worker killed now
|
|
421
|
+
# would leave, canceled with reason worker_stopped (TurnNote's), up to
|
|
422
|
+
# its completed_at. The next collector loads it with the others; the
|
|
423
|
+
# turn's own end writes over it. Only the file has it.
|
|
419
424
|
# @return [Boolean] true on success
|
|
420
|
-
def persist(state_dir: nil)
|
|
425
|
+
def persist(state_dir: nil, running_turn: false)
|
|
421
426
|
sid, data, turn_count = @mutex.synchronize do
|
|
422
|
-
|
|
427
|
+
data = build_snapshot(:all).merge(active_turn: nil, active_tools: [])
|
|
428
|
+
if running_turn && @turn
|
|
429
|
+
data[:turn_records] << running_turn_record
|
|
430
|
+
data[:cancellations] += 1
|
|
431
|
+
end
|
|
432
|
+
[@session_id, data, @turn_records.size]
|
|
423
433
|
end
|
|
424
434
|
return false if sid.nil? || sid.to_s.empty?
|
|
425
435
|
|
|
@@ -611,6 +621,18 @@ module Samagotchi
|
|
|
611
621
|
end
|
|
612
622
|
end
|
|
613
623
|
|
|
624
|
+
# The running turn's record so far, as end_turn would make it for a
|
|
625
|
+
# turn a worker died in (#persist's running_turn). Its open generation
|
|
626
|
+
# (none at an iteration boundary) and running tool calls are left out.
|
|
627
|
+
# Caller holds the mutex.
|
|
628
|
+
def running_turn_record
|
|
629
|
+
{
|
|
630
|
+
id: @turn.id, status: "canceled", started_at: @turn.started_at, completed_at: now.iso8601(3),
|
|
631
|
+
duration_ms: elapsed_ms(@turn.started_monotonic), cancellation_reason: "worker_stopped",
|
|
632
|
+
continue: @turn.continue, resumed: @turn.resumed
|
|
633
|
+
}.merge(turn_token_fields(@turn))
|
|
634
|
+
end
|
|
635
|
+
|
|
614
636
|
# What turn_failed's line is made of (format.js failedTurnText): a
|
|
615
637
|
# provider error's summary, else the message (capped: a server's error
|
|
616
638
|
# page can be long) and class, and the steps that stayed (kept_steps).
|
|
@@ -363,7 +363,7 @@ module Samagotchi
|
|
|
363
363
|
|
|
364
364
|
# A cancel's reason as the UIs name it (anything else as it is);
|
|
365
365
|
# spec/shared/labels_matrix.json pins it for both.
|
|
366
|
-
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
|
|
366
|
+
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped" }.freeze
|
|
367
367
|
# A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
|
|
368
368
|
# not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
|
|
369
369
|
STOP_MARK = "■"
|
|
@@ -9,6 +9,7 @@ require_relative "client_id"
|
|
|
9
9
|
require_relative "iteration_limit"
|
|
10
10
|
require_relative "model_profile"
|
|
11
11
|
require_relative "turn_note"
|
|
12
|
+
require_relative "turn_checkpoint"
|
|
12
13
|
require_relative "config"
|
|
13
14
|
require_relative "context_note"
|
|
14
15
|
require_relative "cancellation_controller"
|
|
@@ -277,6 +278,9 @@ module Samagotchi
|
|
|
277
278
|
session.save if @resume_session
|
|
278
279
|
end
|
|
279
280
|
claim_session!(session.id) unless @owner_lock
|
|
281
|
+
# A turn a worker died in (TurnCheckpoint): ended as canceled, with its
|
|
282
|
+
# note, before this REPL's first turn appends after it.
|
|
283
|
+
session.save if @resume_session && TurnCheckpoint.settle(session)
|
|
280
284
|
keep_cards(session)
|
|
281
285
|
# Saved at once, so a process killed before its first turn leaves a
|
|
282
286
|
# file the sweep knows to delete.
|
|
@@ -881,12 +885,35 @@ module Samagotchi
|
|
|
881
885
|
end
|
|
882
886
|
|
|
883
887
|
# The REPL keeps the kernel's conversation as-is (no [No response]
|
|
884
|
-
# placeholder), so /continue resumes from the tool results
|
|
885
|
-
|
|
888
|
+
# placeholder), so /continue resumes from the tool results; the
|
|
889
|
+
# answer keeps what the after_turn hooks stored on it.
|
|
890
|
+
session.messages = with_answer_display(result.conversation, session.messages)
|
|
886
891
|
@turn_flow.after_turn(result, continue: continue)
|
|
887
892
|
save_session(session)
|
|
888
893
|
end
|
|
889
894
|
|
|
895
|
+
# The answer's display keys (Engine#store_answer_display put them on the
|
|
896
|
+
# session's last message) carried onto +conversation+'s last message,
|
|
897
|
+
# when that is the same answer; +conversation+ as it is otherwise.
|
|
898
|
+
ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
|
|
899
|
+
private_constant :ANSWER_DISPLAY_KEYS
|
|
900
|
+
|
|
901
|
+
def with_answer_display(conversation, stored)
|
|
902
|
+
answer = Array(conversation).last
|
|
903
|
+
kept = Array(stored).last
|
|
904
|
+
return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
|
|
905
|
+
return conversation unless message_field(kept, :role).to_s == "model" &&
|
|
906
|
+
message_field(kept, :content) == message_field(answer, :content)
|
|
907
|
+
|
|
908
|
+
extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
|
|
909
|
+
value = message_field(kept, key)
|
|
910
|
+
found[key] = value unless value.nil?
|
|
911
|
+
end
|
|
912
|
+
extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
|
|
916
|
+
|
|
890
917
|
def save_session(session)
|
|
891
918
|
@engine.store_model!(session)
|
|
892
919
|
session.save
|
|
@@ -966,7 +993,7 @@ module Samagotchi
|
|
|
966
993
|
end
|
|
967
994
|
canceled = result&.canceled?
|
|
968
995
|
if result && !canceled
|
|
969
|
-
session.messages = result.conversation if result.conversation.is_a?(Array)
|
|
996
|
+
session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
|
|
970
997
|
@engine.store_model!(session)
|
|
971
998
|
session.save
|
|
972
999
|
end
|
|
@@ -211,12 +211,16 @@ module Samagotchi
|
|
|
211
211
|
# "stopped" for a
|
|
212
212
|
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
213
213
|
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
214
|
-
# and for a command the user's Stop killed or kept
|
|
215
|
-
# cancel, not a failure (its text still starts "Error:"
|
|
214
|
+
# and for a command the user's Stop (or a signal to chi) killed or kept
|
|
215
|
+
# from starting: a cancel, not a failure (its text still starts "Error:"
|
|
216
|
+
# for the model).
|
|
216
217
|
def tool_activity_status(result, tool_name = nil, command: nil)
|
|
217
218
|
text = result.to_s
|
|
218
|
-
return "stopped" if
|
|
219
|
-
|
|
219
|
+
return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
|
|
220
|
+
if tool_name == Tools::Execute::NAME &&
|
|
221
|
+
text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
|
|
222
|
+
return "stopped"
|
|
223
|
+
end
|
|
220
224
|
return "error" if text.start_with?("Error:")
|
|
221
225
|
# Still running, as a task: its output tail may hold any "exit: N".
|
|
222
226
|
return "ok" if handed_over?(tool_name, text)
|
|
@@ -9,11 +9,11 @@ module Samagotchi
|
|
|
9
9
|
# (max_tool_output_chars).
|
|
10
10
|
module ToolResponse
|
|
11
11
|
SEPARATOR = "\n\n---\n\n"
|
|
12
|
-
# Saved with a result for the web's reload (a plugin tool's params line
|
|
13
|
-
# and
|
|
12
|
+
# Saved with a result for the web's reload (a plugin tool's params line,
|
|
13
|
+
# label and row refs, an edit/write's diff) and for the LLMContextView (each
|
|
14
14
|
# run's chi-owned id, ToolIds, and a strategy's edits by id,
|
|
15
15
|
# LLMContextEdit); the prompt never renders them.
|
|
16
|
-
SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_ids edits].freeze
|
|
16
|
+
SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
|
|
17
17
|
# A run's "[name]" lead: the name, then a newline, a space or the end.
|
|
18
18
|
LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
|
|
19
19
|
|
|
@@ -60,7 +60,7 @@ module Samagotchi
|
|
|
60
60
|
entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
|
|
61
61
|
end
|
|
62
62
|
saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
|
|
63
|
-
tool_diffs: runs.map { |run| run[:diff] } }
|
|
63
|
+
tool_diffs: runs.map { |run| run[:diff] }, tool_refs: runs.map { |run| run[:refs] } }
|
|
64
64
|
saved.each { |key, values| entry[key] = values if values.any? }
|
|
65
65
|
entry[:tool_ids] = ids
|
|
66
66
|
entry
|
|
@@ -75,6 +75,7 @@ module Samagotchi
|
|
|
75
75
|
entry[:tool_params] = run[:shown_params] if run[:shown_params]
|
|
76
76
|
entry[:tool_labels] = run[:shown_label] if run[:shown_label]
|
|
77
77
|
entry[:tool_diffs] = run[:diff] if run[:diff]
|
|
78
|
+
entry[:tool_refs] = run[:refs] if run[:refs]
|
|
78
79
|
entry[:tool_ids] = ids
|
|
79
80
|
entry
|
|
80
81
|
end
|
|
@@ -16,13 +16,15 @@ module Samagotchi
|
|
|
16
16
|
# tool_call_completed (ToolActivity.tool_activity_event builds that
|
|
17
17
|
# activity itself, for the TUI and the metrics);
|
|
18
18
|
# * EVENT_KEYS: top-level on both, so a UI that missed the start (a
|
|
19
|
-
# replay gap) builds its row from the completed event.
|
|
19
|
+
# replay gap) builds its row from the completed event. +refs+ (a
|
|
20
|
+
# plugin tool's, RowRefs) is one, but ToolRunner adds it: #for has no
|
|
21
|
+
# plugins, so a reload reads the saved tool_refs instead.
|
|
20
22
|
#
|
|
21
23
|
# params and label stay where they are: their sources differ per path
|
|
22
24
|
# (the registry's preview, a plugin's label, the saved shown params).
|
|
23
25
|
module ToolRowFields
|
|
24
26
|
ACTIVITY_KEYS = %i[title called_as].freeze
|
|
25
|
-
EVENT_KEYS = %i[view ref].freeze
|
|
27
|
+
EVENT_KEYS = %i[view ref refs].freeze
|
|
26
28
|
KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
|
|
27
29
|
|
|
28
30
|
module_function
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "tool_activity"
|
|
4
4
|
require_relative "tool_row_fields"
|
|
5
|
+
require_relative "row_refs"
|
|
5
6
|
require_relative "guardrails"
|
|
6
7
|
require_relative "vision_context"
|
|
7
8
|
require_relative "log"
|
|
@@ -41,7 +42,8 @@ module Samagotchi
|
|
|
41
42
|
# shown_params: the params line the live row showed, and shown_label:
|
|
42
43
|
# its label ("chrome: screenshot"), only for a tool that isn't built in
|
|
43
44
|
# (the loops save them with the result, so a reload without the plugin
|
|
44
|
-
# shows the same row),
|
|
45
|
+
# shows the same row), refs: its row's refs (a plugin tool's refs:,
|
|
46
|
+
# RowRefs; saved likewise), and diff: what an edit/write changed in its
|
|
45
47
|
# file (EditPreview.change; also on tool_call_completed, never in the
|
|
46
48
|
# model's output)
|
|
47
49
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
@@ -55,9 +57,12 @@ module Samagotchi
|
|
|
55
57
|
started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
56
58
|
tool: call[:name], call: call.dup, params: params }
|
|
57
59
|
started[:label] = label if label
|
|
58
|
-
# Its row fields (ToolRowFields: title, called_as, view, ref
|
|
59
|
-
# its session's working
|
|
60
|
+
# Its row fields (ToolRowFields: title, called_as, view, ref, and a
|
|
61
|
+
# plugin tool's refs); the worker runs in its session's working
|
|
62
|
+
# directory.
|
|
60
63
|
fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
|
|
64
|
+
refs = RowRefs.for(tools && !call[:name].nil? ? tools[call[:name]]&.refs : nil, call, cwd: session_cwd)
|
|
65
|
+
fields[:refs] = refs unless refs.empty?
|
|
61
66
|
started.merge!(fields)
|
|
62
67
|
emit(on_stream_event, started)
|
|
63
68
|
|
|
@@ -121,6 +126,7 @@ module Samagotchi
|
|
|
121
126
|
run[:images] = images if images&.any?
|
|
122
127
|
run[:shown_params] = params if params && plugin_tool?(call[:name])
|
|
123
128
|
run[:shown_label] = label if label
|
|
129
|
+
run[:refs] = refs unless refs.empty?
|
|
124
130
|
run[:diff] = diff if diff
|
|
125
131
|
run
|
|
126
132
|
end
|
|
@@ -249,6 +255,13 @@ module Samagotchi
|
|
|
249
255
|
[result[:output], *notes].join("\n")
|
|
250
256
|
end
|
|
251
257
|
|
|
258
|
+
# The session's folder (the Engine's, KernelLoop#session_cwd), else the
|
|
259
|
+
# process's: a bare kernel (specs) has none.
|
|
260
|
+
def session_cwd
|
|
261
|
+
cwd = @kernel.respond_to?(:session_cwd) && @kernel.session_cwd&.call
|
|
262
|
+
cwd.to_s.empty? ? Dir.pwd : cwd
|
|
263
|
+
end
|
|
264
|
+
|
|
252
265
|
# The kernel's tools, for the activity line of a tool that isn't built in.
|
|
253
266
|
def tools = @kernel.tools
|
|
254
267
|
|
|
@@ -77,7 +77,7 @@ module Samagotchi
|
|
|
77
77
|
start_line: call[:start_line], end_line: call[:end_line])
|
|
78
78
|
end,
|
|
79
79
|
TaskCreate::NAME => lambda do |call, kctx|
|
|
80
|
-
next Execute
|
|
80
|
+
next Execute.not_run(stop_reason_proc(kctx)) if cancelled_proc(kctx).call
|
|
81
81
|
|
|
82
82
|
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
83
83
|
marker_env: task_marker_env(kctx, call[:env]))
|
|
@@ -87,7 +87,8 @@ module Samagotchi
|
|
|
87
87
|
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
|
|
88
88
|
end,
|
|
89
89
|
Execute::NAME => lambda do |call, kctx|
|
|
90
|
-
Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx),
|
|
90
|
+
Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
|
|
91
|
+
env: parent_env(kctx))
|
|
91
92
|
end,
|
|
92
93
|
RegisterReminder::NAME => lambda do |call, kctx|
|
|
93
94
|
RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
|
|
@@ -163,6 +164,12 @@ module Samagotchi
|
|
|
163
164
|
-> { !!peers&.cancelled? }
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
# The running turn's stop reason (:signal, :manual, …), nil without one.
|
|
168
|
+
def stop_reason_proc(kctx)
|
|
169
|
+
peers = kctx.peers
|
|
170
|
+
-> { peers.respond_to?(:cancel_reason) ? peers.cancel_reason : nil }
|
|
171
|
+
end
|
|
172
|
+
|
|
166
173
|
# @param command_description [Boolean] false: execute declares no
|
|
167
174
|
# description parameter (execute.description off)
|
|
168
175
|
# @param policy [String, nil] llm_context.policy, the sentence
|
|
@@ -27,6 +27,10 @@ module Samagotchi
|
|
|
27
27
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
28
28
|
# The first words of a command the user's Stop killed.
|
|
29
29
|
STOPPED_BY_USER = "Error: command stopped by the user"
|
|
30
|
+
# The same two when a signal to chi (a worker's TERM, HUP or INT)
|
|
31
|
+
# stopped the turn: its cancel reason is :signal.
|
|
32
|
+
NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
|
|
33
|
+
STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
|
|
30
34
|
# The first words of a command killed at the limit in kill mode (hooks
|
|
31
35
|
# match on it).
|
|
32
36
|
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
@@ -73,11 +77,13 @@ module Samagotchi
|
|
|
73
77
|
|
|
74
78
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
75
79
|
# command is killed (or not started) and its output so far returned
|
|
80
|
+
# @param stop_reason [#call] the stop's reason (:signal: a signal to
|
|
81
|
+
# chi, which the result then names instead of the user)
|
|
76
82
|
# @param env [Hash, nil] added to the command's environment
|
|
77
83
|
# @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
|
|
78
84
|
# nil reads execute.on_timeout
|
|
79
|
-
def self.call(command, cwd: nil, cancelled: -> { false }, env: nil, on_timeout: nil)
|
|
80
|
-
return
|
|
85
|
+
def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
|
|
86
|
+
return not_run(stop_reason) if cancelled.call
|
|
81
87
|
|
|
82
88
|
command = command.strip
|
|
83
89
|
resolved_cwd = resolve_cwd(cwd)
|
|
@@ -109,11 +115,17 @@ module Samagotchi
|
|
|
109
115
|
# a long command, and return whatever it printed before it was killed.
|
|
110
116
|
stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
111
117
|
rescue CommandCancelled => e
|
|
112
|
-
|
|
118
|
+
first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
|
|
119
|
+
stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
113
120
|
rescue StandardError => e
|
|
114
121
|
"Error: #{e.message}"
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
# What a command the turn's stop kept from starting returns.
|
|
125
|
+
def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
|
|
126
|
+
|
|
127
|
+
def self.signal?(stop_reason) = stop_reason.call == :signal
|
|
128
|
+
|
|
117
129
|
# execute.on_timeout, or the caller's: :task (the default) or :kill.
|
|
118
130
|
def self.on_timeout_mode(value)
|
|
119
131
|
value = Samagotchi::Config.get("execute.on_timeout") if value.nil? || value.to_s.strip.empty?
|
|
@@ -10,11 +10,12 @@ module Samagotchi
|
|
|
10
10
|
# can't host one), and the model the session runs on (model_ref: the
|
|
11
11
|
# resolved ref, live after /model), and the session's own thinking level
|
|
12
12
|
# (thinking: nil without one) and reasoning_history level
|
|
13
|
-
# (reasoning_history: nil without one)
|
|
13
|
+
# (reasoning_history: nil without one), and why the turn was canceled
|
|
14
|
+
# (cancel_reason: :signal, :manual, …; nil while it runs). Engine hands KernelLoop one that follows its current
|
|
14
15
|
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
15
16
|
# without an Engine.
|
|
16
17
|
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
|
|
17
|
-
keyword_init: true) do
|
|
18
|
+
:cancel_reason, keyword_init: true) do
|
|
18
19
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
19
20
|
def cancelled?
|
|
20
21
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -20,11 +20,13 @@ module Samagotchi
|
|
|
20
20
|
# tool" when nil); ToolActivity knows the built-ins' own
|
|
21
21
|
# @!attribute preview [#call, nil] call → the activity line's params
|
|
22
22
|
# @!attribute targets [#call, nil] call → what guardrail rules match
|
|
23
|
+
# @!attribute refs [#call, nil] call → the row's refs (a plugin's
|
|
24
|
+
# refs:, RowRefs checks what it gives)
|
|
23
25
|
# @!attribute source [String] "core", or the bundle that added it
|
|
24
26
|
# @!attribute layer [Symbol, nil] the LLM context layer the tool
|
|
25
27
|
# belongs to (LLMContextStrategy): offered only in a turn running
|
|
26
28
|
# under it (forget_outputs, :forget); nil: always
|
|
27
|
-
Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, :layer, keyword_init: true) do
|
|
29
|
+
Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
|
|
28
30
|
def core? = source == "core"
|
|
29
31
|
|
|
30
32
|
# Offered in a turn under +layers+ (the turn's LLM context layers).
|
|
@@ -36,11 +38,11 @@ module Samagotchi
|
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# @return [Entry]
|
|
39
|
-
def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core", layer: nil)
|
|
41
|
+
def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
|
|
40
42
|
raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
|
|
41
43
|
|
|
42
44
|
@entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
|
|
43
|
-
targets: targets, source: source, layer: layer)
|
|
45
|
+
targets: targets, refs: refs, source: source, layer: layer)
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# Remove a tool (a plugin's tool set changed after load).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "interrupted_turn"
|
|
5
|
+
require_relative "log"
|
|
6
|
+
require_relative "session"
|
|
7
|
+
require_relative "steer"
|
|
8
|
+
require_relative "turn_note"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# A worker's running turn, kept on disk at the loop's iteration
|
|
12
|
+
# boundaries, so a worker killed mid-turn (SIGKILL, a crash of the
|
|
13
|
+
# process) loses at most INTERVAL seconds of it instead of all of it.
|
|
14
|
+
#
|
|
15
|
+
# At each boundary (Worker#pending_input_drain, on the turn's thread) the
|
|
16
|
+
# turn's live conversation (Engine#live_conversation) is copied, with the
|
|
17
|
+
# lines that boundary merges (their input files are deleted as they are
|
|
18
|
+
# claimed), into the session's overlay (Session#turn_checkpoint), marked
|
|
19
|
+
# with an InterruptedTurn. Every save while the turn runs writes the
|
|
20
|
+
# overlay instead of the session's messages: this one's, written at the
|
|
21
|
+
# first boundary (the prompt reaches the disk) and then at most every
|
|
22
|
+
# INTERVAL (or when the boundary merged input), and any other save in the
|
|
23
|
+
# turn (a question asked). The session's in-memory messages never change
|
|
24
|
+
# (a Bridge snapshot shows the turn in progress or in the messages, never
|
|
25
|
+
# both). The turn's end saves the real messages without a marker.
|
|
26
|
+
#
|
|
27
|
+
# The copy is what a Stop at that boundary would keep, minus its note, so
|
|
28
|
+
# the history before the turn is never rewritten (prompt caching); later
|
|
29
|
+
# steps of the same turn may still edit their own entries in place.
|
|
30
|
+
class TurnCheckpoint
|
|
31
|
+
INTERVAL = 10.0
|
|
32
|
+
|
|
33
|
+
# A session loaded with an InterruptedTurn mark (its worker died in the
|
|
34
|
+
# turn): the turn ends now, as canceled. Its note goes at the tail
|
|
35
|
+
# (append-only: the model reads on its next turn that the previous one
|
|
36
|
+
# was cut, after how long), the status goes idle and the mark goes. The
|
|
37
|
+
# dead turn is not run again. The caller saves.
|
|
38
|
+
#
|
|
39
|
+
# last_turn is left as it was: a new last_turn saved idle here, before
|
|
40
|
+
# the turn a `chi send --wait` started this worker for, would end that
|
|
41
|
+
# wait at once as the sent turn's (ReplyWait compares last_turn), with
|
|
42
|
+
# the dead turn's outcome.
|
|
43
|
+
# @return [InterruptedTurn, nil] the mark settled, nil when there was none
|
|
44
|
+
def self.settle(session)
|
|
45
|
+
mark = session.interrupted_turn
|
|
46
|
+
return nil unless mark
|
|
47
|
+
|
|
48
|
+
messages = Array(session.messages)
|
|
49
|
+
# How long it ran up to its last checkpoint (it died later); none
|
|
50
|
+
# when that was its first boundary ("after 0s" would mislead).
|
|
51
|
+
seconds = mark.seconds
|
|
52
|
+
seconds = nil if seconds && seconds < 1
|
|
53
|
+
note = TurnNote.cancelled(:worker_stopped, seconds: seconds, shown: TurnNote.interrupted_tail?(messages))
|
|
54
|
+
session.messages = messages + [note]
|
|
55
|
+
session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
|
|
56
|
+
session.interrupted_turn = nil
|
|
57
|
+
mark
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param session [Session] the worker's session (saved as a whole)
|
|
61
|
+
# @param clock [#call] monotonic seconds (specs)
|
|
62
|
+
def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
63
|
+
@session = session
|
|
64
|
+
@state_dir = state_dir
|
|
65
|
+
@interval = interval
|
|
66
|
+
@clock = clock
|
|
67
|
+
@boundaries = 0
|
|
68
|
+
@written_at = nil
|
|
69
|
+
@started_at = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A turn begins (before Engine#run_turn and the worker's running save):
|
|
73
|
+
# the overlay starts as the history plus +prompt+ (text only: no
|
|
74
|
+
# images, no turn id), marked at iteration 0, so a worker killed before
|
|
75
|
+
# the loop's first boundary leaves the prompt and a mark the next one
|
|
76
|
+
# settles. A continue turn (no prompt) is the history, marked.
|
|
77
|
+
# @param prompt [String, nil]
|
|
78
|
+
def begin_turn(prompt: nil)
|
|
79
|
+
@boundaries = 0
|
|
80
|
+
@written_at = nil
|
|
81
|
+
@started_at = Time.now.iso8601(3)
|
|
82
|
+
messages = Array(@session.messages).dup
|
|
83
|
+
messages << { role: "user", content: prompt } unless prompt.to_s.empty?
|
|
84
|
+
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The turn ended (or never began): the session saves its own messages.
|
|
88
|
+
def end_turn
|
|
89
|
+
@session.turn_checkpoint = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# An iteration boundary of the running turn.
|
|
93
|
+
# @param conversation [Array<Hash>, nil] the loop's live conversation;
|
|
94
|
+
# nil (no turn): nothing
|
|
95
|
+
# @param merging [Array<Steer::Line>] the lines this boundary merges
|
|
96
|
+
# (appended after the conversation, as Steer.inject! will)
|
|
97
|
+
# @param turn_id [String, nil]
|
|
98
|
+
# @return [Boolean] whether the session file was written
|
|
99
|
+
def boundary(conversation, merging: [], turn_id: nil)
|
|
100
|
+
return false unless conversation
|
|
101
|
+
|
|
102
|
+
@boundaries += 1
|
|
103
|
+
merged = merging.empty? ? [] : Steer.merge(merging).messages
|
|
104
|
+
# One level deep: the loop may edit an entry in place later (a
|
|
105
|
+
# forget stub) while a save on another thread serializes this one.
|
|
106
|
+
messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
|
|
107
|
+
marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
|
|
108
|
+
@session.turn_checkpoint = { messages: messages, marker: marker }
|
|
109
|
+
return false unless due? || !merged.empty?
|
|
110
|
+
|
|
111
|
+
write
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def due? = @written_at.nil? || @clock.call - @written_at >= @interval
|
|
117
|
+
|
|
118
|
+
def write
|
|
119
|
+
@session.save(state_dir: @state_dir)
|
|
120
|
+
@written_at = @clock.call
|
|
121
|
+
true
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
# The turn goes on; the next boundary tries again.
|
|
124
|
+
Log.warn(:worker, "checkpoint_failed", error: e.class.name, msg: e.message)
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|