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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -1
- data/README.md +5 -2
- data/bin/chi +4 -4
- data/docs/architecture.md +17 -13
- data/docs/bundle-authoring.md +6 -11
- data/docs/cli.md +7 -4
- data/docs/configuration.md +8 -4
- data/docs/context.md +5 -3
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +27 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +21 -8
- data/docs/internals/prompt-caching.md +4 -0
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/lib/samagotchi/bridge.rb +11 -7
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +26 -15
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +5 -0
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/client_id.rb +3 -0
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_note.rb +13 -6
- data/lib/samagotchi/engine.rb +31 -15
- data/lib/samagotchi/hooks/registry.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/last_turn.rb +3 -2
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command.rb +3 -3
- data/lib/samagotchi/session_manager.rb +12 -20
- data/lib/samagotchi/task_finish_note.rb +90 -0
- data/lib/samagotchi/task_watch.rb +228 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/testing/bundle.rb +32 -19
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_declarations.rb +16 -0
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/builtins.rb +5 -1
- data/lib/samagotchi/tools/task_record.rb +9 -0
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/tools/task_wait.rb +10 -3
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +24 -14
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/notify.js +4 -4
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/turn_events.js +13 -1
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +104 -42
- data/lib/samagotchi/worker_idle_exit.rb +26 -3
- data/lib/samagotchi/worker_inbound.rb +18 -4
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +11 -4
- 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
|
-
|
|
48
|
+
ProcessGroup.signal(pid, "INT")
|
|
39
49
|
status = wait_for(wait, INTERRUPT_GRACE)
|
|
40
50
|
if status == :timeout
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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 "../../
|
|
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
|
-
|
|
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
|
|
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
|
-
@
|
|
52
|
-
@
|
|
53
|
-
@
|
|
54
|
-
@
|
|
55
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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(
|
|
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?(
|
|
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
|
-
|
|
149
|
-
"XDG_STATE_HOME" => state_home,
|
|
150
|
-
"SAMAGOTCHI_RECAP_ENABLED" => "false",
|
|
151
|
-
"SAMAGOTCHI_CACHE_WARMUP" => "off"
|
|
230
|
+
**@layout.env
|
|
152
231
|
)
|
|
153
|
-
env.merge!(
|
|
232
|
+
env.merge!(ChildEnv.unbundled(env))
|
|
154
233
|
env
|
|
155
234
|
end
|
|
156
235
|
end
|