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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/docs/architecture.md +3 -3
  4. data/docs/cli.md +5 -4
  5. data/docs/configuration.md +35 -20
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/background-tasks.md +7 -0
  8. data/docs/internals/engine.md +5 -5
  9. data/docs/internals/prompt-caching.md +2 -2
  10. data/docs/internals/tool-guardrails.md +2 -1
  11. data/docs/plugins.md +26 -6
  12. data/docs/sessions.md +1 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  14. data/lib/samagotchi/bridge.rb +6 -2
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/config.rb +11 -5
  20. data/lib/samagotchi/engine.rb +26 -1
  21. data/lib/samagotchi/host_config.rb +3 -1
  22. data/lib/samagotchi/host_registry.rb +2 -1
  23. data/lib/samagotchi/model_settings.rb +4 -2
  24. data/lib/samagotchi/process_group.rb +76 -3
  25. data/lib/samagotchi/send_command.rb +1 -1
  26. data/lib/samagotchi/session_commands.rb +15 -3
  27. data/lib/samagotchi/steer_cut.rb +12 -8
  28. data/lib/samagotchi/steer_cut_after.rb +69 -0
  29. data/lib/samagotchi/system_prompt.rb +5 -5
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  31. data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
  32. data/lib/samagotchi/terminal_ui.rb +1 -1
  33. data/lib/samagotchi/tool_activity.rb +18 -1
  34. data/lib/samagotchi/tool_declarations.rb +1 -1
  35. data/lib/samagotchi/tools/execute.rb +318 -70
  36. data/lib/samagotchi/tools/task_get.rb +2 -0
  37. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  38. data/lib/samagotchi/tools/task_wait.rb +23 -5
  39. data/lib/samagotchi/utf8_default.rb +1 -1
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/public/read_view.js +3 -1
  42. data/lib/samagotchi/web/public/task_stop.js +25 -7
  43. metadata +2 -1
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
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 (hooks match on it).
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
- READ_CHUNK_BYTES = 65_536
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
- def self.call(command, cwd: nil, cancelled: -> { false }, env: nil)
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
- env: env)
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
- def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false }, env: nil)
89
- stdout_text = ""
90
- stderr_text = ""
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
- Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
100
- stdin.close
101
- readers = [stdout, stderr].map { |io| ProcessGroup::PipeReader.new(io, chunk: READ_CHUNK_BYTES) }
102
-
103
- begin
104
- until wait_thr.join(WAIT_SLICE_SEC)
105
- if cancelled.call
106
- was_cancelled = true
107
- break
108
- elsif monotonic_time > deadline
109
- timed_out = true
110
- break
111
- end
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
- terminate_process_tree(wait_thr.pid) if timed_out || was_cancelled
114
- wait_thr.join
115
- status = wait_thr.value
116
- rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
117
- # An Interrupt (or anything else) mid-wait: the child is in its own
118
- # process group, so it never saw the SIGINT. Kill it, or the reads
119
- # below block until it closes stdout.
120
- aborted = true
121
- terminate_process_tree(wait_thr.pid)
122
- raise
123
- ensure
124
- # The shell is gone, but a child it put in the background (`cmd &`)
125
- # may still hold the pipes: wait for EOF a short grace only, then
126
- # stop the command's process group and keep the output so far.
127
- # Already stopped (timeout, Stop, an exception): no more checks.
128
- stopping = timed_out || was_cancelled || aborted
129
- outcome = await_readers(readers, until_time: monotonic_time + BACKGROUND_GRACE_SEC,
130
- deadline: stopping ? nil : deadline,
131
- cancelled: stopping ? -> { false } : cancelled)
132
- unless outcome == :done
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
- readers.each { |reader| reader.finish(poll: STOP_POLL_INTERVAL_SEC, grace: STOP_GRACE_SEC) }
139
- stdout_text, stderr_text = readers.map(&:text)
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
- # Waits for both readers to hit EOF until +until_time+, the overall
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.await_readers(readers, until_time:, deadline:, cancelled:)
279
+ def self.await_release(paths, until_time:, deadline:, cancelled:)
153
280
  loop do
154
- return :done if readers.all?(&: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
- readers.find { |r| !r.done? }&.wait(STOP_POLL_INTERVAL_SEC)
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
- private_class_method :await_readers
163
-
164
- # The shell has exited (its pid is reaped) but its process group still
165
- # has members holding the output: TERM the group, then KILL it. A process
166
- # that left the group (setsid) is out of reach; the pipes get closed.
167
- # True once the output was released, false if something still holds it.
168
- # Only the group: the leader's pid is reaped, so it may name another process.
169
- def self.stop_process_group(pgid, readers)
170
- released = -> { readers.all?(&:done?) }
171
- wait = ->(seconds) { readers.find { |r| !r.done? }&.wait(seconds) }
172
- return true if ProcessGroup.stop(pgid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, stopped: released, wait: wait)
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 :stop_process_group
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
- def self.terminate_process_tree(pid)
181
- ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, leader: true)
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 = OutputGuardrails.head_tail_from_string(content: content, preview_bytes: preview_bytes)
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's task_create calls started that are
115
- # still running. Only task_create results count: records are shared by
116
- # every session in the worker's cwd, and task_list shows them all.
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's task_create calls started,
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[/\A\[task_create\]\ntask_id: (\S+)/, 1]
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
- escaped_exit_code_path = Shellwords.escape(exit_code_path)
300
- <<~SH
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