samagotchi 0.90.0 → 0.92.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +17 -13
  6. data/docs/bundle-authoring.md +6 -11
  7. data/docs/cli.md +7 -4
  8. data/docs/configuration.md +8 -4
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +27 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/prompt-caching.md +4 -0
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/lib/samagotchi/bridge.rb +11 -7
  21. data/lib/samagotchi/bundle_command/check.rb +43 -12
  22. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  23. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  24. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  25. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  26. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  27. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  28. data/lib/samagotchi/bundle_command/try/trace.rb +26 -15
  29. data/lib/samagotchi/bundle_command.rb +11 -213
  30. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  31. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  33. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/self_map.md +5 -0
  36. data/lib/samagotchi/child_env.rb +50 -0
  37. data/lib/samagotchi/client_id.rb +3 -0
  38. data/lib/samagotchi/config.rb +3 -0
  39. data/lib/samagotchi/context_note.rb +13 -6
  40. data/lib/samagotchi/engine.rb +31 -15
  41. data/lib/samagotchi/hooks/registry.rb +2 -0
  42. data/lib/samagotchi/kernel_loop.rb +14 -3
  43. data/lib/samagotchi/last_turn.rb +3 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  45. data/lib/samagotchi/llm/errors.rb +4 -4
  46. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  47. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  48. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  49. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  50. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  51. data/lib/samagotchi/parent_report.rb +12 -0
  52. data/lib/samagotchi/reply_wait.rb +6 -2
  53. data/lib/samagotchi/sandbox_layout.rb +66 -0
  54. data/lib/samagotchi/send_command.rb +3 -3
  55. data/lib/samagotchi/session_manager.rb +12 -20
  56. data/lib/samagotchi/task_finish_note.rb +90 -0
  57. data/lib/samagotchi/task_watch.rb +228 -0
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
  59. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  60. data/lib/samagotchi/testing/bundle.rb +32 -19
  61. data/lib/samagotchi/testing/install.rb +26 -17
  62. data/lib/samagotchi/testing/sandbox.rb +45 -37
  63. data/lib/samagotchi/testing/waiting.rb +5 -7
  64. data/lib/samagotchi/tool_declarations.rb +16 -0
  65. data/lib/samagotchi/tool_response.rb +28 -6
  66. data/lib/samagotchi/tools/builtins.rb +5 -1
  67. data/lib/samagotchi/tools/task_record.rb +9 -0
  68. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  69. data/lib/samagotchi/tools/task_wait.rb +10 -3
  70. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/public/app.js +24 -14
  73. data/lib/samagotchi/web/public/cards_place.js +4 -1
  74. data/lib/samagotchi/web/public/images.js +6 -0
  75. data/lib/samagotchi/web/public/model_pick.js +10 -0
  76. data/lib/samagotchi/web/public/notify.js +4 -4
  77. data/lib/samagotchi/web/public/toasts.js +17 -9
  78. data/lib/samagotchi/web/public/turn_events.js +13 -1
  79. data/lib/samagotchi/web/public/unqueue.js +20 -4
  80. data/lib/samagotchi/worker.rb +104 -42
  81. data/lib/samagotchi/worker_idle_exit.rb +26 -3
  82. data/lib/samagotchi/worker_inbound.rb +18 -4
  83. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  84. data/lib/samagotchi/worker_wakes.rb +11 -4
  85. metadata +8 -1
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../cli/exit"
4
+ require_relative "../../cli/flags"
5
+ require_relative "../../bundle_session_settings"
6
+ require_relative "../../thinking"
7
+ require_relative "sandbox"
8
+ require_relative "run"
9
+ require_relative "trace"
10
+ require_relative "render"
11
+
12
+ module Samagotchi
13
+ class BundleCommand
14
+ module Try
15
+ # `chi bundle try PATH -p TEXT [options]` (docs/cli.md, "Trying a
16
+ # bundle"): checks the arguments, then installs PATH in a Sandbox, runs
17
+ # one turn (Run), stops what the run left (Sandbox#stop_leftovers),
18
+ # reads its Trace and prints it (Render). BundleCommand parses the
19
+ # flags (FLAGS, HELP) and hands the result to #run.
20
+ class Command
21
+ HELP = <<~TEXT
22
+ Usage: chi bundle try PATH -p TEXT [--model NAME] [--memory NAME] [--mute NAME] [--toolkit NAME]
23
+ [--thinking LEVEL] [--set KEY=VALUE] [--json] [--keep] [--timeout SECONDS]
24
+
25
+ Install PATH and run one non-interactive turn in an isolated temporary config, state and work directory.
26
+ The user's hosts, keys and ordinary runtime settings are retained; their bundles, hooks, guardrails,
27
+ memories, prompt additions, sessions and attached context sources are not. Shipped guardrails and a
28
+ $1 cost cap are installed for the run. --set KEY=VALUE sets the tried bundle's config.yml bundles entry;
29
+ --set cost-cap.cap=N overrides the cap (unlike chi --set BUNDLE.KEY=VALUE, these are config settings).
30
+ --memory and --mute accept comma-separated names and repeat. --keep retains the sandbox and debug log.
31
+ --json prints the experimental machine-readable trace.
32
+ Exit status: child turn's status; 1 on install failure or timeout; 2 for usage; 130 on Ctrl-C.
33
+ TEXT
34
+ FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A-/) do |f|
35
+ f.value "-p", "--prompt", key: :prompt
36
+ f.value "--model"
37
+ f.value "--memory", repeat: true
38
+ f.value "--mute", repeat: true
39
+ f.value "--toolkit"
40
+ f.value "--thinking"
41
+ f.value "--set", repeat: true
42
+ f.switch "--json"
43
+ f.switch "--keep"
44
+ f.value "--timeout"
45
+ f.refuse "--attach", "--shared", "chi bundle try can't use --attach or --shared"
46
+ end
47
+ DEFAULT_TIMEOUT = 300
48
+ # The sandbox's cost-cap bundle setting (--set cost-cap.cap=N).
49
+ COST_CAP_KEY = "cost-cap.cap"
50
+ DEFAULT_COST_CAP = 1
51
+ # The tried bundle's --set values, and the cap for the run.
52
+ Settings = Data.define(:bundle, :cost_cap, :cost_cap_override)
53
+
54
+ def initialize(stdout:, stderr:)
55
+ @stdout = stdout
56
+ @stderr = stderr
57
+ end
58
+
59
+ # @param parsed [CLI::Flags::Result] FLAGS' parse of the arguments
60
+ # @return [Integer] exit status
61
+ def run(parsed)
62
+ options = parsed.options
63
+ path = checked_path(parsed) or return CLI::Exit::USAGE
64
+ timeout = parse_timeout(options[:timeout]) or return CLI::Exit::USAGE
65
+ settings = parse_settings(Array(options[:set])) or return CLI::Exit::USAGE
66
+
67
+ try(path, options, timeout, settings)
68
+ end
69
+
70
+ private
71
+
72
+ def try(path, options, timeout, settings)
73
+ render = Render.new(stdout: @stdout, stderr: @stderr)
74
+ sandbox = nil
75
+ begin
76
+ sandbox = Sandbox.new(path, bundle_settings: settings.bundle, cost_cap: settings.cost_cap,
77
+ cost_cap_override: settings.cost_cap_override)
78
+ sandbox.install
79
+ result = Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
80
+ # Before the trace: a delegate's worker saves its session as it stops.
81
+ sandbox.stop_leftovers
82
+ trace = TraceReader.new(state_home: sandbox.state_home, session_id: result.session_id)
83
+ .read(exit_code: result.exit_status, answer: result.stdout, stderr: result.stderr,
84
+ sandbox: options[:keep] ? sandbox.root : nil, timed_out: result.timed_out)
85
+ render.trace(trace, json: options[:json], child_stderr: result.stderr)
86
+ @stderr.puts "chi bundle try: timed out after #{timeout} seconds" if result.timed_out
87
+ result.exit_status
88
+ rescue Sandbox::Error => e
89
+ @stderr.puts e.message
90
+ e.status
91
+ ensure
92
+ if options[:keep] && sandbox
93
+ sandbox.stop_leftovers
94
+ render.sandbox(sandbox)
95
+ end
96
+ sandbox&.cleanup unless options[:keep]
97
+ end
98
+ end
99
+
100
+ # @return [String, nil] PATH, or nil after saying what's wrong
101
+ def checked_path(parsed)
102
+ options = parsed.options
103
+ if options[:prompt].nil? || parsed.args.size != 1
104
+ @stderr.puts "Usage: chi bundle try PATH -p TEXT [options]"
105
+ return nil
106
+ end
107
+ path = parsed.args.first
108
+ unless File.directory?(path)
109
+ @stderr.puts "chi bundle try: #{path} isn't a folder"
110
+ return nil
111
+ end
112
+ if options.values_at(:model, :toolkit, :thinking, :memory, :mute).flatten.compact.any?(&:empty?)
113
+ @stderr.puts "chi bundle try: model, toolkit, thinking, memory and mute values cannot be empty"
114
+ return nil
115
+ end
116
+ if options[:thinking] && !Thinking::LEVELS.map(&:to_s).include?(options[:thinking])
117
+ @stderr.puts "chi bundle try: --thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}"
118
+ return nil
119
+ end
120
+ path
121
+ end
122
+
123
+ def parse_timeout(value)
124
+ return DEFAULT_TIMEOUT unless value
125
+
126
+ timeout = Float(value)
127
+ return timeout if timeout.positive? && timeout.finite?
128
+
129
+ @stderr.puts "chi bundle try: --timeout must be a positive number"
130
+ nil
131
+ rescue ArgumentError
132
+ @stderr.puts "chi bundle try: --timeout must be a positive number"
133
+ nil
134
+ end
135
+
136
+ # The --set KEY=VALUE pairs, typed as BundleSessionSettings.typed:
137
+ # cost-cap.cap is the run's cap, the others the tried bundle's.
138
+ # @return [Settings, nil] nil after saying what's wrong
139
+ def parse_settings(pairs)
140
+ bundle = {}
141
+ cost_cap = DEFAULT_COST_CAP
142
+ override = false
143
+ pairs.each do |pair|
144
+ key, sep, value = pair.partition("=")
145
+ if key == COST_CAP_KEY && !sep.empty?
146
+ cost_cap = BundleSessionSettings.typed(value)
147
+ override = true
148
+ next if (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
149
+
150
+ @stderr.puts "chi bundle try: --set #{COST_CAP_KEY} takes a non-negative number or off"
151
+ return nil
152
+ end
153
+ unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
154
+ @stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
155
+ return nil
156
+ end
157
+ bundle[key] = BundleSessionSettings.typed(value)
158
+ end
159
+ Settings.new(bundle: bundle, cost_cap: cost_cap, cost_cap_override: override)
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Samagotchi
6
+ class BundleCommand
7
+ module Try
8
+ # What `chi bundle try` prints of a run: the trace's text lines (on
9
+ # stdout, or on stderr under the --json report) and --keep's paths.
10
+ class Render
11
+ def initialize(stdout:, stderr:)
12
+ @stdout = stdout
13
+ @stderr = stderr
14
+ end
15
+
16
+ # The child's stderr (without its Session: line), then the trace.
17
+ def trace(trace, json:, child_stderr:)
18
+ error_lines = child_stderr.lines.reject { |line| line.start_with?("Session: ") }
19
+ @stderr.write(error_lines.join)
20
+ if json
21
+ lines(trace, @stderr, include_answer: false)
22
+ @stdout.puts JSON.generate(trace.to_h.transform_keys(&:to_s))
23
+ else
24
+ lines(trace, @stdout, include_answer: true)
25
+ end
26
+ end
27
+
28
+ def sandbox(sandbox)
29
+ @stderr.puts "sandbox: #{sandbox.root}"
30
+ @stderr.puts "debug log: #{File.join(sandbox.state_home, "samagotchi", "samagotchi.log")}"
31
+ end
32
+
33
+ def lines(trace, output, include_answer:)
34
+ output.puts "tools:"
35
+ trace.tools.each do |tool|
36
+ output.puts format(" %d %-16s %-7s %dms %s", tool[:index], tool[:name], tool[:status],
37
+ tool[:duration_ms], JSON.generate(tool[:args].to_s[0, 60]))
38
+ end
39
+ trace.notices.each { |notice| output.puts "notices: [#{notice["hook"] || "hook"}] #{notice["text"]}" }
40
+ trace.cards.each do |card|
41
+ output.puts "card: #{[card["title"], card["body"]].compact.join(" — ")}"
42
+ end
43
+ trace.questions.each do |entry|
44
+ question = entry.dig("pending_question", "question").to_s
45
+ outcome = if entry["cancelled"] && entry["reason"] == "non_interactive"
46
+ "unanswerable (non_interactive)"
47
+ elsif entry["cancelled"]
48
+ "cancelled (#{entry["reason"]})"
49
+ elsif entry["answer"]
50
+ "answered"
51
+ else
52
+ "unanswered"
53
+ end
54
+ output.puts "questions: #{JSON.generate(question)} -> #{outcome}"
55
+ end
56
+ if trace.interactive_blocked
57
+ count = trace.denied.size + trace.questions.count { |question| question["cancelled"] && question["reason"] == "non_interactive" }
58
+ output.puts "warn: #{count} question(s)/call(s) couldn't be answered"
59
+ end
60
+ if include_answer
61
+ answer_lines = trace.answer.lines
62
+ output.puts "answer:"
63
+ answer_lines.first(10).each { |line| output.puts " #{line.chomp}" }
64
+ output.puts " … (truncated)" if answer_lines.size > 10
65
+ end
66
+ tokens = trace.tokens
67
+ prompt = tokens[:prompt] || "unknown"
68
+ completion = tokens[:completion] || "unknown"
69
+ cost = if trace.cost
70
+ "#{"~" if trace.cost_is_estimate}$#{format("%.4f", trace.cost)}"
71
+ else
72
+ "unknown"
73
+ end
74
+ output.puts "tokens: #{prompt} in / #{completion} out cost #{cost} exit #{trace.exit_code}" \
75
+ "#{" (#{trace.session_id})" if trace.session_id}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -2,10 +2,20 @@
2
2
 
3
3
  require "open3"
4
4
  require "rbconfig"
5
+ require_relative "../../process_group"
5
6
 
6
7
  module Samagotchi
7
8
  class BundleCommand
8
9
  module Try
10
+ # The sandboxed child chi of `chi bundle try`, in a process group of
11
+ # its own (ProcessGroup). A --timeout sends it INT (a Ctrl-C: the turn
12
+ # is saved), then after INTERRUPT_GRACE ProcessGroup.stop stops the
13
+ # group and the groups its processes started. After any exit the
14
+ # group is stopped only while something of the child's is still in it
15
+ # (#stop_stragglers): a group signal to a leader reaped with nothing
16
+ # left would reach whatever reuses its pid. What left the group and
17
+ # its parent (a delegate's worker, a task once the child exited) is
18
+ # Sandbox#stop_leftovers', by the records in the sandbox.
9
19
  class Run
10
20
  Result = Data.define(:exit_status, :stdout, :stderr, :session_id, :timed_out)
11
21
  INTERRUPT_GRACE = 5
@@ -30,18 +40,19 @@ module Samagotchi
30
40
  streams = [stdout, stderr]
31
41
  stdin.close
32
42
  readers = streams.map { |stream| Thread.new { read_stream(stream) } }
33
- attach_child(trap_state, pid)
43
+ attach_child(trap_state, pid, wait)
34
44
  timed_out = false
35
45
  status = wait_for(wait, timeout)
36
46
  if status == :timeout
37
47
  timed_out = true
38
- signal_group(pid, "INT")
48
+ ProcessGroup.signal(pid, "INT")
39
49
  status = wait_for(wait, INTERRUPT_GRACE)
40
50
  if status == :timeout
41
- terminate_group(pid)
51
+ ProcessGroup.stop(pid, grace: GROUP_TERM_GRACE, poll: POLL_INTERVAL, stopped: -> { !wait.alive? })
42
52
  status = wait.value
43
53
  end
44
54
  end
55
+ trap_state[:pid] = nil
45
56
  out, err = finish_readers(readers, streams)
46
57
  code = timed_out ? CLI::Exit::FAILED : process_exit_status(status)
47
58
  Result.new(exit_status: code, stdout: out, stderr: err, session_id: err[/^Session: (\S+)/, 1],
@@ -50,7 +61,8 @@ module Samagotchi
50
61
  rescue SystemCallError => e
51
62
  raise Sandbox::Error, "chi bundle try: couldn't run chi: #{e.message}"
52
63
  ensure
53
- terminate_group(pid) if pid
64
+ trap_state[:pid] = nil
65
+ stop_stragglers(pid) if pid
54
66
  close_streams(streams)
55
67
  stop_readers(readers)
56
68
  restore_traps(old_traps)
@@ -79,12 +91,13 @@ module Samagotchi
79
91
  end
80
92
 
81
93
  def trap_forwarding
82
- state = { pid: nil, pending: nil }
94
+ state = { pid: nil, wait: nil, pending: nil }
83
95
  old = {}
84
96
  %w[INT TERM].each do |signal|
85
97
  old[signal] = Signal.trap(signal) do
86
98
  if state[:pid]
87
- signal_group(state[:pid], signal)
99
+ # Not once its waiter reaped it: that group may be gone.
100
+ ProcessGroup.signal(state[:pid], signal) if state[:wait]&.alive?
88
101
  else
89
102
  state[:pending] ||= signal
90
103
  end
@@ -93,27 +106,32 @@ module Samagotchi
93
106
  [state, old]
94
107
  end
95
108
 
96
- def attach_child(state, pid)
109
+ def attach_child(state, pid, wait)
110
+ state[:wait] = wait
97
111
  state[:pid] = pid
98
112
  pending = state[:pending]
99
113
  state[:pending] = nil
100
- signal_group(pid, pending) if pending
114
+ ProcessGroup.signal(pid, pending) if pending
101
115
  end
102
116
 
103
117
  def restore_traps(old)
104
118
  old.each { |signal, handler| Signal.trap(signal, handler) }
105
119
  end
106
120
 
107
- def signal_group(pid, signal)
108
- Process.kill(signal, -pid)
109
- rescue Errno::ESRCH
110
- nil
111
- end
121
+ # Stops what is left in the child's group once the child (its
122
+ # leader) is reaped: a descendant still holding its pipes. Only
123
+ # while one is in it: no process left means no group, and the pid
124
+ # may be reused by now. While a member is left the group's id
125
+ # can't be reused; the reaped leader's pid can, so a group whose
126
+ # member is the pid itself is someone else's.
127
+ def stop_stragglers(pid)
128
+ return unless ProcessGroup.signalable?(pid) && ProcessGroup.group_alive?(pid)
129
+
130
+ members = ProcessGroup.process_table.select { |_pid, _ppid, group| group == pid }.map(&:first)
131
+ return if members.empty? || members.include?(pid)
112
132
 
113
- def terminate_group(pid)
114
- signal_group(pid, "TERM")
115
- sleep(GROUP_TERM_GRACE)
116
- signal_group(pid, "KILL")
133
+ ProcessGroup.stop(pid, grace: GROUP_TERM_GRACE, poll: POLL_INTERVAL,
134
+ stopped: -> { !ProcessGroup.group_alive?(pid) })
117
135
  end
118
136
 
119
137
  def read_stream(stream)
@@ -6,11 +6,18 @@ require "rbconfig"
6
6
  require "tmpdir"
7
7
  require "yaml"
8
8
  require_relative "../../config"
9
+ require_relative "../../child_env"
9
10
  require_relative "../../cli/exit"
10
- require_relative "../../installed_gem"
11
+ require_relative "../../memory_bundle/system_bundle"
11
12
  require_relative "../../memory_bundle"
12
13
  require_relative "../../memory_bundle/shas"
14
+ require_relative "../../owner_lock"
15
+ require_relative "../../process_group"
16
+ require_relative "../../sandbox_layout"
17
+ require_relative "../../session"
13
18
  require_relative "../../session_manager"
19
+ require_relative "../../tools/task_record"
20
+ require_relative "../../tools/task_runtime"
14
21
 
15
22
  module Samagotchi
16
23
  class BundleCommand
@@ -19,18 +26,46 @@ module Samagotchi
19
26
  LIB_DIR = File.expand_path("../../..", __dir__)
20
27
  CHI = File.expand_path("../../../../bin/chi", __dir__)
21
28
  SHIPPED_DIR = MemoryBundle::SourceNormalizer::SHIPPED_DIR
29
+ # The config.yml sections the sandbox drops (and their SAMAGOTCHI_<SECTION>_* env forms); context:
30
+ # loses only its sources: (#write_config).
22
31
  CONFIG_DENYLIST = %w[
23
32
  bundles hooks guardrails memories append_system_prompt recap session log web update kitty tmux broadcast
24
- context_sources
25
33
  ].freeze
26
34
  CONFIG_DENYLIST_ENV_PREFIXES = CONFIG_DENYLIST.map do |section|
27
35
  prefix = if section == "guardrails"
28
- SessionManager::GUARDRAILS_ENV_PREFIX.delete_suffix("_")
36
+ ChildEnv::GUARDRAILS_PREFIX.delete_suffix("_")
29
37
  else
30
38
  "SAMAGOTCHI_#{section.upcase}"
31
39
  end
32
40
  prefix
33
41
  end.freeze
42
+ # `chi bundle install PATH --force` for each PATH in ARGV, in one
43
+ # process (bin/chi's start, once): an install that fails prints
44
+ # what it printed and exits with its status; the ones before it
45
+ # print nothing.
46
+ INSTALL_SCRIPT = <<~RUBY
47
+ require "stringio"
48
+ require "samagotchi/utf8_default"
49
+ Samagotchi::Utf8Default.apply!
50
+ require "samagotchi/config"
51
+ Samagotchi::ConfigFile.load!
52
+ require "samagotchi"
53
+ require "samagotchi/bundle_command"
54
+ ARGV.each do |path|
55
+ out = StringIO.new
56
+ err = StringIO.new
57
+ status = Samagotchi::BundleCommand.new(["install", path, "--force"], stdout: out, stderr: err).run
58
+ next if status.zero?
59
+
60
+ $stdout.write(out.string)
61
+ $stderr.write(err.string)
62
+ exit(status)
63
+ end
64
+ RUBY
65
+ # How long a leftover worker or task gets after TERM (a worker saves
66
+ # its session) before ProcessGroup.stop KILLs it.
67
+ LEFTOVER_GRACE = 3.0
68
+ LEFTOVER_POLL = 0.05
34
69
  class Error < StandardError
35
70
  attr_reader :status
36
71
 
@@ -40,7 +75,7 @@ module Samagotchi
40
75
  end
41
76
  end
42
77
 
43
- attr_reader :root, :config_home, :state_home, :work_dir, :manifest
78
+ attr_reader :root, :config_home, :state_home, :work_dir
44
79
 
45
80
  def initialize(path, bundle_settings: {}, cost_cap: 1, cost_cap_override: false)
46
81
  @path = File.realpath(path)
@@ -48,12 +83,11 @@ module Samagotchi
48
83
  @cost_cap = cost_cap
49
84
  @cost_cap_override = cost_cap_override
50
85
  read_manifest
51
- @root = File.realpath(Dir.mktmpdir("chi-bundle-try-"))
52
- @config_home = File.join(root, "config")
53
- @state_home = File.join(root, "state")
54
- @work_dir = File.join(root, "work")
55
- [config_home, state_home, work_dir].each { |dir| FileUtils.mkdir_p(dir) }
56
- @work_dir = File.realpath(@work_dir)
86
+ @layout = SandboxLayout.create("chi-bundle-try-")
87
+ @root = @layout.root
88
+ @config_home = @layout.config_home
89
+ @state_home = @layout.state_home
90
+ @work_dir = @layout.work_dir
57
91
  write_config
58
92
  @child_env = build_child_env
59
93
  rescue StandardError
@@ -63,20 +97,43 @@ module Samagotchi
63
97
 
64
98
  def child_env = @child_env.dup
65
99
 
100
+ # Installs the tried bundle, then the shipped guardrails and
101
+ # cost-cap (unless it is one of them), each as `chi bundle install
102
+ # PATH --force` in one child chi with the sandbox's environment.
103
+ # @return [MemoryBundle::Manifest]
104
+ # @raise [Error] what the first install that failed printed
66
105
  def install
67
- install_path(@path)
68
- %w[guardrails cost-cap].each do |name|
69
- next if @manifest.name == name
70
-
71
- install_path(File.join(SHIPPED_DIR, name))
106
+ paths = [@path] + %w[guardrails cost-cap].reject { |name| @manifest.name == name }
107
+ .map { |name| File.join(SHIPPED_DIR, name) }
108
+ out, err, status = Open3.capture3(child_env, RbConfig.ruby, "-I", LIB_DIR, "-e", INSTALL_SCRIPT, *paths,
109
+ chdir: work_dir)
110
+ unless status.success?
111
+ message = [out, err].reject(&:empty?).join("\n").strip
112
+ raise Error, "chi bundle try: #{message.empty? ? "bundle install failed" : message}"
72
113
  end
73
114
  @manifest
74
115
  end
75
116
 
76
117
  def cleanup
118
+ stop_leftovers
77
119
  FileUtils.rm_rf(root) if root && File.exist?(root)
78
120
  end
79
121
 
122
+ # Stops what the run started that outlives its child chi: the
123
+ # workers of its delegates (they leave the child's process tree,
124
+ # SessionManager.detach_from_spawner) and its tasks (a group of
125
+ # their own each, reparented once the child exits). Found by their
126
+ # records in the sandbox, so nothing outside it: a session's lock
127
+ # held by a group leader, a running task's record whose pid still
128
+ # leads its group and started when the record says (#same_process?). Workers
129
+ # first: one may start a task as it stops.
130
+ def stop_leftovers
131
+ return unless root && File.directory?(root)
132
+
133
+ leftover_workers.each { |pid| stop_group(pid) }
134
+ leftover_tasks.each { |pid| stop_group(pid) }
135
+ end
136
+
80
137
  private
81
138
 
82
139
  def write_config
@@ -92,9 +149,7 @@ module Samagotchi
92
149
  data["bundles"][@manifest.name] ||= {}
93
150
  data["bundles"][@manifest.name].merge!(@bundle_settings)
94
151
  data["bundles"]["cost-cap"]["cap"] = @cost_cap if @cost_cap_override
95
- path = File.join(config_home, "samagotchi", "config.yml")
96
- FileUtils.mkdir_p(File.dirname(path))
97
- File.write(path, YAML.dump(data))
152
+ @layout.write_config(data)
98
153
  end
99
154
 
100
155
  def read_manifest
@@ -121,36 +176,60 @@ module Samagotchi
121
176
  raise Error, "chi bundle try: manifest.yml is not valid YAML (#{e.message})"
122
177
  end
123
178
 
124
- def install_path(path)
125
- out, err, status = Open3.capture3(child_env, RbConfig.ruby, "-I", LIB_DIR, CHI,
126
- "bundle", "install", path, "--force", chdir: work_dir)
127
- return if status.success?
179
+ def leftover_workers
180
+ sessions = File.join(state_home, Session::STATE_SUBDIR)
181
+ Dir.glob(File.join(sessions, "*", OwnerLock::FILE)).filter_map do |lock|
182
+ pid = OwnerLock.owner(File.dirname(lock))&.pid
183
+ pid if ProcessGroup.leader?(pid)
184
+ end
185
+ end
186
+
187
+ def leftover_tasks
188
+ pattern = File.join(work_dir, Tools::TaskRuntime::TASKS_DIR, "*", Tools::TaskRuntime::METADATA_FILENAME)
189
+ Dir.glob(pattern).filter_map do |file|
190
+ record = Tools::TaskRecord.from_file(JSON.parse(File.read(file)))
191
+ record.pid if record&.running? && same_process?(record)
192
+ rescue JSON::ParserError, SystemCallError
193
+ nil
194
+ end
195
+ end
196
+
197
+ # Stricter than TaskRuntime.chis_group?: a record with no start time
198
+ # (a hand-over not adopted yet) or a start time that can't be read
199
+ # now is skipped, never a group whose leader may reuse the pid.
200
+ def same_process?(record)
201
+ started = record.pid_started
202
+ return false unless started && ProcessGroup.signalable?(record.pid)
203
+
204
+ ProcessGroup.start_time(record.pid) == started && ProcessGroup.leader?(record.pid)
205
+ end
128
206
 
129
- message = [out, err].reject(&:empty?).join("\n").strip
130
- raise Error, "chi bundle try: #{message.empty? ? "bundle install failed" : message}"
207
+ def stop_group(pid)
208
+ ProcessGroup.stop(pid, grace: LEFTOVER_GRACE, poll: LEFTOVER_POLL)
209
+ rescue ArgumentError, SystemCallError
210
+ nil
131
211
  end
132
212
 
213
+ # The user's environment (their hosts and keys) minus what no child
214
+ # chi takes from its spawner (ChildEnv), the worker markers, this
215
+ # chi's log and test settings, and the env forms of the config
216
+ # sections the sandbox drops.
133
217
  def build_child_env
134
- env = ENV.to_h
135
- SessionManager::SESSION_MARKER_ENV.each { |key| env.delete(key) }
218
+ env = ChildEnv.scrub(ENV.to_h)
136
219
  env.delete("SAMAGOTCHI_LOG_FILE")
137
220
  env.delete("SAMAGOTCHI_LOG_DISABLE")
138
- env.delete("SAMAGOTCHI_SKIP_SYSTEM_BUNDLE")
221
+ env.delete(MemoryBundle::SystemBundle::SKIP_ENV)
139
222
  env.delete("SAMAGOTCHI_ENV") if env["SAMAGOTCHI_ENV"] == "test"
140
- env.delete("SAMAGOTCHI_INSTALLED_VERSION")
141
223
  env.delete_if do |key, _|
142
- key.start_with?(SessionManager::WORKER_ENV_PREFIX) || key == SessionManager::BOOT_FALLBACK_ENV ||
224
+ key.start_with?(ChildEnv::WORKER_PREFIX) || key == SessionManager::BOOT_FALLBACK_ENV ||
143
225
  CONFIG_DENYLIST_ENV_PREFIXES.any? { |prefix| key == prefix || key.start_with?("#{prefix}_") }
144
226
  end
145
227
  env.merge!(
146
228
  "HOME" => Dir.home,
147
229
  "SAMAGOTCHI_KEYCHAIN" => "on",
148
- "XDG_CONFIG_HOME" => config_home,
149
- "XDG_STATE_HOME" => state_home,
150
- "SAMAGOTCHI_RECAP_ENABLED" => "false",
151
- "SAMAGOTCHI_CACHE_WARMUP" => "off"
230
+ **@layout.env
152
231
  )
153
- env.merge!(InstalledGem.unbundled_env_changes(env)) if InstalledGem.spec
232
+ env.merge!(ChildEnv.unbundled(env))
154
233
  env
155
234
  end
156
235
  end