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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -1
@@ -33,17 +33,15 @@ module Samagotchi
33
33
 
34
34
  def run
35
35
  provenance = Provenance.new(name: @name)
36
- data = provenance.read
37
- raise UninstallError, "Bundle '#{@name}' is not installed" unless data
36
+ bundle = provenance.record
37
+ raise UninstallError, "Bundle '#{@name}' is not installed" unless bundle
38
38
 
39
- # Determine scope from flag or provenance
40
- cli_scope = @scope.to_s.strip.empty? ? nil : @scope
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 = data[:files] || {}
46
- hooks = data[: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.is_a?(Hash) && !hooks.empty?
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(data)
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, data|
48
- ok = load_bundle(bundle_name, data, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
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, data, registries, failures: nil, settings: {})
60
- file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(data) unless data[:error]
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 = data[:error] || unloadable_reason(file, data)
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, data)
86
- failure = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
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, data[:plugin][:sha256], required: true)
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, data|
410
- version = data[: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 SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
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
- # The UI-only built-in command +text+ (stripped) is a line of (/stats,
485
- # /exit, …), or nil: the worker never runs it, so chi send must not pass
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
- # Why a UI-only command isn't sent: /stats points at its model-free
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