samagotchi 0.58.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 +38 -1
- data/docs/architecture.md +3 -3
- data/docs/cli.md +5 -4
- data/docs/configuration.md +35 -20
- 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 +1 -1
- 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 +26 -1
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_commands.rb +15 -3
- 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 +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
- data/lib/samagotchi/terminal_ui.rb +1 -1
- 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/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- metadata +2 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../paths"
|
|
4
6
|
require_relative "../process_group"
|
|
5
7
|
require_relative "output_guardrails"
|
|
6
8
|
require_relative "shell"
|
|
9
|
+
require_relative "task_runtime"
|
|
7
10
|
|
|
8
11
|
module Samagotchi
|
|
9
12
|
module Tools
|
|
@@ -24,8 +27,11 @@ module Samagotchi
|
|
|
24
27
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
25
28
|
# The first words of a command the user's Stop killed.
|
|
26
29
|
STOPPED_BY_USER = "Error: command stopped by the user"
|
|
27
|
-
# The first words of a command killed at the limit
|
|
30
|
+
# The first words of a command killed at the limit in kill mode (hooks
|
|
31
|
+
# match on it).
|
|
28
32
|
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
33
|
+
# Only in kill mode (execute.on_timeout: kill, the !cmd bang): in task
|
|
34
|
+
# mode a command at the limit is handed over (HANDED_OVER).
|
|
29
35
|
TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
|
|
30
36
|
# How long the output may stay open once the shell has exited: a
|
|
31
37
|
# `server &` child keeps the pipes, and the read would never end.
|
|
@@ -35,23 +41,52 @@ module Samagotchi
|
|
|
35
41
|
# It had left the process group (setsid), so it is still running.
|
|
36
42
|
BACKGROUND_DETACHED_HINT = "(a background process kept the output open and was left running; " \
|
|
37
43
|
"to keep a server or long job running, start it with task_create)"
|
|
38
|
-
|
|
44
|
+
# What a command still running at the limit gets (execute.on_timeout):
|
|
45
|
+
# :task hands it over as a task (HANDED_OVER), :kill stops it.
|
|
46
|
+
ON_TIMEOUT = %i[task kill].freeze
|
|
47
|
+
# How many of the last lines of each log a hand-over shows.
|
|
48
|
+
HAND_OVER_TAIL_LINES = 10
|
|
49
|
+
# Where each command's logs and exit_code file live while it runs, and
|
|
50
|
+
# after, for a handed-over one: <state dir>/execute/<id>/ (outside the
|
|
51
|
+
# workspace, so git and `rm -rf tmp` never see them).
|
|
52
|
+
LOGS_DIRNAME = "execute"
|
|
53
|
+
STDOUT_LOG = "stdout.log"
|
|
54
|
+
STDERR_LOG = "stderr.log"
|
|
55
|
+
# A handed-over command's dir names its task.json here (the sweep).
|
|
56
|
+
BACK_POINTER = "task"
|
|
57
|
+
# The sweep (sweep_leftovers): a dir no command holds any more, with
|
|
58
|
+
# no back-pointer after a day (chi died mid-execute), or whose task
|
|
59
|
+
# record is gone or is older than RETENTION_DAYS.
|
|
60
|
+
ORPHAN_AGE_SEC = 24 * 3600
|
|
61
|
+
RETENTION_DAYS = 14
|
|
62
|
+
# The first words of a command still running at the limit and handed
|
|
63
|
+
# over as a task (handed_over_line). TaskRuntime.handed_over_id, the
|
|
64
|
+
# web's task_stop.js and the slow-commands bundle match on them.
|
|
65
|
+
HANDED_OVER = "Still running at the"
|
|
39
66
|
|
|
40
67
|
def self.name = NAME
|
|
41
68
|
|
|
69
|
+
# A hand-over result's first line; its "task_id: …" line follows it.
|
|
70
|
+
def self.handed_over_line(limit_sec)
|
|
71
|
+
"#{HANDED_OVER} #{Integer(limit_sec)}s limit: moved to a background task, not killed."
|
|
72
|
+
end
|
|
73
|
+
|
|
42
74
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
43
75
|
# command is killed (or not started) and its output so far returned
|
|
44
76
|
# @param env [Hash, nil] added to the command's environment
|
|
45
|
-
|
|
77
|
+
# @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
|
|
78
|
+
# nil reads execute.on_timeout
|
|
79
|
+
def self.call(command, cwd: nil, cancelled: -> { false }, env: nil, on_timeout: nil)
|
|
46
80
|
return NOT_RUN_ON_STOP if cancelled.call
|
|
47
81
|
|
|
48
82
|
command = command.strip
|
|
49
83
|
resolved_cwd = resolve_cwd(cwd)
|
|
50
84
|
return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
|
|
51
85
|
|
|
86
|
+
sweep_leftovers_once
|
|
52
87
|
timeout_sec = timeout_seconds
|
|
53
88
|
stdout, stderr, status, held_open = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
|
|
54
|
-
|
|
89
|
+
env: env, on_timeout: on_timeout_mode(on_timeout))
|
|
55
90
|
|
|
56
91
|
stdout_block = output_block("stdout", stdout)
|
|
57
92
|
stderr_block = output_block("stderr", stderr)
|
|
@@ -67,6 +102,8 @@ module Samagotchi
|
|
|
67
102
|
parts << (silent ? "exit: #{status.exitstatus} (no output)" : "exit: #{status.exitstatus}")
|
|
68
103
|
parts << (held_open == :detached ? BACKGROUND_DETACHED_HINT : BACKGROUND_HINT) if held_open
|
|
69
104
|
parts.join("\n")
|
|
105
|
+
rescue HandedOver => e
|
|
106
|
+
e.text
|
|
70
107
|
rescue CommandTimedOut => e
|
|
71
108
|
# Keep the "Error:" first line (callers classify on it), say how to run
|
|
72
109
|
# a long command, and return whatever it printed before it was killed.
|
|
@@ -77,6 +114,14 @@ module Samagotchi
|
|
|
77
114
|
"Error: #{e.message}"
|
|
78
115
|
end
|
|
79
116
|
|
|
117
|
+
# execute.on_timeout, or the caller's: :task (the default) or :kill.
|
|
118
|
+
def self.on_timeout_mode(value)
|
|
119
|
+
value = Samagotchi::Config.get("execute.on_timeout") if value.nil? || value.to_s.strip.empty?
|
|
120
|
+
mode = value.to_s.strip.downcase.to_sym
|
|
121
|
+
ON_TIMEOUT.include?(mode) ? mode : :task
|
|
122
|
+
end
|
|
123
|
+
private_class_method :on_timeout_mode
|
|
124
|
+
|
|
80
125
|
def self.stopped_result(first_line, error)
|
|
81
126
|
parts = [first_line]
|
|
82
127
|
parts << output_block("stdout", error.stdout)
|
|
@@ -85,58 +130,91 @@ module Samagotchi
|
|
|
85
130
|
end
|
|
86
131
|
private_class_method :stopped_result
|
|
87
132
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
133
|
+
# The command's stdout and stderr go to two logs in its dir under the
|
|
134
|
+
# state dir (logs_root), not to pipes: a command handed over at the
|
|
135
|
+
# limit keeps writing there after execute (or chi) is gone. chi holds
|
|
136
|
+
# each log with a shared flock across the spawn, so the command's
|
|
137
|
+
# processes inherit open files a fresh exclusive flock can't get while
|
|
138
|
+
# any of them still has one (held?). stdin is /dev/null: a background
|
|
139
|
+
# group reading the terminal would stop on SIGTTIN. The shell is
|
|
140
|
+
# detached at once; the waiter it returns reaps it, also after a
|
|
141
|
+
# hand-over, so it never stays a zombie.
|
|
142
|
+
def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false }, env: nil, on_timeout: :task)
|
|
91
143
|
status = nil
|
|
92
144
|
timed_out = false
|
|
93
145
|
was_cancelled = false
|
|
94
146
|
aborted = false
|
|
147
|
+
handed_over = false
|
|
95
148
|
held_open = false
|
|
149
|
+
started_at = Time.now
|
|
96
150
|
started = monotonic_time
|
|
97
151
|
deadline = started + timeout_sec
|
|
98
152
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
153
|
+
id = TaskRuntime.generate_task_id
|
|
154
|
+
dir = File.join(logs_root, id)
|
|
155
|
+
FileUtils.mkdir_p(dir)
|
|
156
|
+
logs = [File.join(dir, STDOUT_LOG), File.join(dir, STDERR_LOG)]
|
|
157
|
+
exit_code_path = File.join(dir, TaskRuntime::EXIT_CODE_FILENAME)
|
|
158
|
+
begin
|
|
159
|
+
pid = spawn_logged(command, env: env, cwd: cwd, logs: logs, exit_code_path: exit_code_path)
|
|
160
|
+
rescue StandardError
|
|
161
|
+
FileUtils.rm_rf(dir)
|
|
162
|
+
raise
|
|
163
|
+
end
|
|
164
|
+
wait_thr = Process.detach(pid)
|
|
165
|
+
# A stop is done once the shell is reaped and nothing holds the logs:
|
|
166
|
+
# a child that ignores TERM keeps them, so the stop goes on to KILL.
|
|
167
|
+
stopped = -> { !wait_thr.alive? && !held?(logs) }
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
until wait_thr.join(WAIT_SLICE_SEC)
|
|
171
|
+
if cancelled.call
|
|
172
|
+
was_cancelled = true
|
|
173
|
+
break
|
|
174
|
+
elsif monotonic_time > deadline
|
|
175
|
+
timed_out = true
|
|
176
|
+
break
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
# Ended right at the deadline: finished, not timed out.
|
|
180
|
+
timed_out = false if timed_out && !wait_thr.alive?
|
|
181
|
+
if timed_out && on_timeout == :task
|
|
182
|
+
text = hand_over(id: id, command: command, cwd: cwd, pid: pid, waiter: wait_thr, started_at: started_at,
|
|
183
|
+
timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
|
|
184
|
+
if text
|
|
185
|
+
handed_over = true
|
|
186
|
+
raise HandedOver, text
|
|
112
187
|
end
|
|
113
|
-
|
|
114
|
-
wait_thr.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
unless
|
|
188
|
+
# Not adopted because it ended meanwhile: finished too.
|
|
189
|
+
timed_out = false unless wait_thr.alive?
|
|
190
|
+
end
|
|
191
|
+
terminate_process_tree(pid, stopped: stopped) if timed_out || was_cancelled
|
|
192
|
+
wait_thr.join
|
|
193
|
+
status = wait_thr.value
|
|
194
|
+
rescue HandedOver
|
|
195
|
+
raise
|
|
196
|
+
rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
197
|
+
# An Interrupt (or anything else) mid-wait: the child is in its own
|
|
198
|
+
# process group, so it never saw the SIGINT. Kill it.
|
|
199
|
+
aborted = true
|
|
200
|
+
terminate_process_tree(pid, stopped: stopped) if wait_thr.alive?
|
|
201
|
+
raise
|
|
202
|
+
ensure
|
|
203
|
+
unless handed_over
|
|
204
|
+
# The shell is gone; a child it put in the background may still
|
|
205
|
+
# hold the logs. Already stopped (timeout, Stop, an exception): no
|
|
206
|
+
# more checks.
|
|
207
|
+
unless timed_out || was_cancelled || aborted
|
|
208
|
+
outcome, held_open = background_check(pid, logs, deadline: deadline, cancelled: cancelled)
|
|
133
209
|
was_cancelled = true if outcome == :cancelled
|
|
210
|
+
# Under on_timeout: task too: the shell is reaped, there is no
|
|
211
|
+
# leader left to hand over.
|
|
134
212
|
timed_out = true if outcome == :timeout
|
|
135
|
-
stopped = stop_process_group(wait_thr.pid, readers)
|
|
136
|
-
held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
|
|
137
213
|
end
|
|
138
|
-
|
|
139
|
-
|
|
214
|
+
stdout_text, stderr_text = logs.map { |path| read_log(path) }
|
|
215
|
+
# A holder left running (setsid) still writes there: the dir
|
|
216
|
+
# stays, and the sweep removes it once no one holds the logs.
|
|
217
|
+
FileUtils.rm_rf(dir) unless held_open == :detached
|
|
140
218
|
end
|
|
141
219
|
end
|
|
142
220
|
|
|
@@ -147,38 +225,171 @@ module Samagotchi
|
|
|
147
225
|
end
|
|
148
226
|
private_class_method :run_command
|
|
149
227
|
|
|
150
|
-
#
|
|
228
|
+
# @return [Integer] the pid of the shell, which leads its own group
|
|
229
|
+
def self.spawn_logged(command, env:, cwd:, logs:, exit_code_path:)
|
|
230
|
+
# Append mode: the shell and its background children share the open
|
|
231
|
+
# file, and each write lands at the end.
|
|
232
|
+
ios = logs.map { |path| File.open(path, "a") }
|
|
233
|
+
ios.each { |io| io.flock(File::LOCK_SH) }
|
|
234
|
+
ProcessGroup.spawn(env || {}, *Shell.argv(TaskRuntime.wrapped_shell_command(command, exit_code_path)),
|
|
235
|
+
chdir: cwd, in: File::NULL, out: ios[0], err: ios[1])
|
|
236
|
+
ensure
|
|
237
|
+
ios&.each(&:close)
|
|
238
|
+
end
|
|
239
|
+
private_class_method :spawn_logged
|
|
240
|
+
|
|
241
|
+
# After the shell exited: a background child still holding the logs
|
|
242
|
+
# (`cmd &`) gets BACKGROUND_GRACE_SEC, then its group is stopped
|
|
243
|
+
# (BACKGROUND_HINT), unless it left the group (setsid) and holds them
|
|
244
|
+
# still (BACKGROUND_DETACHED_HINT, left running). One that holds none
|
|
245
|
+
# (`nohup cmd >f 2>&1 &`) is left alone, with no note.
|
|
246
|
+
# @return [Array(Symbol, Symbol|false)] the outcome (:done, :grace,
|
|
247
|
+
# :timeout, :cancelled) and the hint (:stopped, :detached or false)
|
|
248
|
+
def self.background_check(pgid, logs, deadline:, cancelled:)
|
|
249
|
+
return [:done, false] unless held?(logs)
|
|
250
|
+
|
|
251
|
+
outcome = await_release(logs, until_time: monotonic_time + BACKGROUND_GRACE_SEC, deadline: deadline, cancelled: cancelled)
|
|
252
|
+
return [outcome, false] if outcome == :done
|
|
253
|
+
|
|
254
|
+
# Only the group: the leader's pid is reaped, so it may name another process.
|
|
255
|
+
ProcessGroup.stop(pgid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, stopped: -> { !held?(logs) })
|
|
256
|
+
return [outcome, false] unless outcome == :grace
|
|
257
|
+
|
|
258
|
+
[outcome, held?(logs) ? :detached : :stopped]
|
|
259
|
+
end
|
|
260
|
+
private_class_method :background_check
|
|
261
|
+
|
|
262
|
+
# Whether some process still has one of +paths+ open from the spawn:
|
|
263
|
+
# a flock belongs to the open file description, so an exclusive try
|
|
264
|
+
# fails while any inherited copy is open (~0.2 ms; no lsof, no /proc).
|
|
265
|
+
def self.held?(paths)
|
|
266
|
+
paths.any? do |path|
|
|
267
|
+
File.open(path, "r") do |file|
|
|
268
|
+
got = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
269
|
+
file.flock(File::LOCK_UN) if got
|
|
270
|
+
!got
|
|
271
|
+
end
|
|
272
|
+
rescue Errno::ENOENT
|
|
273
|
+
false
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Polls the logs until no one holds them (+until_time+), the overall
|
|
151
278
|
# +deadline+ or a Stop. Returns :done, :grace, :timeout or :cancelled.
|
|
152
|
-
def self.
|
|
279
|
+
def self.await_release(paths, until_time:, deadline:, cancelled:)
|
|
153
280
|
loop do
|
|
154
|
-
return :done
|
|
281
|
+
return :done unless held?(paths)
|
|
155
282
|
return :cancelled if cancelled.call
|
|
156
283
|
return :timeout if deadline && monotonic_time > deadline
|
|
157
284
|
return :grace if monotonic_time > until_time
|
|
158
285
|
|
|
159
|
-
|
|
286
|
+
sleep(STOP_POLL_INTERVAL_SEC)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
private_class_method :await_release
|
|
290
|
+
|
|
291
|
+
# The whole log when it is under execute.truncate_at_bytes, else its
|
|
292
|
+
# head and tail only, read by seek (never the whole 100 MB).
|
|
293
|
+
# @return [String, LogPreview]
|
|
294
|
+
def self.read_log(path)
|
|
295
|
+
return "" unless File.file?(path)
|
|
296
|
+
|
|
297
|
+
size = File.size(path)
|
|
298
|
+
return File.read(path) if size <= truncate_at_bytes
|
|
299
|
+
|
|
300
|
+
LogPreview.new(size, OutputGuardrails.head_tail_from_file(path: path, file_size: size, preview_bytes: preview_bytes))
|
|
301
|
+
end
|
|
302
|
+
private_class_method :read_log
|
|
303
|
+
|
|
304
|
+
# A command still running at the limit becomes a task (TaskRuntime.
|
|
305
|
+
# adopt) with the logs where they are; a back-pointer in its dir names
|
|
306
|
+
# the record for the sweep. nil when it can't be adopted (the caller
|
|
307
|
+
# then kills it as in :kill mode) or when it ended meanwhile (the
|
|
308
|
+
# caller then reads it as finished).
|
|
309
|
+
# @return [String, nil] the HANDED_OVER result
|
|
310
|
+
def self.hand_over(id:, command:, cwd:, pid:, waiter:, started_at:, timeout_sec:, dir:, logs:, exit_code_path:)
|
|
311
|
+
return nil unless waiter.alive?
|
|
312
|
+
|
|
313
|
+
record, error = TaskRuntime.adopt(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at,
|
|
314
|
+
output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
|
|
315
|
+
return nil if error
|
|
316
|
+
|
|
317
|
+
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record["id"])))
|
|
318
|
+
write_missing_exit_code(waiter, exit_code_path)
|
|
319
|
+
# Ended between the deadline and the adopt: the record says how.
|
|
320
|
+
TaskRuntime.get_record(id) unless waiter.alive?
|
|
321
|
+
lines = [handed_over_line(timeout_sec), "task_id: #{id}",
|
|
322
|
+
"Wait for it with task_wait (it may take minutes); task_stop stops it."]
|
|
323
|
+
{ "stdout" => logs[0], "stderr" => logs[1] }.each do |label, path|
|
|
324
|
+
tail = TaskRuntime.output_tail_lines(path, HAND_OVER_TAIL_LINES)
|
|
325
|
+
lines << "#{label} (so far, tail):\n#{tail.chomp}" unless tail.empty?
|
|
326
|
+
end
|
|
327
|
+
lines.join("\n")
|
|
328
|
+
end
|
|
329
|
+
private_class_method :hand_over
|
|
330
|
+
|
|
331
|
+
# A command that set its own EXIT trap replaced the wrapper's, so it
|
|
332
|
+
# writes no exit_code: while this chi lives, the waiter's status fills
|
|
333
|
+
# it in (a refresh in between may still read "no exit code").
|
|
334
|
+
def self.write_missing_exit_code(waiter, path)
|
|
335
|
+
Thread.new do
|
|
336
|
+
Thread.current.report_on_exception = false
|
|
337
|
+
code = waiter.value&.exitstatus
|
|
338
|
+
File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
private_class_method :write_missing_exit_code
|
|
342
|
+
|
|
343
|
+
# <state dir>/execute: every command's dir (LOGS_DIRNAME).
|
|
344
|
+
def self.logs_root = File.join(Paths.state_dir, LOGS_DIRNAME)
|
|
345
|
+
|
|
346
|
+
# Once per process, at the first command (sweep_leftovers).
|
|
347
|
+
def self.sweep_leftovers_once
|
|
348
|
+
return if @swept
|
|
349
|
+
|
|
350
|
+
@swept = true
|
|
351
|
+
sweep_leftovers
|
|
352
|
+
rescue StandardError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
private_class_method :sweep_leftovers_once
|
|
356
|
+
|
|
357
|
+
# Removes the dirs under logs_root no process holds any more: one
|
|
358
|
+
# without a back-pointer, older than ORPHAN_AGE_SEC (a chi that died
|
|
359
|
+
# mid-execute); one whose back-pointer's task.json is gone, or whose
|
|
360
|
+
# logs are older than RETENTION_DAYS (a handed-over task long done).
|
|
361
|
+
# A dir whose logs are still held is a command still running: kept.
|
|
362
|
+
# The records live in each project's tmp/tasks, which the state dir
|
|
363
|
+
# can't list: hence the back-pointer.
|
|
364
|
+
# @return [Array<String>] the dirs removed
|
|
365
|
+
def self.sweep_leftovers(now: Time.now)
|
|
366
|
+
Dir.glob(File.join(logs_root, "*")).select { |dir| File.directory?(dir) }.filter_map do |dir|
|
|
367
|
+
next unless leftover?(dir, now)
|
|
368
|
+
|
|
369
|
+
FileUtils.rm_rf(dir)
|
|
370
|
+
dir
|
|
160
371
|
end
|
|
161
372
|
end
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
|
|
373
|
+
|
|
374
|
+
def self.leftover?(dir, now)
|
|
375
|
+
logs = [File.join(dir, STDOUT_LOG), File.join(dir, STDERR_LOG)]
|
|
376
|
+
return false if held?(logs)
|
|
377
|
+
|
|
378
|
+
age = now - ([dir, *logs].filter_map { |path| File.mtime(path) if File.exist?(path) }.max || now)
|
|
379
|
+
pointer = File.join(dir, BACK_POINTER)
|
|
380
|
+
return age > ORPHAN_AGE_SEC unless File.file?(pointer)
|
|
381
|
+
|
|
382
|
+
!File.file?(File.read(pointer).strip) || age > RETENTION_DAYS * 24 * 3600
|
|
383
|
+
rescue SystemCallError
|
|
384
|
+
false
|
|
175
385
|
end
|
|
176
|
-
private_class_method :
|
|
386
|
+
private_class_method :leftover?
|
|
177
387
|
|
|
178
388
|
# The shell still runs: TERM its group (or the shell alone, if it left
|
|
179
|
-
# the group), then KILL it once the grace is over
|
|
180
|
-
|
|
181
|
-
|
|
389
|
+
# the group), then KILL it once the grace is over, unless +stopped+
|
|
390
|
+
# (the shell reaped, the logs released) came first.
|
|
391
|
+
def self.terminate_process_tree(pid, stopped:)
|
|
392
|
+
ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, leader: true, stopped: stopped)
|
|
182
393
|
end
|
|
183
394
|
private_class_method :terminate_process_tree
|
|
184
395
|
|
|
@@ -203,15 +414,29 @@ module Samagotchi
|
|
|
203
414
|
end
|
|
204
415
|
private_class_method :resolve_cwd
|
|
205
416
|
|
|
417
|
+
def self.truncate_at_bytes
|
|
418
|
+
OutputGuardrails.config_positive_int("execute.truncate_at_bytes", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
|
|
419
|
+
end
|
|
420
|
+
private_class_method :truncate_at_bytes
|
|
421
|
+
|
|
422
|
+
def self.preview_bytes
|
|
423
|
+
OutputGuardrails.config_positive_int("execute.preview_bytes", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
|
|
424
|
+
end
|
|
425
|
+
private_class_method :preview_bytes
|
|
426
|
+
|
|
427
|
+
# @param content [String, LogPreview] a log read whole, or its head
|
|
428
|
+
# and tail (read_log)
|
|
206
429
|
def self.output_block(label, content)
|
|
207
430
|
return nil if content.nil? || content.empty?
|
|
208
431
|
|
|
209
|
-
truncate_at_bytes = OutputGuardrails.config_positive_int("execute.truncate_at_bytes", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
|
|
210
|
-
preview_bytes = OutputGuardrails.config_positive_int("execute.preview_bytes", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
|
|
211
432
|
bytes = content.bytesize
|
|
212
|
-
return "#{label}:\n#{content}" if bytes <= truncate_at_bytes
|
|
433
|
+
return "#{label}:\n#{content}" if bytes <= truncate_at_bytes && !content.is_a?(LogPreview)
|
|
213
434
|
|
|
214
|
-
preview =
|
|
435
|
+
preview = if content.is_a?(LogPreview)
|
|
436
|
+
content.preview
|
|
437
|
+
else
|
|
438
|
+
OutputGuardrails.head_tail_from_string(content: content, preview_bytes: preview_bytes)
|
|
439
|
+
end
|
|
215
440
|
|
|
216
441
|
[
|
|
217
442
|
"#{label}:",
|
|
@@ -238,6 +463,29 @@ module Samagotchi
|
|
|
238
463
|
)
|
|
239
464
|
end
|
|
240
465
|
|
|
466
|
+
# A big log: its size, and the head and tail read by seek, which
|
|
467
|
+
# output_block shows as it shows a long string's.
|
|
468
|
+
class LogPreview
|
|
469
|
+
attr_reader :bytesize, :preview
|
|
470
|
+
|
|
471
|
+
def initialize(bytesize, preview)
|
|
472
|
+
@bytesize = bytesize
|
|
473
|
+
@preview = preview
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def empty? = @bytesize.zero?
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Raised out of the wait with the hand-over's text: no read, no rm_rf.
|
|
480
|
+
class HandedOver < StandardError
|
|
481
|
+
attr_reader :text
|
|
482
|
+
|
|
483
|
+
def initialize(text)
|
|
484
|
+
@text = text
|
|
485
|
+
super("handed over")
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
241
489
|
class CommandTimedOut < StandardError
|
|
242
490
|
attr_reader :stdout, :stderr
|
|
243
491
|
|
|
@@ -31,6 +31,8 @@ module Samagotchi
|
|
|
31
31
|
"exit_code: #{record["exit_code"]}",
|
|
32
32
|
"stop_reason: #{record["stop_reason"]}",
|
|
33
33
|
"output_path: #{record.fetch("output_path")}",
|
|
34
|
+
# A handed-over execute's stderr has a log of its own.
|
|
35
|
+
("stderr_path: #{record["stderr_path"]}" if record["stderr_path"]),
|
|
34
36
|
(TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
|
|
35
37
|
].compact.join("\n")
|
|
36
38
|
end
|
|
@@ -11,6 +11,10 @@ require_relative "shell"
|
|
|
11
11
|
|
|
12
12
|
module Samagotchi
|
|
13
13
|
module Tools
|
|
14
|
+
# Execute requires nothing from here at load, and P1b's Execute will
|
|
15
|
+
# require this file: autoload keeps the two from requiring each other.
|
|
16
|
+
autoload :Execute, File.expand_path("execute", __dir__)
|
|
17
|
+
|
|
14
18
|
module TaskRuntime
|
|
15
19
|
TASKS_DIR = File.join("tmp", "tasks")
|
|
16
20
|
METADATA_FILENAME = "task.json"
|
|
@@ -97,6 +101,48 @@ module Samagotchi
|
|
|
97
101
|
output_io&.close
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
# A record for a command chi already runs: an execute still running at
|
|
105
|
+
# its limit, handed over as a task (Execute::HANDED_OVER). Its logs and
|
|
106
|
+
# exit_code file stay where execute put them, so the paths must be
|
|
107
|
+
# absolute; +stderr_path+ is a log of its own (task_wait tails it).
|
|
108
|
+
# The pid's start time is read here, at the hand-over, not at the
|
|
109
|
+
# spawn (a quick execute is spared the ps): read while execute's
|
|
110
|
+
# detach waiter is still running, so the pid is still its child.
|
|
111
|
+
# @param started_at [Time] when execute spawned the command
|
|
112
|
+
# @return [Array(Hash, nil), Array(nil, String)] the record, or an error
|
|
113
|
+
def adopt(id:, command:, cwd:, pid:, started_at:, output_path:, stderr_path:, exit_code_path:)
|
|
114
|
+
return [nil, "Error: invalid task id: #{id}"] unless TASK_ID_SHAPE.match?(id.to_s)
|
|
115
|
+
return [nil, "Error: task already exists: #{id}"] if File.exist?(metadata_path(id))
|
|
116
|
+
|
|
117
|
+
paths = { "output_path" => output_path, "stderr_path" => stderr_path, "exit_code_path" => exit_code_path }
|
|
118
|
+
relative = paths.find { |_, path| !File.absolute_path?(path.to_s) }
|
|
119
|
+
return [nil, "Error: #{relative[0]} must be absolute: #{relative[1]}"] if relative
|
|
120
|
+
return [nil, "Error: not a process chi can stop: #{pid.inspect}"] unless ProcessGroup.signalable?(pid)
|
|
121
|
+
|
|
122
|
+
started = started_at.utc.iso8601
|
|
123
|
+
record = {
|
|
124
|
+
"metadata_version" => METADATA_VERSION,
|
|
125
|
+
"id" => id,
|
|
126
|
+
"command" => command.to_s.strip,
|
|
127
|
+
"cwd" => File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd),
|
|
128
|
+
"workspace_root" => Dir.pwd,
|
|
129
|
+
"status" => "running",
|
|
130
|
+
"pid" => pid,
|
|
131
|
+
"pid_started" => ProcessGroup.start_time(pid),
|
|
132
|
+
"created_at" => started,
|
|
133
|
+
"started_at" => started,
|
|
134
|
+
"finished_at" => nil,
|
|
135
|
+
"exit_code" => nil,
|
|
136
|
+
"stop_reason" => nil,
|
|
137
|
+
**paths,
|
|
138
|
+
"handed_over_by" => Execute::NAME
|
|
139
|
+
}
|
|
140
|
+
write_record(record)
|
|
141
|
+
[record, nil]
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
[nil, "Error: #{e.message}"]
|
|
144
|
+
end
|
|
145
|
+
|
|
100
146
|
def list_records
|
|
101
147
|
ensure_tasks_dir
|
|
102
148
|
task_dirs = Dir.glob(File.join(TASKS_DIR, "*"))
|
|
@@ -111,9 +157,10 @@ module Samagotchi
|
|
|
111
157
|
end.compact.sort_by { |record| record["created_at"].to_s }.reverse
|
|
112
158
|
end
|
|
113
159
|
|
|
114
|
-
# The tasks this conversation
|
|
115
|
-
#
|
|
116
|
-
#
|
|
160
|
+
# The tasks this conversation started that are still running: its
|
|
161
|
+
# task_create calls and the executes it handed over at their limit.
|
|
162
|
+
# Only those results count: records are shared by every session in the
|
|
163
|
+
# worker's cwd, and task_list shows them all.
|
|
117
164
|
# A native tool_response joins its calls' results with "---".
|
|
118
165
|
# @return [Array<Hash>] {id:, command:}, oldest first
|
|
119
166
|
def running_created_in(messages)
|
|
@@ -123,19 +170,53 @@ module Samagotchi
|
|
|
123
170
|
end
|
|
124
171
|
end
|
|
125
172
|
|
|
126
|
-
# The ids of every task this conversation
|
|
127
|
-
# whatever their status now, oldest first.
|
|
173
|
+
# The ids of every task this conversation started (task_create, a
|
|
174
|
+
# handed-over execute), whatever their status now, oldest first.
|
|
128
175
|
# @return [Array<String>]
|
|
129
176
|
def created_ids_in(messages)
|
|
130
177
|
Array(messages).flat_map do |message|
|
|
131
178
|
next [] unless (message[:role] || message["role"]).to_s == "tool_response"
|
|
132
179
|
|
|
133
180
|
(message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
|
|
134
|
-
block[
|
|
181
|
+
block[CREATED_BLOCK, 1] || handed_over_id(block)
|
|
135
182
|
end
|
|
136
183
|
end.uniq
|
|
137
184
|
end
|
|
138
185
|
|
|
186
|
+
# A task_create result as the model gets it: its "[task_create]" lead,
|
|
187
|
+
# after the one "ran as: …" line a gate-corrected call has.
|
|
188
|
+
RAN_AS_LINE = "(?:ran as: [^\n]*\n)?"
|
|
189
|
+
CREATED_BLOCK = /\A#{RAN_AS_LINE}\[task_create\]\ntask_id: (\S+)/
|
|
190
|
+
CREATED_OUTPUT = /\A#{RAN_AS_LINE}(?:\[task_create\]\n)?task_id: (\S+)/
|
|
191
|
+
|
|
192
|
+
# The task id of a tool output that started a task, the "[tool]" lead
|
|
193
|
+
# optional (a UI's row has it or not): a task_create's, or an execute's
|
|
194
|
+
# handed_over_id. nil for any other tool or output.
|
|
195
|
+
# @return [String, nil]
|
|
196
|
+
def created_id(tool, text)
|
|
197
|
+
case tool
|
|
198
|
+
when "task_create" then text.to_s[CREATED_OUTPUT, 1]
|
|
199
|
+
when Execute::NAME then handed_over_id(text)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The task id an execute's hand-over result names: anchored on its
|
|
204
|
+
# first line (Execute::HANDED_OVER), never a "task_id:" line anywhere,
|
|
205
|
+
# which the command's own output can print. The "[execute]" lead and
|
|
206
|
+
# a "ran as: …" line before it are optional. web/public/task_stop.js
|
|
207
|
+
# has the JS twin (handedOverId).
|
|
208
|
+
# @return [String, nil]
|
|
209
|
+
def handed_over_id(text)
|
|
210
|
+
text = text.to_s
|
|
211
|
+
# A regexp raises on bytes that aren't UTF-8 (a command's raw output).
|
|
212
|
+
text = text.scrub unless text.valid_encoding?
|
|
213
|
+
text[handed_over_pattern, 1]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def handed_over_pattern
|
|
217
|
+
@handed_over_pattern ||= /\A#{RAN_AS_LINE}(?:\[#{Execute::NAME}\]\n)?#{Regexp.escape(Execute::HANDED_OVER)} \d+s limit:[^\n]*\ntask_id: (\S+)/
|
|
218
|
+
end
|
|
219
|
+
|
|
139
220
|
# A task id as task_create makes them: no path parts.
|
|
140
221
|
TASK_ID = /\A[A-Za-z0-9_-]+\z/
|
|
141
222
|
|
|
@@ -295,14 +376,16 @@ module Samagotchi
|
|
|
295
376
|
value.empty? ? Dir.pwd : File.expand_path(value)
|
|
296
377
|
end
|
|
297
378
|
|
|
379
|
+
# The command with an EXIT trap set before it that writes the shell's
|
|
380
|
+
# exit status to +exit_code_path+. A prefix, not lines after the
|
|
381
|
+
# command: a trailing "\\" or "|" in the command would swallow those,
|
|
382
|
+
# and an `exit N` or a `set -e` failure would skip them. A command that
|
|
383
|
+
# sets its own EXIT trap replaces this one: no exit_code file then
|
|
384
|
+
# (the task ends failed with process_ended_without_exit_code, unless
|
|
385
|
+
# its starter's waiter writes the code: Execute's hand-over).
|
|
298
386
|
def wrapped_shell_command(command, exit_code_path)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
#{command}
|
|
302
|
-
status=$?
|
|
303
|
-
printf "%s" "$status" > #{escaped_exit_code_path}
|
|
304
|
-
exit "$status"
|
|
305
|
-
SH
|
|
387
|
+
write = %(printf "%s" "$?" > #{Shellwords.escape(exit_code_path)})
|
|
388
|
+
"trap #{Shellwords.escape(write)} EXIT\n#{command}\n"
|
|
306
389
|
end
|
|
307
390
|
|
|
308
391
|
def sanitized_spawn_env
|