samagotchi 0.18.0 → 0.19.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 +119 -1
- data/README.md +4 -3
- data/bin/chi +5 -2
- data/docs/architecture.md +15 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +8 -0
- data/docs/memory.md +4 -0
- data/docs/sessions.md +6 -4
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +2 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/engine.rb +35 -11
- data/lib/samagotchi/guardrail_wiring.rb +64 -5
- data/lib/samagotchi/guardrails/approval.rb +10 -4
- data/lib/samagotchi/guardrails/approvals.rb +60 -12
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/memory_bundle/builder.rb +24 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +33 -0
- data/lib/samagotchi/memory_bundle/status.rb +5 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +6 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/terminal_ui.rb +65 -16
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +97 -19
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +64 -11
- data/lib/samagotchi/web/public/app.js +102 -29
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +4 -2
- data/lib/samagotchi/web/public/index.html +11 -0
- data/lib/samagotchi/web/public/question_card.js +12 -0
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/turn_events.js +107 -3
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +5 -0
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +4 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatting"
|
|
4
|
+
require_relative "event_renderer"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The turn sink of `chi -p … --non-interactive` (Engine#run_turn's
|
|
9
|
+
# on_event): the few lines a reader wants while the turn runs, on
|
|
10
|
+
# stderr. stdout is the answer's alone, printed after the turn: a
|
|
11
|
+
# script or a parent agent reads it.
|
|
12
|
+
#
|
|
13
|
+
# It prints the retry lines the REPL shows: the loop asking again after
|
|
14
|
+
# an empty answer (:empty_answer_retry), and a generation retried after
|
|
15
|
+
# a failed request (:generation_retrying). And the hooks' notices
|
|
16
|
+
# (:hook_notice): one during the turn as it comes, one the after_turn
|
|
17
|
+
# hooks give (after :turn_completed; source-links' `sources:`) kept for
|
|
18
|
+
# #flush, which the caller runs after it has printed the answer.
|
|
19
|
+
class OneShotSink
|
|
20
|
+
include Formatting
|
|
21
|
+
|
|
22
|
+
# @param err [IO, nil] nil writes to $stderr as it is at each write
|
|
23
|
+
def initialize(err: nil)
|
|
24
|
+
@err = err
|
|
25
|
+
@turn_over = false
|
|
26
|
+
@held = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(event)
|
|
30
|
+
case event[:type]
|
|
31
|
+
when :empty_answer_retry then line(format_empty_retry_line(event))
|
|
32
|
+
when :generation_retrying then line(format_generation_retry_line(event))
|
|
33
|
+
when :turn_completed, :turn_canceled then @turn_over = true
|
|
34
|
+
when :hook_notice
|
|
35
|
+
text = EventRenderer.hook_notice_line(event)
|
|
36
|
+
@turn_over ? @held << text : line(text)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Print the notices that came after the turn's end.
|
|
41
|
+
def flush
|
|
42
|
+
line(@held.shift) until @held.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def line(text)
|
|
48
|
+
err.puts(text)
|
|
49
|
+
err.flush
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def err = @err || $stderr
|
|
53
|
+
|
|
54
|
+
# Colour when stderr, where the lines go, is a colour terminal.
|
|
55
|
+
def color_output?
|
|
56
|
+
return false unless err.tty?
|
|
57
|
+
return false if ENV.key?("NO_COLOR")
|
|
58
|
+
|
|
59
|
+
ENV.fetch("TERM", "") != "dumb"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../installed_versions"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The line an attaching terminal shows when chi versions differ: the
|
|
9
|
+
# session's worker (its snapshot's chi_version), this terminal (VERSION)
|
|
10
|
+
# and the newest installed. Neither process can reload itself: the worker
|
|
11
|
+
# moves with `chi sessions restart`, the terminal by attaching again.
|
|
12
|
+
module VersionLines
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param worker [String, nil] the worker's chi_version (nil: a worker
|
|
16
|
+
# from before it said so; nothing is said then)
|
|
17
|
+
# @param installed [String, nil] InstalledVersions#newest
|
|
18
|
+
# @return [String, nil] one "chi> ..." line, or nil when all is current
|
|
19
|
+
def at_attach(worker:, installed:, session_id:, terminal: VERSION)
|
|
20
|
+
return nil if worker.to_s.empty?
|
|
21
|
+
|
|
22
|
+
newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
|
|
23
|
+
.max_by { |v| Gem::Version.new(v) }
|
|
24
|
+
worker_old = InstalledVersions.newer?(newest, worker)
|
|
25
|
+
terminal_old = InstalledVersions.newer?(newest, terminal)
|
|
26
|
+
return nil unless worker_old || terminal_old
|
|
27
|
+
|
|
28
|
+
id = session_id.to_s[0, 8]
|
|
29
|
+
restart = "chi sessions restart #{id}"
|
|
30
|
+
attach = "/detach, then chi --attach #{id}"
|
|
31
|
+
if worker_old && terminal_old
|
|
32
|
+
"chi> chi #{newest} is installed; this session's worker runs #{worker} and this terminal #{terminal}. " \
|
|
33
|
+
"#{restart} moves the worker; #{attach} for the terminal."
|
|
34
|
+
elsif worker_old
|
|
35
|
+
"chi> chi #{newest} is installed; this session's worker runs #{worker}. #{restart} moves it there " \
|
|
36
|
+
"(this terminal follows)."
|
|
37
|
+
else
|
|
38
|
+
"chi> chi #{newest} is installed; this terminal runs #{terminal}. #{attach} to use it."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# After the stream moved to a new worker (a restart).
|
|
43
|
+
def restarted(worker)
|
|
44
|
+
worker.to_s.empty? ? "chi> the session's worker restarted" : "chi> the session's worker restarted on chi #{worker}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -30,6 +30,7 @@ require_relative "terminal_ui/input_support"
|
|
|
30
30
|
require_relative "terminal_ui/image_input"
|
|
31
31
|
require_relative "terminal_ui/plain_surface"
|
|
32
32
|
require_relative "terminal_ui/live_region"
|
|
33
|
+
require_relative "terminal_ui/one_shot_sink"
|
|
33
34
|
require_relative "terminal_ui/question_prompt"
|
|
34
35
|
require_relative "terminal_ui/repl_input"
|
|
35
36
|
require_relative "terminal_ui/status_row"
|
|
@@ -210,7 +211,9 @@ module Samagotchi
|
|
|
210
211
|
# follow-up REPL (--non-interactive) or drops into the REPL carrying the
|
|
211
212
|
# post-turn conversation.
|
|
212
213
|
# @return [Symbol, nil] :turn_failed when lines came from a pipe and a
|
|
213
|
-
# turn failed
|
|
214
|
+
# turn failed, :empty_answer when a -p --non-interactive turn ended
|
|
215
|
+
# with no answer (bin/chi exits 1 on both), :interrupted when Ctrl-C
|
|
216
|
+
# cancelled that turn (bin/chi exits 130)
|
|
214
217
|
def run
|
|
215
218
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
216
219
|
# nothing and return (no transient session, no banner).
|
|
@@ -240,25 +243,36 @@ module Samagotchi
|
|
|
240
243
|
if @prompt && @non_interactive
|
|
241
244
|
# Headless / CI mode: run directly without TTY rendering.
|
|
242
245
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
246
|
+
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
247
|
+
sink = OneShotSink.new
|
|
248
|
+
begin
|
|
249
|
+
result = @engine.run_turn(
|
|
250
|
+
session,
|
|
251
|
+
@prompt,
|
|
252
|
+
on_event: sink,
|
|
253
|
+
max_iterations: IterationLimit.for(no_interrupt: true),
|
|
254
|
+
cancel_controller: nil,
|
|
255
|
+
images: ImageInput.extract(@prompt)
|
|
256
|
+
)
|
|
257
|
+
rescue LLM::ProviderError, ImageStore::Error => e
|
|
258
|
+
return one_shot_failed(session, e)
|
|
259
|
+
rescue Interrupt
|
|
260
|
+
return one_shot_interrupted(session)
|
|
261
|
+
end
|
|
251
262
|
# No answer (the empty-answer retries used up): no text, or the chat
|
|
252
263
|
# loop's placeholder (LLM::ModelResult#empty_answer?).
|
|
253
264
|
if result.empty_answer?
|
|
254
|
-
# Nothing to print: say so on stderr and fail
|
|
255
|
-
# pipes the answer doesn't take the silence for one.
|
|
265
|
+
# Nothing to print: say so on stderr and fail (bin/chi exits 1),
|
|
266
|
+
# so a script that pipes the answer doesn't take the silence for one.
|
|
256
267
|
session.save
|
|
257
268
|
warn EMPTY_ANSWER_ERROR
|
|
258
|
-
|
|
269
|
+
sink.flush
|
|
270
|
+
return :empty_answer
|
|
259
271
|
end
|
|
260
272
|
@surface.commit(result.output)
|
|
261
273
|
session.save
|
|
274
|
+
# The after_turn hooks' notices, after the answer as the REPL shows them.
|
|
275
|
+
sink.flush
|
|
262
276
|
return
|
|
263
277
|
end
|
|
264
278
|
|
|
@@ -286,6 +300,18 @@ module Samagotchi
|
|
|
286
300
|
|
|
287
301
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
288
302
|
|
|
303
|
+
# A -p --non-interactive turn that failed (bin/chi exits 1): the error,
|
|
304
|
+
# then how to go on. The Engine saved the failed turn with its prompt,
|
|
305
|
+
# so the session is kept and `chi --resume` takes it up again.
|
|
306
|
+
# @return [Symbol] :turn_failed
|
|
307
|
+
def one_shot_failed(session, error)
|
|
308
|
+
warn "Error: #{error.respond_to?(:summary) ? error.summary : error.message}"
|
|
309
|
+
if !@scratch && Session.exist?(session.id)
|
|
310
|
+
warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
|
|
311
|
+
end
|
|
312
|
+
:turn_failed
|
|
313
|
+
end
|
|
314
|
+
|
|
289
315
|
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
290
316
|
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
291
317
|
# the command changed it.
|
|
@@ -302,6 +328,19 @@ module Samagotchi
|
|
|
302
328
|
:turn_failed if result.status == :error
|
|
303
329
|
end
|
|
304
330
|
|
|
331
|
+
# Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
|
|
332
|
+
# a cancel note in the session (it saves only a failed turn), so save
|
|
333
|
+
# it here: `chi --resume` takes it up again. A scratch session goes in
|
|
334
|
+
# #run's ensure, so there is nothing to keep.
|
|
335
|
+
# @return [Symbol] :interrupted
|
|
336
|
+
def one_shot_interrupted(session)
|
|
337
|
+
return :interrupted if @scratch
|
|
338
|
+
|
|
339
|
+
session.save
|
|
340
|
+
warn "chi: canceled (Ctrl-C); the session is kept: continue it with chi --resume #{session.id}"
|
|
341
|
+
:interrupted
|
|
342
|
+
end
|
|
343
|
+
|
|
305
344
|
# Delete the scratch session, the last thing the REPL does. Quiet after
|
|
306
345
|
# a one-shot or on the way out of an error or a signal.
|
|
307
346
|
def delete_scratch(session, quiet:)
|
|
@@ -400,19 +439,29 @@ module Samagotchi
|
|
|
400
439
|
system_message = { role: "system", content: seed_system_prompt }
|
|
401
440
|
if @resume_session
|
|
402
441
|
messages = ContextNote.with_system_head(session.messages.dup, system_message)
|
|
403
|
-
|
|
442
|
+
banner("Resumed session: #{session.id}")
|
|
404
443
|
saved = @engine.saved_recap
|
|
405
|
-
|
|
444
|
+
banner(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
|
|
406
445
|
elsif @scratch
|
|
407
446
|
messages = [system_message]
|
|
408
|
-
|
|
447
|
+
banner("Scratch session: nothing is kept, it is deleted when you leave.")
|
|
409
448
|
else
|
|
410
449
|
messages = [system_message]
|
|
411
|
-
|
|
450
|
+
banner("Session: #{session.id}")
|
|
412
451
|
end
|
|
413
452
|
messages
|
|
414
453
|
end
|
|
415
454
|
|
|
455
|
+
# A line about the session, not the answer: on stderr in a
|
|
456
|
+
# --non-interactive run, whose stdout is the answer's alone (a script
|
|
457
|
+
# or a parent agent reads it), and before the turn, so it comes first.
|
|
458
|
+
def banner(text)
|
|
459
|
+
return @surface.commit(text) unless @non_interactive
|
|
460
|
+
|
|
461
|
+
$stderr.puts(text)
|
|
462
|
+
$stderr.flush
|
|
463
|
+
end
|
|
464
|
+
|
|
416
465
|
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
417
466
|
# threaded in here (so --prompt / --resume share one code path). The
|
|
418
467
|
# session's conversation is the single working copy: turns go through
|
|
@@ -18,7 +18,7 @@ module Samagotchi
|
|
|
18
18
|
TOOL_SCHEMAS = [
|
|
19
19
|
{
|
|
20
20
|
name: "execute",
|
|
21
|
-
description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata.",
|
|
21
|
+
description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. Don't background with &; use task_create.",
|
|
22
22
|
parameters: {
|
|
23
23
|
type: "object",
|
|
24
24
|
properties: {
|
|
@@ -153,7 +153,7 @@ module Samagotchi
|
|
|
153
153
|
},
|
|
154
154
|
{
|
|
155
155
|
name: "task_create",
|
|
156
|
-
description: "Start a background task for a long-running shell command. Returns task id and output path for later inspection.",
|
|
156
|
+
description: "Start a background task for a long-running shell command. Servers and anything meant to keep running belong here, not in execute with &. Returns task id and output path for later inspection.",
|
|
157
157
|
parameters: {
|
|
158
158
|
type: "object",
|
|
159
159
|
properties: {
|
|
@@ -22,6 +22,7 @@ require_relative "delegate"
|
|
|
22
22
|
require_relative "delegate_result"
|
|
23
23
|
require_relative "ask_user_question"
|
|
24
24
|
require_relative "../guardrails/parent_approvals"
|
|
25
|
+
require_relative "../model_profile"
|
|
25
26
|
|
|
26
27
|
module Samagotchi
|
|
27
28
|
module Tools
|
|
@@ -73,7 +74,8 @@ module Samagotchi
|
|
|
73
74
|
TaskCreate::NAME => lambda do |call, kctx|
|
|
74
75
|
next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
|
|
75
76
|
|
|
76
|
-
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
77
|
+
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
78
|
+
marker_env: task_marker_env(kctx, call[:env]))
|
|
77
79
|
end,
|
|
78
80
|
TaskWait::NAME => lambda do |call, kctx|
|
|
79
81
|
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
@@ -108,17 +110,31 @@ module Samagotchi
|
|
|
108
110
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
109
111
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
110
112
|
# /model), which chi self reports; unset when unknown, so a value
|
|
111
|
-
# inherited from an outer session never passes for this one.
|
|
112
|
-
|
|
113
|
+
# inherited from an outer session never passes for this one. A
|
|
114
|
+
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
115
|
+
# is unset: it was that run's, not the default of a chi run here.
|
|
116
|
+
def parent_env(kctx, env: ENV)
|
|
113
117
|
peers = kctx.peers
|
|
114
118
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
115
119
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
116
|
-
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
117
|
-
|
|
120
|
+
vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
121
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model }
|
|
122
|
+
if env[ModelProfile::MODEL_FROM_CLI_ENV]
|
|
123
|
+
vars[ModelProfile::MODEL_ENV] = nil
|
|
124
|
+
vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
|
|
125
|
+
end
|
|
126
|
+
vars
|
|
118
127
|
end
|
|
119
128
|
|
|
120
129
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
121
130
|
|
|
131
|
+
# task_create's parent_env, less the unsets of a variable the model
|
|
132
|
+
# set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
|
|
133
|
+
def task_marker_env(kctx, env)
|
|
134
|
+
given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
|
|
135
|
+
parent_env(kctx).reject { |key, value| value.nil? && given.include?(key) }
|
|
136
|
+
end
|
|
137
|
+
|
|
122
138
|
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
123
139
|
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
124
140
|
def cancelled_proc(kctx)
|
|
@@ -21,6 +21,15 @@ module Samagotchi
|
|
|
21
21
|
WAIT_SLICE_SEC = 0.2
|
|
22
22
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
23
23
|
TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
|
|
24
|
+
# How long the output may stay open once the shell has exited: a
|
|
25
|
+
# `server &` child keeps the pipes, and the read would never end.
|
|
26
|
+
BACKGROUND_GRACE_SEC = 1.5
|
|
27
|
+
BACKGROUND_HINT = "(a background process kept the output open and was stopped; " \
|
|
28
|
+
"to keep a server or long job running, start it with task_create)"
|
|
29
|
+
# It had left the process group (setsid), so it is still running.
|
|
30
|
+
BACKGROUND_DETACHED_HINT = "(a background process kept the output open and was left running; " \
|
|
31
|
+
"to keep a server or long job running, start it with task_create)"
|
|
32
|
+
READ_CHUNK_BYTES = 65_536
|
|
24
33
|
|
|
25
34
|
def self.name = NAME
|
|
26
35
|
|
|
@@ -35,7 +44,7 @@ module Samagotchi
|
|
|
35
44
|
return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
|
|
36
45
|
|
|
37
46
|
timeout_sec = timeout_seconds
|
|
38
|
-
stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
|
|
47
|
+
stdout, stderr, status, held_open = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
|
|
39
48
|
env: env)
|
|
40
49
|
|
|
41
50
|
stdout_block = output_block("stdout", stdout)
|
|
@@ -50,6 +59,7 @@ module Samagotchi
|
|
|
50
59
|
# model as "done, something happened".
|
|
51
60
|
silent = stdout_block.nil? && stderr_block.nil?
|
|
52
61
|
parts << (silent ? "exit: #{status.exitstatus} (no output)" : "exit: #{status.exitstatus}")
|
|
62
|
+
parts << (held_open == :detached ? BACKGROUND_DETACHED_HINT : BACKGROUND_HINT) if held_open
|
|
53
63
|
parts.join("\n")
|
|
54
64
|
rescue CommandTimedOut => e
|
|
55
65
|
# Keep the "Error:" first line (callers classify on it), say how to run
|
|
@@ -75,13 +85,14 @@ module Samagotchi
|
|
|
75
85
|
status = nil
|
|
76
86
|
timed_out = false
|
|
77
87
|
was_cancelled = false
|
|
88
|
+
aborted = false
|
|
89
|
+
held_open = false
|
|
78
90
|
started = monotonic_time
|
|
79
91
|
deadline = started + timeout_sec
|
|
80
92
|
|
|
81
93
|
Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
82
94
|
stdin.close
|
|
83
|
-
|
|
84
|
-
stderr_reader = reader_thread_for(stderr)
|
|
95
|
+
readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
|
|
85
96
|
|
|
86
97
|
begin
|
|
87
98
|
until wait_thr.join(WAIT_SLICE_SEC)
|
|
@@ -100,36 +111,103 @@ module Samagotchi
|
|
|
100
111
|
# An Interrupt (or anything else) mid-wait: the child is in its own
|
|
101
112
|
# process group, so it never saw the SIGINT. Kill it, or the reads
|
|
102
113
|
# below block until it closes stdout.
|
|
114
|
+
aborted = true
|
|
103
115
|
terminate_process_tree(wait_thr.pid)
|
|
104
116
|
raise
|
|
105
117
|
ensure
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
# The shell is gone, but a child it put in the background (`cmd &`)
|
|
119
|
+
# may still hold the pipes: wait for EOF a short grace only, then
|
|
120
|
+
# stop the command's process group and keep the output so far.
|
|
121
|
+
# Already stopped (timeout, Stop, an exception): no more checks.
|
|
122
|
+
stopping = timed_out || was_cancelled || aborted
|
|
123
|
+
outcome = await_readers(readers, until_time: monotonic_time + BACKGROUND_GRACE_SEC,
|
|
124
|
+
deadline: stopping ? nil : deadline,
|
|
125
|
+
cancelled: stopping ? -> { false } : cancelled)
|
|
126
|
+
unless outcome == :done
|
|
127
|
+
was_cancelled = true if outcome == :cancelled
|
|
128
|
+
timed_out = true if outcome == :timeout
|
|
129
|
+
stopped = stop_process_group(wait_thr.pid, readers)
|
|
130
|
+
held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
|
|
131
|
+
end
|
|
132
|
+
readers.each(&:finish)
|
|
133
|
+
stdout_text, stderr_text = readers.map(&:text)
|
|
114
134
|
end
|
|
115
135
|
end
|
|
116
136
|
|
|
117
137
|
raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
|
|
118
138
|
raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
|
|
119
139
|
|
|
120
|
-
[stdout_text, stderr_text, status]
|
|
140
|
+
[stdout_text, stderr_text, status, held_open]
|
|
121
141
|
end
|
|
122
142
|
private_class_method :run_command
|
|
123
143
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
# Waits for both readers to hit EOF until +until_time+, the overall
|
|
145
|
+
# +deadline+ or a Stop. Returns :done, :grace, :timeout or :cancelled.
|
|
146
|
+
def self.await_readers(readers, until_time:, deadline:, cancelled:)
|
|
147
|
+
loop do
|
|
148
|
+
return :done if readers.all?(&:done?)
|
|
149
|
+
return :cancelled if cancelled.call
|
|
150
|
+
return :timeout if deadline && monotonic_time > deadline
|
|
151
|
+
return :grace if monotonic_time > until_time
|
|
152
|
+
|
|
153
|
+
readers.find { |r| !r.done? }&.wait(STOP_POLL_INTERVAL_SEC)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
private_class_method :await_readers
|
|
157
|
+
|
|
158
|
+
# The shell has exited (its pid is reaped) but its process group still
|
|
159
|
+
# has members holding the output: TERM the group, then KILL it. A process
|
|
160
|
+
# that left the group (setsid) is out of reach; the pipes get closed.
|
|
161
|
+
# True once the output was released, false if something still holds it.
|
|
162
|
+
def self.stop_process_group(pgid, readers)
|
|
163
|
+
signal_group(pgid, "TERM")
|
|
164
|
+
return true if await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil,
|
|
165
|
+
cancelled: -> { false }) == :done
|
|
166
|
+
|
|
167
|
+
signal_group(pgid, "KILL")
|
|
168
|
+
await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
|
|
169
|
+
end
|
|
170
|
+
private_class_method :stop_process_group
|
|
171
|
+
|
|
172
|
+
# Only the group: the leader's pid is reaped, so it may name another process.
|
|
173
|
+
def self.signal_group(pgid, signal)
|
|
174
|
+
Process.kill(signal, -pgid)
|
|
175
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
private_class_method :signal_group
|
|
179
|
+
|
|
180
|
+
# Reads a pipe in chunks on its own thread, so the output read so far
|
|
181
|
+
# survives the pipe being closed under it.
|
|
182
|
+
class OutputReader
|
|
183
|
+
def initialize(io)
|
|
184
|
+
@io = io
|
|
185
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
186
|
+
@thread = Thread.new do
|
|
187
|
+
Thread.current.report_on_exception = false
|
|
188
|
+
loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
|
|
189
|
+
rescue EOFError, IOError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def done? = !@thread.alive?
|
|
195
|
+
|
|
196
|
+
def wait(seconds) = @thread.join(seconds)
|
|
197
|
+
|
|
198
|
+
# Closes the pipe (a reader still blocked gets IOError) and ends the
|
|
199
|
+
# thread; the text is safe to read afterwards.
|
|
200
|
+
def finish
|
|
201
|
+
@thread.join(STOP_POLL_INTERVAL_SEC) unless done?
|
|
202
|
+
Execute.send(:close_quietly, @io)
|
|
203
|
+
@thread.kill unless @thread.join(STOP_GRACE_SEC)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def text
|
|
207
|
+
@buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
|
|
130
208
|
end
|
|
131
209
|
end
|
|
132
|
-
|
|
210
|
+
private_constant :OutputReader
|
|
133
211
|
|
|
134
212
|
def self.close_quietly(io)
|
|
135
213
|
io.close unless io.closed?
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -37,7 +37,8 @@ module Samagotchi
|
|
|
37
37
|
# @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
|
|
38
38
|
# @param seconds [Numeric, nil] how long the turn had run
|
|
39
39
|
# @param shown [Boolean] visible text had streamed (an `[interrupted]`
|
|
40
|
-
# model message precedes this note)
|
|
40
|
+
# model message precedes this note): an answer, or only narration
|
|
41
|
+
# before a tool call, so the note says "reply"
|
|
41
42
|
# @param running_tasks [Array<Hash>] {id:, command:} of this session's
|
|
42
43
|
# background tasks still running: a Stop doesn't end them
|
|
43
44
|
# @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
|
|
@@ -45,7 +46,7 @@ module Samagotchi
|
|
|
45
46
|
def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
|
|
46
47
|
why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
|
|
47
48
|
after = seconds ? " after #{seconds.round}s" : ""
|
|
48
|
-
what = shown ? "the
|
|
49
|
+
what = shown ? "the reply above ends where it was cut off." : "no answer had been shown."
|
|
49
50
|
message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -34,8 +34,9 @@ module Samagotchi
|
|
|
34
34
|
--no-gem don't install a newer gem (config update.gem: false)
|
|
35
35
|
--no-bundles leave the shipped bundles alone (config update.bundles: false)
|
|
36
36
|
--no-desktop leave the desktop helper alone (config update.desktop: false)
|
|
37
|
-
Running sessions keep their chi until
|
|
38
|
-
|
|
37
|
+
Running sessions keep their chi until restarted (chi sessions
|
|
38
|
+
restart ID) or until they idle out with nothing attached; a running
|
|
39
|
+
chi web needs a restart (the sessions it starts run the new chi).
|
|
39
40
|
TEXT
|
|
40
41
|
|
|
41
42
|
# Help is a flag here: it doesn't stop the parse (an unknown option
|
|
@@ -238,22 +239,42 @@ module Samagotchi
|
|
|
238
239
|
def live_rows
|
|
239
240
|
rows = []
|
|
240
241
|
stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
|
|
241
|
-
unless stale.empty?
|
|
242
|
-
versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
|
|
243
|
-
ids = stale.map { |w| w.session_id[0, 8] }.join(" ")
|
|
244
|
-
rows << Row.new(component: "workers",
|
|
245
|
-
status: "#{stale.size} live on #{versions}: they move to #{VERSION} at idle exit (30 min) " \
|
|
246
|
-
"or chi sessions stop #{ids}")
|
|
247
|
-
end
|
|
242
|
+
rows << Row.new(component: "workers", status: workers_status(stale)) unless stale.empty?
|
|
248
243
|
host, port = @web
|
|
249
|
-
|
|
250
|
-
|
|
244
|
+
info = LiveVersions.web_info(host, port)
|
|
245
|
+
web = info && info["version"].to_s
|
|
246
|
+
if web && !web.empty? && web != VERSION
|
|
247
|
+
# Since newest_workers, the sessions it starts run the newest chi.
|
|
248
|
+
spreads = !Array(info["features"]).include?("newest_workers")
|
|
251
249
|
rows << Row.new(component: "chi web :#{port}", from: web,
|
|
252
|
-
status: "running old code: new sessions it starts run #{web} too; restart it")
|
|
250
|
+
status: "running old code#{": new sessions it starts run #{web} too" if spreads}; restart it")
|
|
253
251
|
end
|
|
254
252
|
rows
|
|
255
253
|
end
|
|
256
254
|
|
|
255
|
+
# A worker keeps its chi until restarted: `chi sessions restart` for
|
|
256
|
+
# one that can, a stop for an older one (the next prompt starts it on
|
|
257
|
+
# the newest). It idles out on its own only with nothing attached.
|
|
258
|
+
def workers_status(stale)
|
|
259
|
+
versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
|
|
260
|
+
restart, stop = stale.partition(&:restart).map { |ws| ws.map { |w| w.session_id[0, 8] }.join(" ") }
|
|
261
|
+
moves = []
|
|
262
|
+
moves << "chi sessions restart #{restart}" unless restart.empty?
|
|
263
|
+
moves << "chi sessions stop #{stop}" unless stop.empty?
|
|
264
|
+
"#{stale.size} live on #{versions}: they keep it until restarted (#{moves.join("; ")})#{idle_note}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def idle_note
|
|
268
|
+
minutes = begin
|
|
269
|
+
Config.get("session.idle_exit_minutes").to_f
|
|
270
|
+
rescue StandardError
|
|
271
|
+
30.0
|
|
272
|
+
end
|
|
273
|
+
return "; they never idle out (session.idle_exit_minutes: 0)" unless minutes.positive?
|
|
274
|
+
|
|
275
|
+
"; or they idle out after #{minutes.round(1).to_s.delete_suffix(".0")} min with nothing attached"
|
|
276
|
+
end
|
|
277
|
+
|
|
257
278
|
def footers(options)
|
|
258
279
|
return [] if off(options, "bundles")
|
|
259
280
|
|