samagotchi 0.57.0 → 0.59.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 +56 -1
- data/docs/architecture.md +3 -3
- data/docs/cli.md +5 -4
- data/docs/configuration.md +40 -23
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +7 -0
- data/docs/internals/engine.md +5 -5
- data/docs/internals/prompt-caching.md +2 -2
- data/docs/internals/tool-guardrails.md +2 -1
- data/docs/plugins.md +26 -6
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +6 -2
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +11 -5
- data/lib/samagotchi/engine.rb +28 -3
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/model_profile.rb +14 -3
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/redacted_inspect.rb +21 -6
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_commands.rb +19 -6
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/steer_cut_after.rb +69 -0
- data/lib/samagotchi/system_prompt.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
- data/lib/samagotchi/terminal_ui.rb +15 -6
- data/lib/samagotchi/tool_activity.rb +18 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/execute.rb +318 -70
- data/lib/samagotchi/tools/task_get.rb +2 -0
- data/lib/samagotchi/tools/task_runtime.rb +96 -13
- data/lib/samagotchi/tools/task_wait.rb +23 -5
- data/lib/samagotchi/utf8_default.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +8 -2
- data/lib/samagotchi/web/public/app.js +7 -6
- data/lib/samagotchi/web/public/index.html +20 -7
- data/lib/samagotchi/web/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/ref_menu.js +188 -0
- data/lib/samagotchi/web/public/ref_scan.js +11 -1
- data/lib/samagotchi/web/public/refs.js +271 -51
- data/lib/samagotchi/web/public/stage_view.js +21 -0
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- data/lib/samagotchi/worker.rb +2 -2
- metadata +3 -1
|
@@ -11,13 +11,22 @@
|
|
|
11
11
|
# (relative to the project), and a trailing output filter dropped (a final
|
|
12
12
|
# 2>&1, `| tail …`, `| head …`, `| grep …`: they don't change how long it
|
|
13
13
|
# runs). Arguments stay: `bundle exec rspec` (the suite) is not
|
|
14
|
-
# `bundle exec rspec spec/x_spec.rb`.
|
|
14
|
+
# `bundle exec rspec spec/x_spec.rb`. Wrappers a model adds by habit are
|
|
15
|
+
# dropped at the start of each simple command (after &&, ||, ;, a newline,
|
|
16
|
+
# so after a leading `cd X &&` too): `time [-p]` and `timeout`/`gtimeout`
|
|
17
|
+
# with its options and duration. `X=…` assignments stay, sorted (they can
|
|
18
|
+
# pick the target or the mode: SPEC=…, RAILS_ENV=…, CI=… (jest/vitest
|
|
19
|
+
# watch without it)), except NO_COLOR, FORCE_COLOR and TERM, which never
|
|
20
|
+
# change what runs; a plain
|
|
21
|
+
# `env X=… cmd` is `X=… cmd`, an `env` with -C/-i/-u (or any other option)
|
|
22
|
+
# stays as typed. The entry's `shown` is the last run as typed. Input that ends inside a quote, $(…)
|
|
15
23
|
# or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
|
|
16
24
|
# cat/tee read as data: ShellLex makes those one placeholder, so different
|
|
17
25
|
# commands would share a key.
|
|
18
26
|
#
|
|
19
|
-
# after_tool_call records: timed out
|
|
20
|
-
#
|
|
27
|
+
# after_tool_call records: timed out (killed at the limit), handed over as
|
|
28
|
+
# a task at the limit (Execute::HANDED_OVER), or ran at least warn_ratio of
|
|
29
|
+
# the limit -> slow; ran under half of it with exit 0 -> the entry is dropped
|
|
21
30
|
# (a fast failure says nothing about the suite's length), unless a trailing
|
|
22
31
|
# filter was stripped: `rspec | tail` exits with tail's 0 when rspec failed,
|
|
23
32
|
# and a `| head` can end it early. A stopped or
|
|
@@ -25,15 +34,23 @@
|
|
|
25
34
|
# running it again in the same turn is let through (maybe it got faster),
|
|
26
35
|
# and that run records the new truth. before_turn resets it.
|
|
27
36
|
#
|
|
28
|
-
# Store: <data_dir>/commands.json, a list of {project, command
|
|
29
|
-
# limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
|
|
37
|
+
# Store: <data_dir>/commands.json, a list of {project, command (the key),
|
|
38
|
+
# shown, slow_at, limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
|
|
30
39
|
# record counts at once), written whole (AtomicFile), entries older than
|
|
31
40
|
# ttl_days dropped on write.
|
|
32
41
|
#
|
|
33
42
|
# The advice names how long a finished task of the same command took: the
|
|
34
43
|
# project's tmp/tasks/*/task.json read as files (never TaskRuntime's
|
|
35
44
|
# list_records, which mkdirs, runs ps and writes), the exit_code file's
|
|
36
|
-
# mtime minus started_at (finished_at is when chi noticed).
|
|
45
|
+
# mtime minus started_at (finished_at is when chi noticed). The exit_code
|
|
46
|
+
# file is the record's exit_code_path (a handed-over execute keeps it in
|
|
47
|
+
# the state dir), else the one next to task.json.
|
|
48
|
+
#
|
|
49
|
+
# A `timeout N` over the execute limit is denied up front (no history
|
|
50
|
+
# needed) when execute.on_timeout is kill: execute stops it first. Under
|
|
51
|
+
# task (the default) it runs on as a task until timeout fires: nothing to
|
|
52
|
+
# say. Exit codes mean what they always do here: 124/137 (timeout's) are
|
|
53
|
+
# no sign of a slow command by themselves.
|
|
37
54
|
#
|
|
38
55
|
# Settings (config.yml, bundles: slow-commands:):
|
|
39
56
|
# mode: deny deny | notify (warn the user, never deny)
|
|
@@ -46,6 +63,7 @@ require "json"
|
|
|
46
63
|
require "pathname"
|
|
47
64
|
require "time"
|
|
48
65
|
require "samagotchi/atomic_file"
|
|
66
|
+
require "samagotchi/config"
|
|
49
67
|
require "samagotchi/guardrails/shell_lex"
|
|
50
68
|
require "samagotchi/tools/execute"
|
|
51
69
|
require "samagotchi/tools/task_runtime"
|
|
@@ -55,10 +73,23 @@ class Plugin
|
|
|
55
73
|
FILE = "commands.json"
|
|
56
74
|
FILTERS = %w[tail head grep].freeze
|
|
57
75
|
TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
|
|
76
|
+
# Still running at the limit, handed over as a task (chi >= 0.59.0).
|
|
77
|
+
HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
|
|
58
78
|
STOPPED = [Samagotchi::Tools::Execute::STOPPED_BY_USER, Samagotchi::Tools::Execute::NOT_RUN_ON_STOP].freeze
|
|
59
79
|
LEX = Samagotchi::Guardrails::ShellLex
|
|
60
80
|
SHORT_CHARS = 60
|
|
61
81
|
WAIT_DEFAULT = 600
|
|
82
|
+
# Where a simple command starts: a wrapper there is dropped from the key.
|
|
83
|
+
STARTS = ["&&", "||", ";", "\n"].freeze
|
|
84
|
+
ASSIGNMENT = /\A[A-Za-z_][A-Za-z0-9_]*=/
|
|
85
|
+
# Assignments that never change what runs: not part of the key.
|
|
86
|
+
NEUTRAL_VARS = %w[NO_COLOR FORCE_COLOR TERM].freeze
|
|
87
|
+
TIMEOUTS = %w[timeout gtimeout].freeze
|
|
88
|
+
# timeout's options: those that take a value, and the flags.
|
|
89
|
+
TIMEOUT_VALUE_OPTIONS = %w[-s -k --signal --kill-after].freeze
|
|
90
|
+
TIMEOUT_FLAGS = %w[--foreground --preserve-status -v --verbose].freeze
|
|
91
|
+
DURATION = /\A(\d+(?:\.\d+)?|\.\d+)([smhd]?)\z/
|
|
92
|
+
UNIT_SECONDS = { "" => 1, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
|
|
62
93
|
|
|
63
94
|
def initialize(settings = {})
|
|
64
95
|
@mode = settings["mode"].to_s == "notify" ? :notify : :deny
|
|
@@ -87,6 +118,7 @@ class Plugin
|
|
|
87
118
|
tokens = LEX.lex(text)
|
|
88
119
|
return nil if tokens.any? { |kind, value| kind == :word && value.include?("\0") }
|
|
89
120
|
|
|
121
|
+
tokens = unwrap(tokens).first
|
|
90
122
|
tokens = strip_filters(tokens)
|
|
91
123
|
return nil if tokens.empty?
|
|
92
124
|
|
|
@@ -96,6 +128,95 @@ class Plugin
|
|
|
96
128
|
words.join(" ")
|
|
97
129
|
end
|
|
98
130
|
|
|
131
|
+
# The tokens without the wrappers at the start of each simple command
|
|
132
|
+
# (time, timeout), assignments sorted and the neutral ones dropped, and
|
|
133
|
+
# the seconds of every timeout dropped. A simple command left with no
|
|
134
|
+
# words (`NO_COLOR=1 && rspec`) goes with the operator after it, and a
|
|
135
|
+
# last one with the operator before it.
|
|
136
|
+
# @return [Array(Array, Array<Float>)]
|
|
137
|
+
def self.unwrap(tokens)
|
|
138
|
+
durations = []
|
|
139
|
+
out = []
|
|
140
|
+
segment = []
|
|
141
|
+
flush = lambda do
|
|
142
|
+
words, seconds = unwrap_command(segment)
|
|
143
|
+
durations.concat(seconds)
|
|
144
|
+
out.concat(words)
|
|
145
|
+
segment = []
|
|
146
|
+
!words.empty?
|
|
147
|
+
end
|
|
148
|
+
tokens.each do |token|
|
|
149
|
+
if token[0] == :op && STARTS.include?(token[1])
|
|
150
|
+
out << token if flush.call
|
|
151
|
+
else
|
|
152
|
+
segment << token
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
out.pop if !flush.call && out.last&.first == :op && STARTS.include?(out.last[1])
|
|
156
|
+
[out, durations]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One simple command's tokens (it may hold a | or & further on: only its
|
|
160
|
+
# start is looked at).
|
|
161
|
+
def self.unwrap_command(tokens)
|
|
162
|
+
rest = tokens.dup
|
|
163
|
+
assignments = []
|
|
164
|
+
durations = []
|
|
165
|
+
loop do
|
|
166
|
+
assignments << rest.shift[1] while word?(rest.first) && rest.first[1].match?(ASSIGNMENT)
|
|
167
|
+
head = word?(rest.first) ? rest.first[1] : nil
|
|
168
|
+
# time -p only: another option (GNU time -v) is a different program.
|
|
169
|
+
if head == "time" && !(word?(rest[1]) && rest[1][1].start_with?("-") && rest[1][1] != "-p")
|
|
170
|
+
rest.shift
|
|
171
|
+
rest.shift if word?(rest.first) && rest.first[1] == "-p"
|
|
172
|
+
elsif head == "env" && word?(rest[1]) && !rest[1][1].start_with?("-")
|
|
173
|
+
rest.shift
|
|
174
|
+
elsif TIMEOUTS.include?(head) && (skipped = timeout_length(rest))
|
|
175
|
+
count, seconds = skipped
|
|
176
|
+
rest.shift(count)
|
|
177
|
+
durations << seconds
|
|
178
|
+
else
|
|
179
|
+
break
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
kept = assignments.reject { |a| NEUTRAL_VARS.include?(a.split("=", 2).first) }.sort
|
|
183
|
+
[kept.map { |a| [:word, a, ""] } + rest, durations]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# [words to drop, seconds] for a `timeout [opts] DUR` at the start of
|
|
187
|
+
# +tokens+, or nil when it can't be read (then it stays in the key).
|
|
188
|
+
def self.timeout_length(tokens)
|
|
189
|
+
i = 1
|
|
190
|
+
while word?(tokens[i]) && tokens[i][1].start_with?("-")
|
|
191
|
+
option = tokens[i][1]
|
|
192
|
+
if TIMEOUT_FLAGS.include?(option) || option.start_with?("--signal=", "--kill-after=")
|
|
193
|
+
i += 1
|
|
194
|
+
elsif TIMEOUT_VALUE_OPTIONS.include?(option)
|
|
195
|
+
i += 2
|
|
196
|
+
else
|
|
197
|
+
return nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
seconds = word?(tokens[i]) ? duration_seconds(tokens[i][1]) : nil
|
|
201
|
+
seconds && word?(tokens[i + 1]) ? [i + 1, seconds] : nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.duration_seconds(text)
|
|
205
|
+
match = DURATION.match(text)
|
|
206
|
+
match ? Float(match[1]) * UNIT_SECONDS.fetch(match[2]) : nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.word?(token) = token && token[0] == :word
|
|
210
|
+
|
|
211
|
+
# The longest `timeout N` (seconds) at the start of a simple command in
|
|
212
|
+
# +text+, or nil for none (or text that can't be keyed).
|
|
213
|
+
def self.timeout_seconds(text)
|
|
214
|
+
text = text.to_s.strip
|
|
215
|
+
return nil if text.empty? || LEX.unterminated?(text)
|
|
216
|
+
|
|
217
|
+
unwrap(LEX.lex(text)).last.max
|
|
218
|
+
end
|
|
219
|
+
|
|
99
220
|
# Whether the key dropped a trailing output filter (2>&1, | tail …):
|
|
100
221
|
# the exit code is then the filter's, and the run may have been cut short.
|
|
101
222
|
def self.filtered?(text)
|
|
@@ -148,18 +269,44 @@ class Plugin
|
|
|
148
269
|
key = key_for(call, ctx)
|
|
149
270
|
return unless key
|
|
150
271
|
return if @denied[key]
|
|
272
|
+
return if over_limit_timeout(call, verdict, ctx, key)
|
|
151
273
|
|
|
152
274
|
entry = live_entries(ctx).find { |e| e["project"] == key[0] && e["command"] == key[1] }
|
|
153
275
|
return unless entry
|
|
154
276
|
|
|
155
277
|
@denied[key] = true
|
|
156
278
|
if @mode == :notify
|
|
157
|
-
ctx.notify("execute #{short(
|
|
279
|
+
ctx.notify("execute #{short(call[:content])}: #{what(entry)} before", level: :warn)
|
|
158
280
|
return
|
|
159
281
|
end
|
|
160
282
|
|
|
161
283
|
verdict.deny!("slow command", source: SOURCE, advice: advice(entry, ctx, key))
|
|
162
|
-
ctx.notify("execute #{short(
|
|
284
|
+
ctx.notify("execute #{short(call[:content])}: #{what(entry)} before, denied", level: :warn)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# A `timeout N` over the limit while execute kills at it (on_timeout:
|
|
288
|
+
# kill): N never takes effect. Denied once a turn, like a slow entry.
|
|
289
|
+
# @return [Boolean] whether it was handled
|
|
290
|
+
def over_limit_timeout(call, verdict, ctx, key)
|
|
291
|
+
return false unless Samagotchi::Config.get("execute.on_timeout").to_s == "kill"
|
|
292
|
+
|
|
293
|
+
seconds = self.class.timeout_seconds(call[:content])
|
|
294
|
+
limit = Samagotchi::Tools::Execute.timeout_seconds
|
|
295
|
+
return false unless seconds && seconds > limit
|
|
296
|
+
|
|
297
|
+
@denied[key] = true
|
|
298
|
+
wait = [WAIT_DEFAULT, seconds.ceil].max
|
|
299
|
+
line = "execute #{short(call[:content])}: timeout #{duration(seconds.ceil)} is past the #{limit} s limit"
|
|
300
|
+
if @mode == :notify
|
|
301
|
+
ctx.notify(line, level: :warn)
|
|
302
|
+
else
|
|
303
|
+
verdict.deny!("timeout past the execute limit", source: SOURCE,
|
|
304
|
+
advice: "execute stops a command at #{limit} s, so its timeout " \
|
|
305
|
+
"(#{duration(seconds.ceil)}) never takes effect. Start it " \
|
|
306
|
+
"with task_create, then task_wait (timeout #{wait}).")
|
|
307
|
+
ctx.notify("#{line}, denied", level: :warn)
|
|
308
|
+
end
|
|
309
|
+
true
|
|
163
310
|
end
|
|
164
311
|
|
|
165
312
|
def after(event, ctx)
|
|
@@ -174,10 +321,11 @@ class Plugin
|
|
|
174
321
|
return unless key
|
|
175
322
|
|
|
176
323
|
limit = Samagotchi::Tools::Execute.timeout_seconds
|
|
177
|
-
|
|
178
|
-
|
|
324
|
+
shown = call[:content].to_s.strip
|
|
325
|
+
if (timed_out = result[TIMED_OUT, 1] || result[HANDED_OVER, 1])
|
|
326
|
+
remember(ctx, key, shown, limit_s: timed_out.to_i, elapsed_s: timed_out.to_i, timed_out: true)
|
|
179
327
|
elsif elapsed_ms >= @warn_ratio * limit * 1000
|
|
180
|
-
remember(ctx, key, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
|
|
328
|
+
remember(ctx, key, shown, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
|
|
181
329
|
elsif elapsed_ms < 0.5 * limit * 1000 && exit_zero?(result) && !self.class.filtered?(call[:content])
|
|
182
330
|
forget_key(ctx, key)
|
|
183
331
|
end
|
|
@@ -218,9 +366,10 @@ class Plugin
|
|
|
218
366
|
Samagotchi::AtomicFile.write(store_path(ctx), "#{JSON.pretty_generate(list.select { |e| live?(e) })}\n")
|
|
219
367
|
end
|
|
220
368
|
|
|
221
|
-
|
|
369
|
+
# +shown+: the command as this run typed it (the list shows it).
|
|
370
|
+
def remember(ctx, key, shown, limit_s:, elapsed_s:, timed_out:)
|
|
222
371
|
list = entries(ctx).reject { |e| e["project"] == key[0] && e["command"] == key[1] }
|
|
223
|
-
list << { "project" => key[0], "command" => key[1], "slow_at" => Time.now.utc.iso8601,
|
|
372
|
+
list << { "project" => key[0], "command" => key[1], "shown" => shown, "slow_at" => Time.now.utc.iso8601,
|
|
224
373
|
"limit_s" => limit_s, "elapsed_s" => elapsed_s, "timed_out" => timed_out }
|
|
225
374
|
save(ctx, list)
|
|
226
375
|
end
|
|
@@ -259,7 +408,8 @@ class Plugin
|
|
|
259
408
|
# [started_at, seconds] for a task record of +key+ that ended with exit 0.
|
|
260
409
|
def finished_task(path, ctx, key)
|
|
261
410
|
record = JSON.parse(File.read(path))
|
|
262
|
-
exit_code =
|
|
411
|
+
exit_code = record["exit_code_path"].to_s
|
|
412
|
+
exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME) unless File.absolute_path?(exit_code)
|
|
263
413
|
return nil unless File.file?(exit_code) && File.read(exit_code).strip == "0"
|
|
264
414
|
|
|
265
415
|
command = self.class.normalize(record["command"], cwd: record["cwd"], project: key[0], base: ctx.cwd)
|
|
@@ -282,7 +432,7 @@ class Plugin
|
|
|
282
432
|
return "slow-commands: none recorded for #{project}" if mine.empty?
|
|
283
433
|
|
|
284
434
|
lines = mine.each_with_index.map do |e, i|
|
|
285
|
-
"#{i + 1}. #{e
|
|
435
|
+
"#{i + 1}. #{shown(e)}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
|
|
286
436
|
end
|
|
287
437
|
"slow-commands in #{project}:\n#{lines.join("\n")}\n/slow-commands forget <n|all> drops an entry"
|
|
288
438
|
when /\Aforget\s+(all|\d+)\z/
|
|
@@ -291,7 +441,7 @@ class Plugin
|
|
|
291
441
|
return "slow-commands: no entry #{which} (/slow-commands lists them)" if drop.empty?
|
|
292
442
|
|
|
293
443
|
save(ctx, entries(ctx).reject { |e| drop.any? { |d| d["project"] == e["project"] && d["command"] == e["command"] } })
|
|
294
|
-
"slow-commands: forgot #{drop.size == 1 ? drop.first
|
|
444
|
+
"slow-commands: forgot #{drop.size == 1 ? shown(drop.first) : "#{drop.size} commands"}"
|
|
295
445
|
else
|
|
296
446
|
"usage: /slow-commands (list this project's slow commands), /slow-commands forget <n|all>"
|
|
297
447
|
end
|
|
@@ -299,6 +449,9 @@ class Plugin
|
|
|
299
449
|
|
|
300
450
|
# --- text ---
|
|
301
451
|
|
|
452
|
+
# The command as last typed; an entry from 0.1.0 has only its key.
|
|
453
|
+
def shown(entry) = entry["shown"].to_s.empty? ? entry["command"] : entry["shown"]
|
|
454
|
+
|
|
302
455
|
def what(entry)
|
|
303
456
|
entry["timed_out"] ? "ran past #{entry["limit_s"]} s" : "took #{duration(entry["elapsed_s"])} of #{entry["limit_s"]} s"
|
|
304
457
|
end
|
|
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
121
121
|
1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
|
|
122
122
|
2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
|
|
123
123
|
3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
|
|
124
|
-
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `
|
|
124
|
+
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
|
|
125
125
|
5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
|
|
126
126
|
6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
|
|
127
127
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.59.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:44a35f59baf44202972c9250989daa50eb978ab2a1967159e622c74ddd9c736c
|
|
10
10
|
memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -20,6 +20,8 @@ module Samagotchi
|
|
|
20
20
|
# One models: entry, and one field found in them; they need ConfigFile's readers.
|
|
21
21
|
autoload :ModelSettings, File.expand_path("model_settings", __dir__)
|
|
22
22
|
autoload :ModelSetting, File.expand_path("model_settings", __dir__)
|
|
23
|
+
# The per-model/host seconds a cut waits (like Thinking for the level).
|
|
24
|
+
autoload :SteerCutAfter, File.expand_path("steer_cut_after", __dir__)
|
|
23
25
|
|
|
24
26
|
# Unified configuration registry implementing the implicit convention:
|
|
25
27
|
# ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
|
|
@@ -216,7 +218,8 @@ module Samagotchi
|
|
|
216
218
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
217
219
|
# Seconds a generation must have streamed only thinking before a message
|
|
218
220
|
# for the running turn (the user's, chi send's, a parent agent's) cuts
|
|
219
|
-
# it (Engine#cut_for_steer); 0 = never.
|
|
221
|
+
# it (Engine#cut_for_steer); 0 = never. hosts.<name>.steer_cut_after and
|
|
222
|
+
# models.<key>.steer_cut_after win. No CLI flag: workers get no CLI args.
|
|
220
223
|
Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
|
|
221
224
|
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
222
225
|
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
@@ -242,8 +245,11 @@ module Samagotchi
|
|
|
242
245
|
Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
|
|
243
246
|
Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
244
247
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
245
|
-
# Seconds one execute command may run before
|
|
248
|
+
# Seconds one execute command may run before execute.on_timeout applies (Tools::Execute::TIMEOUT_SEC).
|
|
246
249
|
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
250
|
+
# What happens to a command still running at that limit (Tools::Execute::ON_TIMEOUT): task hands it over as a
|
|
251
|
+
# background task (task_wait waits for it), kill stops it.
|
|
252
|
+
Entry.new(key: "execute.on_timeout", yaml_path: %w[execute on_timeout], type: :enum, default: "task", expose: %i[env config cli], enum_values: %w[task kill]),
|
|
247
253
|
# Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
|
|
248
254
|
Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
|
|
249
255
|
|
|
@@ -263,7 +269,7 @@ module Samagotchi
|
|
|
263
269
|
# env+config only
|
|
264
270
|
Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
|
|
265
271
|
Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
|
|
266
|
-
Entry.new(key: "
|
|
272
|
+
Entry.new(key: "skip_agents_md", yaml_path: %w[skip_agents_md], type: :bool, default: false, expose: %i[env config])
|
|
267
273
|
].freeze
|
|
268
274
|
|
|
269
275
|
# Top-level maps read by their own code, whose entry names and contents
|
|
@@ -274,8 +280,8 @@ module Samagotchi
|
|
|
274
280
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
275
281
|
MAP_ENTRY_KEYS = {
|
|
276
282
|
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
|
|
277
|
-
reasoning_history enabled remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
278
|
-
"models" => %w[profile vision sampling thinking reasoning_history window_tokens llm_context_strategy llm_context_apply
|
|
283
|
+
reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
284
|
+
"models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
|
|
279
285
|
llm_context_budget_tokens].freeze
|
|
280
286
|
}.freeze
|
|
281
287
|
# The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -115,7 +115,8 @@ module Samagotchi
|
|
|
115
115
|
# may steer or ask whether a turn runs while it loads.
|
|
116
116
|
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
117
117
|
# A message for the running turn cutting its generation.
|
|
118
|
-
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller }
|
|
118
|
+
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
|
|
119
|
+
cut_after: -> { steer_cut_after })
|
|
119
120
|
@scratch = scratch
|
|
120
121
|
@no_interrupt = no_interrupt
|
|
121
122
|
@chat_backend = nil
|
|
@@ -266,8 +267,8 @@ module Samagotchi
|
|
|
266
267
|
def wire_kernel(kernel, muted_memories)
|
|
267
268
|
# A given kernel (specs) gets the Engine's store, hooks and tools, as
|
|
268
269
|
# the one built here does.
|
|
269
|
-
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile,
|
|
270
|
-
tools: @tools)
|
|
270
|
+
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: @effective_model_name,
|
|
271
|
+
hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
|
|
271
272
|
@kernel.reminder_store = @reminder_store
|
|
272
273
|
@kernel.hooks = @hooks
|
|
273
274
|
@kernel.tools = @tools
|
|
@@ -1094,6 +1095,29 @@ module Samagotchi
|
|
|
1094
1095
|
explained&.source ? explained.label : nil
|
|
1095
1096
|
end
|
|
1096
1097
|
|
|
1098
|
+
# The seconds a cut waits for the effective model to stream only
|
|
1099
|
+
# thinking (SteerCutAfter.resolve), resolved on every call, as
|
|
1100
|
+
# turn_sampling; on any error, the global and its name.
|
|
1101
|
+
# @return [Array(Integer, String|nil)]
|
|
1102
|
+
def steer_cut_after_explained
|
|
1103
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1104
|
+
SteerCutAfter.resolve(target, names: model_lookup_names(target))
|
|
1105
|
+
rescue StandardError
|
|
1106
|
+
[SteerCut.cut_after, "steer.cut_after"]
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
# #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
|
|
1110
|
+
# reads, per host and model.
|
|
1111
|
+
def steer_cut_after = steer_cut_after_explained.first
|
|
1112
|
+
|
|
1113
|
+
# "after 60 s (models: qwen)", "never (hosts.splash)" for /model: the
|
|
1114
|
+
# effective model's cut wait and where it came from, nil for the
|
|
1115
|
+
# default.
|
|
1116
|
+
def steer_cut_summary
|
|
1117
|
+
seconds, source = steer_cut_after_explained
|
|
1118
|
+
source ? SteerCutAfter.label(seconds, source) : nil
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1097
1121
|
# The effective model's thinking level with where it came from and the
|
|
1098
1122
|
# session's own (SettingExplained), as the next turn resolves it;
|
|
1099
1123
|
# nil when it can't be resolved.
|
|
@@ -1916,6 +1940,7 @@ module Samagotchi
|
|
|
1916
1940
|
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
1917
1941
|
turn_started[:continue] = true if turn.continue
|
|
1918
1942
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1943
|
+
turn_started[:cut_after] = steer_cut_after
|
|
1919
1944
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
1920
1945
|
raise image_error if image_error
|
|
1921
1946
|
|
|
@@ -19,7 +19,7 @@ module Samagotchi
|
|
|
19
19
|
# describes them, nil when unset; models: {downcased id => HostModel}.
|
|
20
20
|
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
|
|
21
21
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
22
|
-
:window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
22
|
+
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
23
23
|
:models) do
|
|
24
24
|
# Every field but name may be left out: nil, models {}.
|
|
25
25
|
def initialize(**fields)
|
|
@@ -50,6 +50,7 @@ module Samagotchi
|
|
|
50
50
|
vision: ConfigFile.vision_flag(value.call(:vision), where),
|
|
51
51
|
remote: ConfigFile.bool_flag(value.call(:remote), where, "remote"),
|
|
52
52
|
window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
|
|
53
|
+
steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
|
|
53
54
|
sampling: ConfigFile.sampling_map(value.call(:sampling), where),
|
|
54
55
|
thinking: Thinking.level(value.call(:thinking), where),
|
|
55
56
|
reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
|
|
@@ -170,6 +171,7 @@ module Samagotchi
|
|
|
170
171
|
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
171
172
|
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
172
173
|
"reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
|
|
174
|
+
"steer_cut_after" => steer_cut_after,
|
|
173
175
|
"llm_context_strategy" => llm_context_strategy&.map(&:to_s),
|
|
174
176
|
"llm_context_apply" => llm_context_apply&.to_s,
|
|
175
177
|
"llm_context_budget_tokens" => llm_context_budget_tokens,
|
|
@@ -38,13 +38,14 @@ module Samagotchi
|
|
|
38
38
|
# reasoning_history: the configured level (ReasoningHistory), nil when unset;
|
|
39
39
|
# remote: the configured true/false (#remote?), nil when unset;
|
|
40
40
|
# window_tokens: the configured context window (ContextWindow.setting), nil when unset;
|
|
41
|
+
# steer_cut_after: the seconds a cut waits for its thinking (SteerCutAfter), nil when unset;
|
|
41
42
|
# llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
|
|
42
43
|
# llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
|
|
43
44
|
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
44
45
|
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
45
46
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
46
47
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
47
|
-
:window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
48
|
+
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
48
49
|
:models, keyword_init: true) do
|
|
49
50
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
50
51
|
# raw-prompt loop.
|
|
@@ -142,13 +142,24 @@ module Samagotchi
|
|
|
142
142
|
end
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# The model the config names as the default, or nil when it names none.
|
|
146
|
+
# It never raises.
|
|
147
|
+
# @return [String, nil]
|
|
148
|
+
def self.default_model_name
|
|
149
|
+
require_relative "config"
|
|
150
|
+
value = Samagotchi::Config.get("default.model")
|
|
151
|
+
return nil unless value
|
|
152
|
+
|
|
153
|
+
stripped = value.to_s.strip
|
|
154
|
+
stripped.empty? ? nil : stripped
|
|
155
|
+
end
|
|
156
|
+
|
|
145
157
|
def self.required_model_name(model_name = nil)
|
|
146
158
|
value = model_name.to_s.strip
|
|
147
159
|
if value.empty?
|
|
148
|
-
|
|
149
|
-
value = Samagotchi::Config.get("default.model").to_s.strip
|
|
160
|
+
value = default_model_name
|
|
150
161
|
end
|
|
151
|
-
raise MissingModel, missing_model_message if value.empty?
|
|
162
|
+
raise MissingModel, missing_model_message if value.to_s.strip.empty?
|
|
152
163
|
|
|
153
164
|
value
|
|
154
165
|
end
|
|
@@ -10,10 +10,11 @@ module Samagotchi
|
|
|
10
10
|
# downcased key. Each field is nil when unset: profile kept as written
|
|
11
11
|
# (downcased; ModelProfile.resolve validates it), vision true/false,
|
|
12
12
|
# sampling a request-field map, thinking a level, reasoning_history a
|
|
13
|
-
# ReasoningHistory level, window_tokens,
|
|
13
|
+
# ReasoningHistory level, window_tokens, steer_cut_after whole seconds
|
|
14
|
+
# (0 = never, SteerCutAfter), and the
|
|
14
15
|
# LLM context strategy, apply rule and budget (LLMContextStrategy). The
|
|
15
16
|
# same per-model fields a hosts: entry carries (HostConfig).
|
|
16
|
-
ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens,
|
|
17
|
+
ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens, :steer_cut_after,
|
|
17
18
|
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
|
|
18
19
|
# Every field may be left out (nil).
|
|
19
20
|
def initialize(**fields)
|
|
@@ -37,6 +38,7 @@ module Samagotchi
|
|
|
37
38
|
thinking: Thinking.level(value.call(:thinking), where),
|
|
38
39
|
reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
|
|
39
40
|
window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
|
|
41
|
+
steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
|
|
40
42
|
llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
|
|
41
43
|
llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
|
|
42
44
|
llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where))
|
|
@@ -109,23 +109,96 @@ module Samagotchi
|
|
|
109
109
|
# TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
|
|
110
110
|
# waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
|
|
111
111
|
# done, when kill returns).
|
|
112
|
+
#
|
|
113
|
+
# The groups the group's processes started are stopped with it: a
|
|
114
|
+
# descendant that moved to a group of its own (GNU `timeout N cmd`
|
|
115
|
+
# without --foreground calls setpgid) is out of reach of a signal to
|
|
116
|
+
# +pgid+. They are found (#descendant_groups) before the TERM, while the
|
|
117
|
+
# group's processes still run, and again before the KILL; each is
|
|
118
|
+
# signalled too and must be gone as well. One that already lost its
|
|
119
|
+
# parent (reparented to init: a setsid'd `cmd &` whose shell exited) is
|
|
120
|
+
# not found, and so left running.
|
|
112
121
|
# @param stopped [#call] true once it has stopped (default: the leader
|
|
113
122
|
# is gone)
|
|
114
123
|
# @param wait [#call] (seconds) between two checks (default: a sleep)
|
|
124
|
+
# @param walk [Boolean] also stop the descendants' groups (one ps each
|
|
125
|
+
# time, ~40 ms on macOS with ~850 processes)
|
|
115
126
|
# @param signal_options [Hash] for #signal (leader:, quiet:)
|
|
116
127
|
# @return [Boolean] true when it stopped within the grace (no KILL sent)
|
|
117
128
|
# @raise [ArgumentError] unless #signalable?
|
|
118
129
|
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
119
|
-
kill_wait: KILL_WAIT_SEC, **signal_options)
|
|
130
|
+
kill_wait: KILL_WAIT_SEC, walk: true, **signal_options)
|
|
120
131
|
check!(pgid)
|
|
132
|
+
# The group's members only: a leader reaped by now may be another process.
|
|
133
|
+
extra = walk ? descendant_groups(pgid) : []
|
|
121
134
|
signal(pgid, "TERM", **signal_options)
|
|
122
|
-
|
|
135
|
+
extra.each { |group| signal(group, "TERM") }
|
|
136
|
+
# +stopped+ is asked until it says true once (it may reap: a second
|
|
137
|
+
# wait on a reaped pid raises ECHILD), then only the extra groups.
|
|
138
|
+
done = false
|
|
139
|
+
all_stopped = -> { (done ||= stopped.call ? true : false) && extra.none? { |group| group_alive?(group) } }
|
|
140
|
+
return true if wait_for(all_stopped, grace, poll, wait)
|
|
123
141
|
|
|
142
|
+
extra |= descendant_groups(pgid) if walk
|
|
124
143
|
signal(pgid, "KILL", **signal_options)
|
|
125
|
-
|
|
144
|
+
extra.each { |group| signal(group, "KILL") }
|
|
145
|
+
wait_for(all_stopped, kill_wait, poll, wait)
|
|
126
146
|
false
|
|
127
147
|
end
|
|
128
148
|
|
|
149
|
+
# Whether any process is still in the group +pgid+.
|
|
150
|
+
def group_alive?(pgid)
|
|
151
|
+
check!(pgid)
|
|
152
|
+
Process.kill(0, -pgid)
|
|
153
|
+
true
|
|
154
|
+
rescue Errno::ESRCH
|
|
155
|
+
false
|
|
156
|
+
rescue Errno::EPERM
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The process groups, other than +pgid+ and chi's own, of the
|
|
161
|
+
# descendants of the processes in the group +pgid+ (and of the process
|
|
162
|
+
# +pgid+ itself with +leader+: a leader chi started that left its
|
|
163
|
+
# group). Read from one `ps -A -o pid=,ppid=,pgid=`; [] when ps fails.
|
|
164
|
+
# Only members of the group are roots, never the bare pid +pgid+ without
|
|
165
|
+
# +leader+: a reaped leader's pid may name some other process by now.
|
|
166
|
+
# @return [Array<Integer>]
|
|
167
|
+
def descendant_groups(pgid, leader: false)
|
|
168
|
+
rows = process_table
|
|
169
|
+
children = rows.group_by { |_pid, ppid, _group| ppid }
|
|
170
|
+
queue = rows.select { |_pid, _ppid, group| group == pgid }.map(&:first)
|
|
171
|
+
queue << pgid if leader
|
|
172
|
+
seen = {}
|
|
173
|
+
groups = []
|
|
174
|
+
until queue.empty?
|
|
175
|
+
pid = queue.shift
|
|
176
|
+
next if seen[pid]
|
|
177
|
+
|
|
178
|
+
seen[pid] = true
|
|
179
|
+
Array(children[pid]).each do |child, _ppid, group|
|
|
180
|
+
groups << group unless group == pgid
|
|
181
|
+
queue << child
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
own = Process.getpgrp
|
|
185
|
+
groups.uniq.select { |group| signalable?(group) && group != own }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Every process as [pid, ppid, pgid].
|
|
189
|
+
# @return [Array<Array(Integer, Integer, Integer)>]
|
|
190
|
+
def process_table
|
|
191
|
+
out, status = Open3.capture2("ps", "-A", "-o", "pid=,ppid=,pgid=", err: File::NULL)
|
|
192
|
+
return [] unless status.success?
|
|
193
|
+
|
|
194
|
+
out.lines.filter_map do |line|
|
|
195
|
+
fields = line.split.map { |field| Integer(field, exception: false) }
|
|
196
|
+
fields if fields.size == 3 && fields.all?
|
|
197
|
+
end
|
|
198
|
+
rescue SystemCallError, IOError
|
|
199
|
+
[]
|
|
200
|
+
end
|
|
201
|
+
|
|
129
202
|
# Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
|
|
130
203
|
# @return [Boolean] whether it stopped
|
|
131
204
|
def wait_for(stopped, seconds, poll, wait)
|