samagotchi 0.52.0 → 0.54.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 +58 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +1 -1
- data/docs/testing.md +5 -3
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +35 -44
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +0 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +132 -135
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_registry.rb +13 -0
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +184 -190
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +4 -28
- data/lib/samagotchi/session_commands.rb +32 -4
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +45 -257
- data/lib/samagotchi/session_metrics.rb +18 -21
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +25 -3
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -8
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -8
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/thinking_chip.js +31 -21
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +9 -1
|
@@ -33,17 +33,15 @@ module Samagotchi
|
|
|
33
33
|
|
|
34
34
|
def run
|
|
35
35
|
provenance = Provenance.new(name: @name)
|
|
36
|
-
|
|
37
|
-
raise UninstallError, "Bundle '#{@name}' is not installed" unless
|
|
36
|
+
bundle = provenance.record
|
|
37
|
+
raise UninstallError, "Bundle '#{@name}' is not installed" unless bundle
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
raw = cli_scope || data[:scope]&.to_s
|
|
42
|
-
target_scope = raw.nil? || raw.strip.empty? ? "system" : raw
|
|
39
|
+
# The scope from the flag, else the record's
|
|
40
|
+
target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
|
|
43
41
|
target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
|
|
44
42
|
|
|
45
|
-
files =
|
|
46
|
-
hooks =
|
|
43
|
+
files = bundle.files
|
|
44
|
+
hooks = bundle.hooks
|
|
47
45
|
if files.empty? && hooks.empty?
|
|
48
46
|
# No file list, just remove provenance
|
|
49
47
|
FileUtils.rm_rf(provenance.bundle_dir)
|
|
@@ -99,7 +97,7 @@ module Samagotchi
|
|
|
99
97
|
# ── Hooks removal (bundle-owned) ──────────────────────────
|
|
100
98
|
# Hooks are stored under <bundle_dir>/hooks/ and are not part of the index.
|
|
101
99
|
# Explicit removal for intent; the final rm_rf sweeps it anyway.
|
|
102
|
-
if hooks.
|
|
100
|
+
if hooks.any?
|
|
103
101
|
hooks_dir = provenance.hooks_dir
|
|
104
102
|
hooks.each do |hook_key, _meta|
|
|
105
103
|
hook_key_str = hook_key.to_s
|
|
@@ -117,7 +115,7 @@ module Samagotchi
|
|
|
117
115
|
FileUtils.rm_rf(provenance.hooks_dir)
|
|
118
116
|
end
|
|
119
117
|
|
|
120
|
-
plugin_path = provenance.plugin_path(
|
|
118
|
+
plugin_path = provenance.plugin_path(bundle)
|
|
121
119
|
@removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
|
|
122
120
|
FileUtils.rm_rf(provenance.bundle_dir)
|
|
123
121
|
true
|
|
@@ -44,8 +44,8 @@ module Samagotchi
|
|
|
44
44
|
# @return [Array<String>] the bundles whose plugin loaded
|
|
45
45
|
def load_installed(registries, failures: nil, settings: {})
|
|
46
46
|
loaded = []
|
|
47
|
-
MemoryBundle::Provenance.each_installed(holding: :plugin) do |bundle_name,
|
|
48
|
-
ok = load_bundle(bundle_name,
|
|
47
|
+
MemoryBundle::Provenance.each_installed(holding: :plugin) do |bundle_name, bundle|
|
|
48
|
+
ok = load_bundle(bundle_name, bundle, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
|
|
49
49
|
loaded << bundle_name if ok
|
|
50
50
|
end
|
|
51
51
|
loaded
|
|
@@ -56,10 +56,10 @@ module Samagotchi
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# @return [Boolean] whether the plugin loaded
|
|
59
|
-
def load_bundle(bundle_name,
|
|
60
|
-
file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(
|
|
59
|
+
def load_bundle(bundle_name, bundle, registries, failures: nil, settings: {})
|
|
60
|
+
file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(bundle) unless bundle.error?
|
|
61
61
|
basename = file ? File.basename(file) : "plugin"
|
|
62
|
-
reason =
|
|
62
|
+
reason = bundle.error || unloadable_reason(file, bundle)
|
|
63
63
|
return failed(bundle_name, basename, reason, failures) if reason
|
|
64
64
|
|
|
65
65
|
api = nil
|
|
@@ -82,12 +82,12 @@ module Samagotchi
|
|
|
82
82
|
def label(bundle_name, basename) = "#{basename} (bundle #{bundle_name})"
|
|
83
83
|
|
|
84
84
|
# Why the installed file can't be loaded, or nil.
|
|
85
|
-
def unloadable_reason(file,
|
|
86
|
-
failure = MemoryBundle::Manifest.requires_chi_failure(
|
|
85
|
+
def unloadable_reason(file, bundle)
|
|
86
|
+
failure = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
|
|
87
87
|
return failure if failure
|
|
88
88
|
return "the file is missing" unless file && File.file?(file)
|
|
89
89
|
|
|
90
|
-
Hooks::BundleLoader.sha_mismatch(file,
|
|
90
|
+
Hooks::BundleLoader.sha_mismatch(file, bundle.plugin.sha256, required: true)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
# module_eval the file into a fresh module in the bundle's namespace
|
|
@@ -406,8 +406,8 @@ module Samagotchi
|
|
|
406
406
|
# "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
|
|
407
407
|
def bundles_summary
|
|
408
408
|
shipped = MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version rescue nil
|
|
409
|
-
installed = MemoryBundle::Provenance.each_installed.map do |name,
|
|
410
|
-
version =
|
|
409
|
+
installed = MemoryBundle::Provenance.each_installed.map do |name, bundle|
|
|
410
|
+
version = bundle.version || "?"
|
|
411
411
|
label = "#{name} #{version}"
|
|
412
412
|
label += " (shipped #{shipped})" if name == MemoryBundle::SystemBundle::BUNDLE_NAME
|
|
413
413
|
label
|
|
@@ -339,7 +339,7 @@ module Samagotchi
|
|
|
339
339
|
thinking: options[:thinking]),
|
|
340
340
|
state_dir: @state_dir)
|
|
341
341
|
end
|
|
342
|
-
rescue
|
|
342
|
+
rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
|
343
343
|
error_line("chi send: refused: #{e.message}")
|
|
344
344
|
return 1
|
|
345
345
|
rescue StandardError => e
|
|
@@ -481,26 +481,8 @@ module Samagotchi
|
|
|
481
481
|
# (a bundle's too).
|
|
482
482
|
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
483
483
|
|
|
484
|
-
#
|
|
485
|
-
|
|
486
|
-
# it on. What the UIs run: the line as the entry's match takes it — bare
|
|
487
|
-
# "exit" too (/exit's match), but not with arguments ("/exit --delete" is
|
|
488
|
-
# a UI line; "/stats please" is a prompt), as in a terminal.
|
|
489
|
-
# @return [Commands::Registry::Entry, nil]
|
|
490
|
-
def local_command(text)
|
|
491
|
-
line = text.to_s.strip
|
|
492
|
-
return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
|
|
493
|
-
|
|
494
|
-
entry = SessionCommands.builtin_registry.lookup_local(line)
|
|
495
|
-
# Only the exact line the UI runs: "/stats please" is a prompt, and
|
|
496
|
-
# "/exit --delete" is left to the worker (the TUI runs it as /exit, the
|
|
497
|
-
# worker answers its unknown-command way). Bare "exit" is /exit's line
|
|
498
|
-
# as its match takes it; the reply names the command, /exit.
|
|
499
|
-
return nil unless entry
|
|
500
|
-
return entry if line == entry.name
|
|
501
|
-
|
|
502
|
-
line.match?(/\Aexit\z/i) ? entry : nil
|
|
503
|
-
end
|
|
484
|
+
# @return [Commands::Registry::Entry, nil] SessionCommands.ui_only_entry
|
|
485
|
+
def local_command(text) = SessionCommands.ui_only_entry(text)
|
|
504
486
|
|
|
505
487
|
# A UI-only command: nothing is sent (with --new, no session is created
|
|
506
488
|
# either). /stats points at its model-free reader and names the session;
|
|
@@ -525,13 +507,7 @@ module Samagotchi
|
|
|
525
507
|
CLI::Exit::FAILED
|
|
526
508
|
end
|
|
527
509
|
|
|
528
|
-
|
|
529
|
-
# reader (naming the session when there is one).
|
|
530
|
-
def local_refusal(name, id)
|
|
531
|
-
return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
|
|
532
|
-
|
|
533
|
-
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
534
|
-
end
|
|
510
|
+
def local_refusal(name, id) = SessionCommands.ui_only_refusal(name, id, runner: "chi send")
|
|
535
511
|
|
|
536
512
|
# --wait after a message that ran as a session command: no reply comes
|
|
537
513
|
# for it. Its output shows where the session is open.
|
|
@@ -141,6 +141,34 @@ module Samagotchi
|
|
|
141
141
|
@builtin_registry ||= register_builtins(Commands::Registry.new).freeze
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
+
# The UI-only built-in command +text+ is a line of (/stats, /exit, …), or
|
|
145
|
+
# nil: neither a worker nor a one-shot run (chi send, chi -p
|
|
146
|
+
# --non-interactive) runs it, so they refuse it rather than send it to
|
|
147
|
+
# the model as a prompt. What the UIs run: the line as the entry's match
|
|
148
|
+
# takes it — bare "exit" too (/exit's match), but not with arguments
|
|
149
|
+
# ("/exit --delete" is a UI line; "/stats please" is a prompt), as in a
|
|
150
|
+
# terminal.
|
|
151
|
+
# @return [Commands::Registry::Entry, nil]
|
|
152
|
+
def self.ui_only_entry(text)
|
|
153
|
+
line = text.to_s.strip
|
|
154
|
+
return nil unless line.start_with?("/", "!") || line.match?(/\Aexit\z/i)
|
|
155
|
+
|
|
156
|
+
entry = builtin_registry.lookup_local(line)
|
|
157
|
+
return nil unless entry
|
|
158
|
+
return entry if line == entry.name
|
|
159
|
+
|
|
160
|
+
line.match?(/\Aexit\z/i) ? entry : nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Why a UI-only command isn't run by +runner+ ("chi send"): /stats points
|
|
164
|
+
# at its model-free reader (naming the session +id+ when there is one).
|
|
165
|
+
# @return [String]
|
|
166
|
+
def self.ui_only_refusal(name, id, runner:)
|
|
167
|
+
return "#{name} is a terminal and web command; #{runner} doesn't run it" unless name == "/stats"
|
|
168
|
+
|
|
169
|
+
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
170
|
+
end
|
|
171
|
+
|
|
144
172
|
# The name of this chi's own session command +line+ is (one a worker
|
|
145
173
|
# runs), or nil. A worker that answers such a line unknown_command runs
|
|
146
174
|
# an older chi that doesn't have it yet (/llm-context against 0.39.0):
|
|
@@ -488,7 +516,7 @@ module Samagotchi
|
|
|
488
516
|
|
|
489
517
|
key = alias_name.strip.downcase
|
|
490
518
|
warn_prefix = previous ? "warning: overwriting alias '#{key}' (#{previous} -> #{model_name}); " : ""
|
|
491
|
-
base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated" : "runtime model set to #{model_name}#{model_note}"
|
|
519
|
+
base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}" : "runtime model set to #{model_name}#{model_note}#{thinking_note}"
|
|
492
520
|
["#{warn_prefix}#{base}; alias '#{key}' -> '#{model_name}' persisted", true]
|
|
493
521
|
else
|
|
494
522
|
return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
|
|
@@ -497,14 +525,14 @@ module Samagotchi
|
|
|
497
525
|
return ["--default cannot be combined with clear/default/none/off", false] if persist_default
|
|
498
526
|
|
|
499
527
|
switch_model(@default_model)
|
|
500
|
-
return ["runtime model reset to #{model_name}#{model_note}#{notes_note}", true]
|
|
528
|
+
return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
|
|
501
529
|
end
|
|
502
530
|
|
|
503
531
|
switch_model(arg, persist_default: persist_default)
|
|
504
532
|
if persist_default
|
|
505
|
-
["runtime model set to #{model_name}#{model_note} and default updated#{notes_note}", true]
|
|
533
|
+
["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{notes_note}", true]
|
|
506
534
|
else
|
|
507
|
-
["runtime model set to #{model_name}#{model_note}#{notes_note}", true]
|
|
535
|
+
["runtime model set to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
|
|
508
536
|
end
|
|
509
537
|
end
|
|
510
538
|
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "session_setup"
|
|
6
|
+
require_relative "session_inbox"
|
|
7
|
+
require_relative "session_chain"
|
|
8
|
+
require_relative "child_move"
|
|
9
|
+
require_relative "archive_store"
|
|
10
|
+
require_relative "recap_store"
|
|
11
|
+
require_relative "bridge_client"
|
|
12
|
+
require_relative "model_profile"
|
|
13
|
+
require_relative "log"
|
|
14
|
+
require_relative "session_manager"
|
|
15
|
+
|
|
16
|
+
module Samagotchi
|
|
17
|
+
# The next link of a session chain (chi send --new --continues, web
|
|
18
|
+
# Continue →): SessionManager.continue_session runs .run. The archive,
|
|
19
|
+
# spawn, stop and wake it builds on are SessionManager's.
|
|
20
|
+
module SessionContinue
|
|
21
|
+
# A continue that can't happen (#run). reason: :continued
|
|
22
|
+
# (another session continues it already: +ids+ names it), :open_children
|
|
23
|
+
# (open children it can't move to the new link: on an older chi's
|
|
24
|
+
# worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
|
|
25
|
+
# folder isn't there).
|
|
26
|
+
class Refused < StandardError
|
|
27
|
+
attr_reader :session_id, :reason, :ids
|
|
28
|
+
|
|
29
|
+
def initialize(session_id, reason, ids: [], detail: nil)
|
|
30
|
+
@session_id = session_id
|
|
31
|
+
@reason = reason
|
|
32
|
+
@ids = ids
|
|
33
|
+
super(continue_refused_message(detail))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def continue_refused_message(detail)
|
|
39
|
+
short = @session_id[0, 8]
|
|
40
|
+
case @reason
|
|
41
|
+
when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
|
|
42
|
+
when :open_children
|
|
43
|
+
"#{short} has delegates still open that can't move to the next link: #{detail}; " \
|
|
44
|
+
"then continue (or wait for them, stop them or archive them)"
|
|
45
|
+
else "#{short}'s folder #{detail} is gone"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# How long #run waits for the previous link's worker to write its
|
|
51
|
+
# recap (IdleRecap's own request timeout).
|
|
52
|
+
RECAP_WAIT = 30.0
|
|
53
|
+
# In the previous link's folder: one continue of a session at a time.
|
|
54
|
+
LOCK = "continue.lock"
|
|
55
|
+
# How often a last:<id> continue looks again when another continue
|
|
56
|
+
# moved the chain's end while it waited for the lock.
|
|
57
|
+
TRIES = 3
|
|
58
|
+
|
|
59
|
+
# Start the next link of a chain: a new session that continues
|
|
60
|
+
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
61
|
+
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
62
|
+
# starting with a context note from chi: the link and the previous
|
|
63
|
+
# link's recap (SessionChain.note_text). The previous link is archived
|
|
64
|
+
# first, with its finished delegates (archive_session's rules: a turn
|
|
65
|
+
# running, a prompt queued or the step-limit question refuse; an idle
|
|
66
|
+
# worker is stopped), so a refusal starts nothing. Its open delegates
|
|
67
|
+
# (running, waiting, live, an unreported reply, at any depth below them)
|
|
68
|
+
# move to the new link instead (ChildMove): their reports reach it.
|
|
69
|
+
# Before that its live worker is asked for a recap and given up to
|
|
70
|
+
# +recap_wait+ seconds to write it.
|
|
71
|
+
# One continue of a session at a time (a lock in its folder): a double
|
|
72
|
+
# click finds the first one's link and is refused; a last:<id> one
|
|
73
|
+
# follows the chain to its new end instead.
|
|
74
|
+
# @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
|
|
75
|
+
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
76
|
+
# @param title [String, nil] what the lists show before the first turn;
|
|
77
|
+
# by default the prompt, else the previous link's preview
|
|
78
|
+
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
79
|
+
# #moved_children: the delegates it took over)
|
|
80
|
+
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
81
|
+
# @raise [Refused] continued already, delegates open that can't
|
|
82
|
+
# move, or its folder is gone
|
|
83
|
+
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
84
|
+
def self.run(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
|
|
85
|
+
sd = state_dir || Session.default_state_dir
|
|
86
|
+
TRIES.times do |try|
|
|
87
|
+
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
88
|
+
result = with_lock(id, sd) do
|
|
89
|
+
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
|
|
90
|
+
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
91
|
+
|
|
92
|
+
run_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
93
|
+
archive_wait: archive_wait)
|
|
94
|
+
end
|
|
95
|
+
return result unless result == :moved
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private_class_method def self.with_lock(id, state_dir, &)
|
|
100
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
101
|
+
FileUtils.mkdir_p(dir)
|
|
102
|
+
File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
103
|
+
lock.flock(File::LOCK_EX)
|
|
104
|
+
yield
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private_class_method def self.run_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
109
|
+
previous = Session.load(id, state_dir: state_dir)
|
|
110
|
+
plan = check!(previous, state_dir)
|
|
111
|
+
# archive_session's refusals that can be known now, before the wait.
|
|
112
|
+
SessionManager.archivable!(id, state_dir, except: plan.move)
|
|
113
|
+
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
114
|
+
# Again: a turn sent to it during the wait may have delegated (they
|
|
115
|
+
# move too), or a delegate finished.
|
|
116
|
+
plan = move_plan!(id, state_dir)
|
|
117
|
+
already = [id, *SessionManager.descendant_ids(id, state_dir, except: plan.move)].select do |sid|
|
|
118
|
+
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
119
|
+
end
|
|
120
|
+
archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
|
|
121
|
+
archived = archive[:archived] - already
|
|
122
|
+
begin
|
|
123
|
+
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
# Nothing started: the delegates go back, what this archived goes
|
|
126
|
+
# back to the lists, and a new link saved before its worker failed
|
|
127
|
+
# to spawn goes (left, it would hold the chain: "continued already",
|
|
128
|
+
# last: to it).
|
|
129
|
+
undo_move(id, state_dir, moved: plan.move)
|
|
130
|
+
begin
|
|
131
|
+
discard_failed_link(id, state_dir)
|
|
132
|
+
ensure
|
|
133
|
+
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
134
|
+
wake_for_rings(id, state_dir) unless plan.move.empty?
|
|
135
|
+
end
|
|
136
|
+
raise
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The delegates a failed #run moved, or began to (its
|
|
141
|
+
# intent names them), go back to +id+.
|
|
142
|
+
private_class_method def self.undo_move(id, state_dir, moved:)
|
|
143
|
+
link = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
144
|
+
ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
|
|
145
|
+
ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
|
|
146
|
+
ChildMove.clear_intent(link, state_dir: state_dir)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The link a failed #run saved (no other continues +id+:
|
|
152
|
+
# check! made sure under the lock): its worker stopped if one
|
|
153
|
+
# started, then the rings a moved delegate wrote into it just before
|
|
154
|
+
# the undo go back to +id+, then the link goes. A worker that outlives
|
|
155
|
+
# the stop is raised (SessionManager::DeleteRefused): the link stays,
|
|
156
|
+
# and the caller hears it.
|
|
157
|
+
private_class_method def self.discard_failed_link(id, state_dir)
|
|
158
|
+
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
159
|
+
if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
|
|
160
|
+
!SessionManager.stop_session(orphan, state_dir: state_dir, wait: 10)
|
|
161
|
+
raise SessionManager::DeleteRefused.new(orphan, :still_stopping)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
|
|
165
|
+
SessionManager.delete_session(orphan, state_dir: state_dir)
|
|
166
|
+
rescue SessionManager::DeleteRefused
|
|
167
|
+
raise
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A failed continue's previous link, back in the lists, with rings that
|
|
173
|
+
# came in meanwhile: wake it for them, as the ring's own wake would have.
|
|
174
|
+
private_class_method def self.wake_for_rings(id, state_dir)
|
|
175
|
+
return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
|
|
176
|
+
|
|
177
|
+
SessionManager.wake_for_report(id, state_dir: state_dir)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
183
|
+
# while +move+ goes to it.
|
|
184
|
+
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
|
|
185
|
+
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
186
|
+
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
187
|
+
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
188
|
+
before_spawn = lambda do |link|
|
|
189
|
+
ChildMove.mark_starting(link.id, state_dir: state_dir)
|
|
190
|
+
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
191
|
+
end
|
|
192
|
+
link = SessionManager.spawn_session(
|
|
193
|
+
prompt: start, title: title, working_directory: previous.working_directory,
|
|
194
|
+
model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
|
|
195
|
+
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
196
|
+
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
197
|
+
)
|
|
198
|
+
link.moved_children = move
|
|
199
|
+
link
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The refusals that come before anything changes: continued already (no
|
|
203
|
+
# forks in a chain), an open delegate that can't move to the new link
|
|
204
|
+
# (ChildMove.plan), the folder gone (the worker would run elsewhere), a
|
|
205
|
+
# model whose host config.yml no longer has.
|
|
206
|
+
# @return [ChildMove::Plan]
|
|
207
|
+
# @raise [Refused, ModelProfile::MissingModel]
|
|
208
|
+
private_class_method def self.check!(previous, state_dir)
|
|
209
|
+
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
210
|
+
raise Refused.new(previous.id, :continued, ids: [following])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
plan = move_plan!(previous.id, state_dir)
|
|
214
|
+
# Its model's host, as SessionManager.spawn_session checks it: before
|
|
215
|
+
# the archive stops the previous link's worker.
|
|
216
|
+
ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
|
|
217
|
+
dir = previous.working_directory.to_s
|
|
218
|
+
raise Refused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
219
|
+
|
|
220
|
+
plan
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A child still open below a session (#open_children); why: running,
|
|
224
|
+
# waiting, live, unreported reply, or why it can't move (ChildMove).
|
|
225
|
+
OpenChild = Data.define(:id, :short_id, :why)
|
|
226
|
+
|
|
227
|
+
# The delegates a continue of +id+ moves (ChildMove.plan).
|
|
228
|
+
# @return [ChildMove::Plan]
|
|
229
|
+
# @raise [Refused] for open ones it can't move
|
|
230
|
+
private_class_method def self.move_plan!(id, state_dir)
|
|
231
|
+
plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
|
|
232
|
+
return plan if plan.refuse.empty?
|
|
233
|
+
|
|
234
|
+
raise Refused.new(id, :open_children, ids: plan.refuse.map(&:id),
|
|
235
|
+
detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The unarchived children, at any depth (the archive's cascade), of the
|
|
239
|
+
# session still running, waiting for an answer, with a live worker, or
|
|
240
|
+
# (a delegate) with a reply its parent wasn't given (ChildrenStatus).
|
|
241
|
+
# @return [Array<OpenChild>]
|
|
242
|
+
def self.open_children(id, state_dir)
|
|
243
|
+
require_relative "children_status"
|
|
244
|
+
nodes = [id, *SessionManager.descendant_ids(id, state_dir)]
|
|
245
|
+
nodes.flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id).filter_map do |child|
|
|
246
|
+
why = if %w[waiting running].include?(child.state) then child.state
|
|
247
|
+
elsif child.live then "live"
|
|
248
|
+
elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
|
|
249
|
+
end
|
|
250
|
+
why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Ask the session's live worker for a recap now (Bridge POST /recap:
|
|
255
|
+
# IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
|
|
256
|
+
# to change. A worker that is gone wrote its recap when it left; one
|
|
257
|
+
# that has nothing new to say, too short a conversation, or recaps off
|
|
258
|
+
# answers at once. Best effort: the continue goes on without.
|
|
259
|
+
private_class_method def self.recap_before_archive(id, state_dir, wait:)
|
|
260
|
+
return unless SessionManager.worker_live?(id, state_dir: state_dir)
|
|
261
|
+
|
|
262
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
263
|
+
client = BridgeClient.discover(id, session_dir: dir) or return
|
|
264
|
+
before = RecapStore.read(dir)
|
|
265
|
+
reply = client.request_recap
|
|
266
|
+
asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
|
|
267
|
+
return unless %w[started in_flight].include?(asked)
|
|
268
|
+
|
|
269
|
+
BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|