samagotchi 0.18.1 → 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 +85 -1
- data/README.md +4 -3
- data/docs/architecture.md +15 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +8 -4
- 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/engine.rb +9 -3
- data/lib/samagotchi/guardrail_wiring.rb +8 -2
- 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/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 +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- 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 +48 -3
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/tools/builtins.rb +21 -5
- 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 +3 -1
|
@@ -19,7 +19,9 @@ require_relative "bridge_client"
|
|
|
19
19
|
require_relative "worker_sidecar"
|
|
20
20
|
require_relative "log"
|
|
21
21
|
require_relative "log_path"
|
|
22
|
+
require_relative "model_profile"
|
|
22
23
|
require_relative "installed_gem"
|
|
24
|
+
require_relative "installed_versions"
|
|
23
25
|
require_relative "recap_store"
|
|
24
26
|
require_relative "archive_store"
|
|
25
27
|
require_relative "image_store"
|
|
@@ -191,6 +193,7 @@ module Samagotchi
|
|
|
191
193
|
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
192
194
|
# other flags this chi was started with.
|
|
193
195
|
child_env.merge!(Config.cli_env)
|
|
196
|
+
child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
|
|
194
197
|
# And the spawner's debug log, absolute: a relative log.file would
|
|
195
198
|
# otherwise land in the worker's (the session's) directory.
|
|
196
199
|
log_path = begin LogPath.resolve rescue nil end
|
|
@@ -204,10 +207,25 @@ module Samagotchi
|
|
|
204
207
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
205
208
|
# older or future env-exposed one out as well.
|
|
206
209
|
ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
|
|
210
|
+
# A smoke's fake "newest installed" is for this process's notices only.
|
|
211
|
+
child_env[InstalledVersions::ENV_KEY] = nil
|
|
212
|
+
# A gem-mode worker activates the newest installed chi itself: under
|
|
213
|
+
# `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
|
|
214
|
+
# hold it to the bundle's version, and its badge would never clear.
|
|
215
|
+
child_env.merge!(unbundled_env_changes) if InstalledGem.spec
|
|
207
216
|
opts[:env] = child_env unless child_env.empty?
|
|
208
217
|
opts
|
|
209
218
|
end
|
|
210
219
|
|
|
220
|
+
# What Bundler changed in ENV (RUBYOPT, BUNDLE_*), mapped back to the
|
|
221
|
+
# values from before it (nil: unset); {} outside Bundler.
|
|
222
|
+
private_class_method def self.unbundled_env_changes(env = ENV.to_h)
|
|
223
|
+
return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:unbundled_env)
|
|
224
|
+
|
|
225
|
+
clean = ::Bundler.unbundled_env
|
|
226
|
+
(env.keys | clean.keys).each_with_object({}) { |key, changes| changes[key] = clean[key] unless clean[key] == env[key] }
|
|
227
|
+
end
|
|
228
|
+
|
|
211
229
|
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
212
230
|
|
|
213
231
|
# List all sessions, reading status from persisted session.json files.
|
|
@@ -409,6 +427,10 @@ module Samagotchi
|
|
|
409
427
|
# status is turn state, not liveness: a new worker runs no turn yet,
|
|
410
428
|
# and must not find the session stopped (it would exit): neither the
|
|
411
429
|
# marker nor a status field from before it.
|
|
430
|
+
# One this process just spawned may not hold the lock yet (a slow
|
|
431
|
+
# start, deliver_turn's second resume): it would only lose it.
|
|
432
|
+
return session if still_starting?(session.id, state_dir: sd)
|
|
433
|
+
|
|
412
434
|
Session.clear_stopped(session.id, state_dir: sd)
|
|
413
435
|
session.status = Session::STATUS_IDLE
|
|
414
436
|
session.save(state_dir: sd)
|
|
@@ -416,6 +438,36 @@ module Samagotchi
|
|
|
416
438
|
session
|
|
417
439
|
end
|
|
418
440
|
|
|
441
|
+
# How long after a spawn a resume trusts that worker to take the
|
|
442
|
+
# session, while it runs (and the session wasn't stopped since).
|
|
443
|
+
WORKER_START_GRACE = 30.0
|
|
444
|
+
|
|
445
|
+
@spawns = {}
|
|
446
|
+
@spawns_mutex = Mutex.new
|
|
447
|
+
|
|
448
|
+
# A worker this process spawned for the session within
|
|
449
|
+
# WORKER_START_GRACE is still running, and no stop came since.
|
|
450
|
+
private_class_method def self.still_starting?(session_id, state_dir:)
|
|
451
|
+
return false if Session.stopped_marker?(session_id, state_dir: state_dir)
|
|
452
|
+
|
|
453
|
+
at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
|
|
454
|
+
return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
|
|
455
|
+
|
|
456
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
private_class_method def self.note_spawn(session_id, state_dir:, waiter:)
|
|
460
|
+
@spawns_mutex.synchronize do
|
|
461
|
+
@spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
|
|
462
|
+
@spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Forget the spawns #still_starting? remembers (specs).
|
|
467
|
+
def self.forget_spawns
|
|
468
|
+
@spawns_mutex.synchronize { @spawns.clear }
|
|
469
|
+
end
|
|
470
|
+
|
|
419
471
|
# Read any new output files from a session directory.
|
|
420
472
|
# Optionally filters to only files newer than `since_time`.
|
|
421
473
|
def self.read_responses(session_id, since_time: nil, state_dir: nil)
|
|
@@ -423,6 +475,85 @@ module Samagotchi
|
|
|
423
475
|
since_time: since_time)
|
|
424
476
|
end
|
|
425
477
|
|
|
478
|
+
# A restart that can't happen now. reason: :not_running (no live worker:
|
|
479
|
+
# the next prompt starts one on the newest chi anyway), :unsupported (a
|
|
480
|
+
# worker from before restarts, running +version+: stop it instead), a
|
|
481
|
+
# WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
|
|
482
|
+
# or :failed (the worker answered something else).
|
|
483
|
+
class RestartRefused < StandardError
|
|
484
|
+
attr_reader :session_id, :reason, :version
|
|
485
|
+
|
|
486
|
+
def initialize(session_id, reason, version: nil)
|
|
487
|
+
@session_id = session_id
|
|
488
|
+
@reason = reason.to_sym
|
|
489
|
+
@version = version
|
|
490
|
+
super(RestartRefused.words(session_id, @reason, version))
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
HOLDS = {
|
|
494
|
+
starting: "its worker is still starting",
|
|
495
|
+
turn_running: "a turn is running",
|
|
496
|
+
input_queued: "a prompt is queued",
|
|
497
|
+
continue_offered: "the step-limit question waits for an answer",
|
|
498
|
+
question_pending: "a question or approval waits for an answer",
|
|
499
|
+
reminders: "it has reminders, which a restart would drop",
|
|
500
|
+
command_running: "a /btw (or another command) is still running",
|
|
501
|
+
relays_open: "a delegate's approval relayed here is open or just answered",
|
|
502
|
+
background_tasks: "background tasks it started are still running"
|
|
503
|
+
}.freeze
|
|
504
|
+
|
|
505
|
+
# What the CLI and the web say.
|
|
506
|
+
def self.words(session_id, reason, version = nil)
|
|
507
|
+
case reason
|
|
508
|
+
when :not_running
|
|
509
|
+
"session #{session_id} has no running worker; the next prompt starts one on the newest chi"
|
|
510
|
+
when :unsupported
|
|
511
|
+
"session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
|
|
512
|
+
"chi sessions stop #{session_id}, then any prompt starts it on the newest chi"
|
|
513
|
+
when :failed then "session #{session_id}'s worker didn't take the restart"
|
|
514
|
+
else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# What a restart did: the worker's version before and after (nil when
|
|
520
|
+
# the new worker hadn't published its Bridge within the wait).
|
|
521
|
+
Restarted = Struct.new(:session_id, :from_version, :version, keyword_init: true)
|
|
522
|
+
|
|
523
|
+
RESTART_WAIT = 10.0
|
|
524
|
+
|
|
525
|
+
# Hand a session to a new worker on the newest chi installed: ask its
|
|
526
|
+
# worker (POST /exit restart: true), which leaves only when nothing would
|
|
527
|
+
# be lost and starts its successor (Worker#exit_request), then wait up to
|
|
528
|
+
# +wait+ seconds for the successor's Bridge. Web tabs and attached
|
|
529
|
+
# terminals reconnect to it.
|
|
530
|
+
# @return [Restarted]
|
|
531
|
+
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
532
|
+
# @raise [RestartRefused]
|
|
533
|
+
# @raise [ArgumentError] no such session
|
|
534
|
+
def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: "cli:restart")
|
|
535
|
+
sd = state_dir || Session.default_state_dir
|
|
536
|
+
Session.load(session_id, state_dir: sd) # ArgumentError for none
|
|
537
|
+
owner = refuse_tui!(session_id, state_dir: sd)
|
|
538
|
+
dir = Session.session_dir(session_id, state_dir: sd)
|
|
539
|
+
sidecar = owner&.worker? && WorkerSidecar.live(dir, unlink: false)
|
|
540
|
+
raise RestartRefused.new(session_id, :not_running) unless sidecar
|
|
541
|
+
unless sidecar.features.include?("restart")
|
|
542
|
+
raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
reply = BridgeClient.new(session_id: session_id, port: sidecar.port).request_exit(client_id: client_id, restart: true)
|
|
546
|
+
raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
|
|
547
|
+
raise RestartRefused.new(session_id, :failed) unless reply.status == 200
|
|
548
|
+
|
|
549
|
+
Log.info(:worker, "restart", sid: session_id, from: sidecar.version, by: client_id)
|
|
550
|
+
fresh = BridgeClient.poll(wait, interval: 0.1) do
|
|
551
|
+
found = WorkerSidecar.live(dir, unlink: false)
|
|
552
|
+
found if found && found.started_at != sidecar.started_at
|
|
553
|
+
end
|
|
554
|
+
Restarted.new(session_id: session_id, from_version: sidecar.version, version: fresh&.version)
|
|
555
|
+
end
|
|
556
|
+
|
|
426
557
|
# Stop a session by sending TERM to its process.
|
|
427
558
|
# With +wait+ (seconds), also wait for the owner to let go of the session,
|
|
428
559
|
# so a resume right after spawns a fresh worker instead of finding the
|
|
@@ -638,7 +769,7 @@ module Samagotchi
|
|
|
638
769
|
# @param poll_interval [Numeric, nil] seconds between the loop's fallback
|
|
639
770
|
# ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
|
|
640
771
|
# A stopped session's worker exits 0 and a crashed one 1, there.
|
|
641
|
-
# @return [Symbol] :idle_exit or :
|
|
772
|
+
# @return [Symbol] :idle_exit, :exit_requested or :restart
|
|
642
773
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
643
774
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
644
775
|
# A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
|
|
@@ -652,7 +783,10 @@ module Samagotchi
|
|
|
652
783
|
# Kept in a class ivar so the lock's File lives as long as the worker.
|
|
653
784
|
@owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
|
|
654
785
|
exit(0) unless @owner_lock
|
|
655
|
-
|
|
786
|
+
# Set by the boot string when the newest installed chi didn't load.
|
|
787
|
+
fallback = ENV.delete(BOOT_FALLBACK_ENV)
|
|
788
|
+
Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
|
|
789
|
+
Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
|
|
656
790
|
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
657
791
|
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
|
|
658
792
|
result = begin
|
|
@@ -672,7 +806,10 @@ module Samagotchi
|
|
|
672
806
|
# Only after the release: a writer that still saw this worker as the
|
|
673
807
|
# owner may have queued input since the last check. Either it finds no
|
|
674
808
|
# owner after its write and wakes one, or this finds its input.
|
|
675
|
-
|
|
809
|
+
# A restart starts its successor here, on the newest chi installed
|
|
810
|
+
# (worker_command), once this one's lock is free.
|
|
811
|
+
if result == :restart ||
|
|
812
|
+
(%i[idle_exit exit_requested].include?(result) && !SessionInbox.find_new_input_files(session_dir).empty?)
|
|
676
813
|
resume_session(session_id, state_dir: sd)
|
|
677
814
|
elsif worker.discard?
|
|
678
815
|
discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
|
|
@@ -707,16 +844,31 @@ module Samagotchi
|
|
|
707
844
|
session.save(state_dir: state_dir)
|
|
708
845
|
end
|
|
709
846
|
|
|
710
|
-
# The worker's command line
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
847
|
+
# The worker's command line. Run from an installed gem, the worker
|
|
848
|
+
# activates the newest installed samagotchi (`>= 0.a`, prereleases
|
|
849
|
+
# count), whoever spawns it: an old chi web, chi send or --attach then
|
|
850
|
+
# starts new workers on the newest chi rather than on its own. Activating
|
|
851
|
+
# the gem also makes its dependencies resolve as its gemspec pins them
|
|
852
|
+
# (reline ~> 0.6.3). Should that fail (a broken newest install), the
|
|
853
|
+
# worker falls back to the spawner's own version and logs why.
|
|
854
|
+
#
|
|
855
|
+
# A source checkout (bin/chi, bundle exec; InstalledGem.spec is nil
|
|
856
|
+
# there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
|
|
857
|
+
#
|
|
858
|
+
# CROSS-VERSION CONTRACT: this boot string, run_session_loop(id,
|
|
859
|
+
# state_dir:), the env keys spawn_options sets and the input/ file
|
|
860
|
+
# format are read by a NEWER chi than the one writing them. Change them
|
|
861
|
+
# only additively (spec/worker_contract_spec.rb).
|
|
862
|
+
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
863
|
+
|
|
715
864
|
def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
|
|
716
865
|
boot = "require 'samagotchi/session_manager'; " \
|
|
717
866
|
"Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
|
|
718
|
-
|
|
719
|
-
|
|
867
|
+
return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
|
|
868
|
+
|
|
869
|
+
activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
|
|
870
|
+
"ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
|
|
871
|
+
[RbConfig.ruby, "-e", "#{activate}; #{boot}"]
|
|
720
872
|
end
|
|
721
873
|
|
|
722
874
|
private_class_method def self.spawn_worker_for_session(session, state_dir:)
|
|
@@ -729,6 +881,8 @@ module Samagotchi
|
|
|
729
881
|
env = opts.delete(:env)
|
|
730
882
|
command = worker_command(session.id, state_dir: state_dir)
|
|
731
883
|
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
884
|
+
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
885
|
+
note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
|
|
732
886
|
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
733
887
|
pid
|
|
734
888
|
end
|
|
@@ -831,7 +985,8 @@ module Samagotchi
|
|
|
831
985
|
raise OwnedByTUI, session_id
|
|
832
986
|
end
|
|
833
987
|
# A worker that idle-exited since the resume never reads it either:
|
|
834
|
-
# wake a new one. (The exiting worker also looks for input it left
|
|
988
|
+
# wake a new one. (The exiting worker also looks for input it left;
|
|
989
|
+
# one the first resume spawned that is still starting is left be.)
|
|
835
990
|
manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
|
|
836
991
|
{ status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
|
|
837
992
|
end
|
|
@@ -296,7 +296,8 @@ module Samagotchi
|
|
|
296
296
|
retries: totals[:retries] + (turn&.retries || 0),
|
|
297
297
|
started_at: @started_at,
|
|
298
298
|
last_activity_at: @last_activity_at,
|
|
299
|
-
|
|
299
|
+
# From the session's first start (an earlier process's too), to now.
|
|
300
|
+
session_duration_ms: ms_since(@started_at),
|
|
300
301
|
**records_view(records_mode),
|
|
301
302
|
active_turn: active_turn_snapshot,
|
|
302
303
|
active_tools: active_tool_snapshots
|
|
@@ -327,7 +328,6 @@ module Samagotchi
|
|
|
327
328
|
load_persisted
|
|
328
329
|
end
|
|
329
330
|
@started_at ||= now.iso8601(3)
|
|
330
|
-
@session_started_monotonic ||= monotonic_time
|
|
331
331
|
@turn = TurnState.new(
|
|
332
332
|
session_id: @session_id,
|
|
333
333
|
iteration_count: 0,
|
|
@@ -364,6 +364,10 @@ module Samagotchi
|
|
|
364
364
|
# A turn that failed mid-stream never saw its generation end:
|
|
365
365
|
# what it got to still counts.
|
|
366
366
|
close_generation if @turn.gen_open
|
|
367
|
+
# A call still running when the turn ends (a cancel, a failure)
|
|
368
|
+
# never completes: its record says so, as the count already has it.
|
|
369
|
+
@turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, "canceled") }
|
|
370
|
+
@turn.tool_calls_by_id.clear
|
|
367
371
|
finished_at = now
|
|
368
372
|
record = {
|
|
369
373
|
id: @turn.id,
|
|
@@ -486,20 +490,24 @@ module Samagotchi
|
|
|
486
490
|
active = @turn.tool_calls_by_id.delete(key)
|
|
487
491
|
return unless active
|
|
488
492
|
|
|
489
|
-
|
|
490
|
-
record = active.slice(
|
|
491
|
-
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
492
|
-
).merge(
|
|
493
|
-
status: status.empty? ? "ok" : status,
|
|
494
|
-
completed_at: finished_at.iso8601(3),
|
|
495
|
-
# Less a guardrail approval wait (ToolRunner's waited_ms).
|
|
496
|
-
duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
|
|
497
|
-
)
|
|
498
|
-
@tool_records << record
|
|
499
|
-
count_tool(record)
|
|
493
|
+
finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
|
|
500
494
|
end
|
|
501
495
|
end
|
|
502
496
|
|
|
497
|
+
# Caller holds the mutex.
|
|
498
|
+
def finish_tool_record(active, status, waited_ms: 0)
|
|
499
|
+
record = active.slice(
|
|
500
|
+
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
501
|
+
).merge(
|
|
502
|
+
status: status,
|
|
503
|
+
completed_at: now.iso8601(3),
|
|
504
|
+
# Less a guardrail approval wait (ToolRunner's waited_ms).
|
|
505
|
+
duration_ms: [elapsed_ms(active[:started_monotonic]) - waited_ms, 0].max
|
|
506
|
+
)
|
|
507
|
+
@tool_records << record
|
|
508
|
+
count_tool(record)
|
|
509
|
+
end
|
|
510
|
+
|
|
503
511
|
# Accumulate token counts from a streamed generation_chunk payload.
|
|
504
512
|
# Server-first: prefer real timings/usage. Prompt and completion tokens
|
|
505
513
|
# are tracked as a per-generation MAX (the server's counts are cumulative
|
|
@@ -558,6 +566,15 @@ module Samagotchi
|
|
|
558
566
|
[((monotonic_time - started) * 1000.0).round, 0].max
|
|
559
567
|
end
|
|
560
568
|
|
|
569
|
+
# Wall-clock ms from an ISO 8601 stamp to now (0 without one).
|
|
570
|
+
def ms_since(timestamp)
|
|
571
|
+
return 0 unless timestamp
|
|
572
|
+
|
|
573
|
+
[((now - Time.iso8601(timestamp.to_s)) * 1000.0).round, 0].max
|
|
574
|
+
rescue ArgumentError
|
|
575
|
+
0
|
|
576
|
+
end
|
|
577
|
+
|
|
561
578
|
def tool_key(iteration, call_index)
|
|
562
579
|
"#{iteration}:#{call_index}"
|
|
563
580
|
end
|
|
@@ -609,6 +626,12 @@ module Samagotchi
|
|
|
609
626
|
@tool_records.each { |record| count_tool(record) }
|
|
610
627
|
@started_at = earliest_timestamp(prior["started_at"], @started_at)
|
|
611
628
|
@last_activity_at ||= prior["last_activity_at"]
|
|
629
|
+
# The served model last reported, with the name asked for then (the
|
|
630
|
+
# Engine shows it only while that name is still the one asked for).
|
|
631
|
+
if @served_model.nil? && prior["served_model"]
|
|
632
|
+
@served_model = prior["served_model"]
|
|
633
|
+
@served_model_for = prior["served_model_for"]
|
|
634
|
+
end
|
|
612
635
|
# The window last seen, until this process's first generation reports.
|
|
613
636
|
window = prior["context"].is_a?(Hash) ? prior["context"] : {}
|
|
614
637
|
if window["window_tokens"] && @context_window_tokens.nil?
|
|
@@ -13,7 +13,7 @@ require_relative "cli/command"
|
|
|
13
13
|
require_relative "cli/flags"
|
|
14
14
|
|
|
15
15
|
module Samagotchi
|
|
16
|
-
# `chi sessions`: list, stop, archive/unarchive, delete, prune and clean
|
|
16
|
+
# `chi sessions`: list, stop, restart, archive/unarchive, delete, prune and clean
|
|
17
17
|
# sessions from the shell (bin/chi dispatches here before OptionParser;
|
|
18
18
|
# the subcommands have their own flags). Not SessionCommands, the REPL's
|
|
19
19
|
# slash commands for sessions (session_commands.rb).
|
|
@@ -21,7 +21,7 @@ module Samagotchi
|
|
|
21
21
|
include CLI::Command
|
|
22
22
|
|
|
23
23
|
USAGE = <<~TEXT
|
|
24
|
-
Usage: chi sessions <list|stop|archive|unarchive|delete|prune|clean> [options]
|
|
24
|
+
Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean> [options]
|
|
25
25
|
list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
|
|
26
26
|
[--live] [--cwd PATH] [--format text|json|tsv] [--archived]
|
|
27
27
|
--live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
|
|
@@ -29,6 +29,7 @@ module Samagotchi
|
|
|
29
29
|
[--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
|
|
30
30
|
--archived: archived sessions too (marked [archived]; json: archived: true)
|
|
31
31
|
stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
|
|
32
|
+
restart ID... # hand each session to a new worker on the newest chi installed; refused while a turn, question or reminder would be lost
|
|
32
33
|
archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
|
|
33
34
|
delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
|
|
34
35
|
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
@@ -37,7 +38,8 @@ module Samagotchi
|
|
|
37
38
|
TEXT
|
|
38
39
|
|
|
39
40
|
STOP_USAGE = "Usage: chi sessions stop ID...\n"
|
|
40
|
-
|
|
41
|
+
RESTART_USAGE = "Usage: chi sessions restart ID...\n"
|
|
42
|
+
SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean].freeze
|
|
41
43
|
|
|
42
44
|
# Each subcommand's flags ("--flag V" or "--flag=V" for a value): an
|
|
43
45
|
# unknown flag, a value flag with nothing after it or an argument where
|
|
@@ -50,6 +52,7 @@ module Samagotchi
|
|
|
50
52
|
f.switch "--archived", key: :include_archived
|
|
51
53
|
end,
|
|
52
54
|
"stop" => CLI::Flags.new,
|
|
55
|
+
"restart" => CLI::Flags.new,
|
|
53
56
|
"prune" => CLI::Flags.new(args: false) do |f|
|
|
54
57
|
%w[--days --keep --keep-status].each { |name| f.value name }
|
|
55
58
|
f.switch "--dry-run"
|
|
@@ -94,6 +97,7 @@ module Samagotchi
|
|
|
94
97
|
case @sub
|
|
95
98
|
when "list" then list
|
|
96
99
|
when "stop" then stop(parsed.args)
|
|
100
|
+
when "restart" then restart(parsed.args)
|
|
97
101
|
else prune(@sub)
|
|
98
102
|
end
|
|
99
103
|
end
|
|
@@ -101,7 +105,7 @@ module Samagotchi
|
|
|
101
105
|
private
|
|
102
106
|
|
|
103
107
|
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
|
|
104
|
-
def usage_text =
|
|
108
|
+
def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE }.fetch(@sub, USAGE)
|
|
105
109
|
|
|
106
110
|
# quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
|
|
107
111
|
def integers(options)
|
|
@@ -257,6 +261,41 @@ module Samagotchi
|
|
|
257
261
|
ok.all? ? 0 : 1
|
|
258
262
|
end
|
|
259
263
|
|
|
264
|
+
# Each session in turn: its worker hands over to a new one on the newest
|
|
265
|
+
# chi (SessionManager.restart_session), or says why not.
|
|
266
|
+
def restart(ids)
|
|
267
|
+
return usage_error("give session ids") if ids.empty?
|
|
268
|
+
|
|
269
|
+
ok = ids.uniq.map do |given|
|
|
270
|
+
begin
|
|
271
|
+
id = Samagotchi::Session.resolve_id(given)
|
|
272
|
+
result = Samagotchi::SessionManager.restart_session(id)
|
|
273
|
+
rescue Samagotchi::SessionManager::OwnedByTUI
|
|
274
|
+
@stdout.flush
|
|
275
|
+
@stderr.puts "session #{id} is open in a chi REPL; close it there first"
|
|
276
|
+
next false
|
|
277
|
+
rescue Samagotchi::SessionManager::RestartRefused => e
|
|
278
|
+
@stdout.flush
|
|
279
|
+
@stderr.puts e.reason == :not_running || e.reason == :unsupported ? e.message : "session #{id}: #{e.message}"
|
|
280
|
+
next false
|
|
281
|
+
rescue ArgumentError => e
|
|
282
|
+
@stdout.flush
|
|
283
|
+
@stderr.puts e.message
|
|
284
|
+
next false
|
|
285
|
+
end
|
|
286
|
+
@stdout.puts restarted_line(result)
|
|
287
|
+
true
|
|
288
|
+
end
|
|
289
|
+
ok.all? ? 0 : 1
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def restarted_line(result)
|
|
293
|
+
return "Restarting session #{result.session_id}; its new worker is still starting." unless result.version
|
|
294
|
+
|
|
295
|
+
from = result.from_version && result.from_version != result.version ? " (was #{result.from_version})" : ""
|
|
296
|
+
"Restarted session #{result.session_id} on chi #{result.version}#{from}."
|
|
297
|
+
end
|
|
298
|
+
|
|
260
299
|
def prune(sub)
|
|
261
300
|
days, keep, keep_status, dry_run, test_only = @opts.values_at(:days, :keep, :keep_status, :dry_run, :test_only)
|
|
262
301
|
test_only = true if sub == "clean" && !@opts[:all]
|
|
@@ -310,6 +310,15 @@ module Samagotchi
|
|
|
310
310
|
end
|
|
311
311
|
end
|
|
312
312
|
|
|
313
|
+
# Once per name for this prompt (the Engine's, one per process): a
|
|
314
|
+
# rebuild (another level, a /model switch) doesn't repeat it.
|
|
315
|
+
def warn_preload_failed(name, echo)
|
|
316
|
+
@warned_preloads ||= Set.new
|
|
317
|
+
return unless @warned_preloads.add?(name)
|
|
318
|
+
|
|
319
|
+
Log.warn(:memory, "preload_failed", echo: echo, memory: name)
|
|
320
|
+
end
|
|
321
|
+
|
|
313
322
|
def explicit_memory_section
|
|
314
323
|
return nil if @requested_memories.empty?
|
|
315
324
|
|
|
@@ -322,7 +331,7 @@ module Samagotchi
|
|
|
322
331
|
body = Tools::MemoryRead.call(actual_name, scope: scope, **overlay_keys)
|
|
323
332
|
if body.start_with?("Error:")
|
|
324
333
|
source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
|
|
325
|
-
|
|
334
|
+
warn_preload_failed(name, "Warning: #{source} could not be loaded (#{body})")
|
|
326
335
|
next
|
|
327
336
|
end
|
|
328
337
|
# Record activated names so the UI can echo them in the sticky
|
|
@@ -11,6 +11,7 @@ require_relative "image_input"
|
|
|
11
11
|
require_relative "line_reader"
|
|
12
12
|
require_relative "question_prompt"
|
|
13
13
|
require_relative "reline_seam"
|
|
14
|
+
require_relative "version_lines"
|
|
14
15
|
require_relative "../bridge/turn_accumulator"
|
|
15
16
|
require_relative "../bridge_client"
|
|
16
17
|
require_relative "../log"
|
|
@@ -92,8 +93,10 @@ module Samagotchi
|
|
|
92
93
|
default_input: false, wait_at_eof: false, parent_answers: false,
|
|
93
94
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
94
95
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
95
|
-
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) }
|
|
96
|
+
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
97
|
+
installed_version: -> { InstalledVersions.new.newest })
|
|
96
98
|
@client = client
|
|
99
|
+
@installed_version = installed_version
|
|
97
100
|
@delete_session = delete_session
|
|
98
101
|
@archive_session = archive_session
|
|
99
102
|
@screen = screen
|
|
@@ -108,6 +111,8 @@ module Samagotchi
|
|
|
108
111
|
@running = false
|
|
109
112
|
@joined_mid_turn = false
|
|
110
113
|
@attached = false
|
|
114
|
+
# The stream moved to a new worker; its snapshot resyncs (#switch_worker).
|
|
115
|
+
@worker_changed = false
|
|
111
116
|
@early_lines = []
|
|
112
117
|
@question = nil
|
|
113
118
|
@answered_ids = Set.new
|
|
@@ -158,7 +163,12 @@ module Samagotchi
|
|
|
158
163
|
RelineSeam.interrupt_handler = method(:note_interrupted_line)
|
|
159
164
|
# Tagged, so the worker doesn't count this stream as another UI
|
|
160
165
|
# when this UI asks it to exit.
|
|
161
|
-
|
|
166
|
+
# A new worker (a restart) is followed, not lost: its sidecar is
|
|
167
|
+
# looked for whenever the stream drops.
|
|
168
|
+
stream = @client.follow(client_id: @client_id, rediscover: method(:live_worker),
|
|
169
|
+
reconnect_delays: BridgeClient::EventStream::REDISCOVER_DELAYS) do |event|
|
|
170
|
+
queue << [:event, event]
|
|
171
|
+
end
|
|
162
172
|
@reader = LineReader.new(queue, prompt: method(:prompt_text), read: input || method(:read_input_line),
|
|
163
173
|
prefill: default_input_text).start
|
|
164
174
|
loop do
|
|
@@ -280,6 +290,7 @@ module Samagotchi
|
|
|
280
290
|
when :generation_completed
|
|
281
291
|
@status.take_event(event)
|
|
282
292
|
@renderer.call(event)
|
|
293
|
+
when :worker_changed then switch_worker(event)
|
|
283
294
|
when :stream_closed
|
|
284
295
|
@view.finish_thinking_spinner
|
|
285
296
|
@screen.commit("Lost the session's worker (#{event[:reason]}). " \
|
|
@@ -292,6 +303,32 @@ module Samagotchi
|
|
|
292
303
|
|
|
293
304
|
private
|
|
294
305
|
|
|
306
|
+
# The newest chi installed, looked at once, at the join.
|
|
307
|
+
def installed_version
|
|
308
|
+
@installed_version.call
|
|
309
|
+
rescue StandardError
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The session's live worker for the stream to follow (EventStream's
|
|
314
|
+
# rediscover): nil while there is none yet, :gone once the session was
|
|
315
|
+
# stopped (no worker is coming).
|
|
316
|
+
def live_worker
|
|
317
|
+
id = @client.session_id
|
|
318
|
+
return :gone if Session.stopped_marker?(id)
|
|
319
|
+
|
|
320
|
+
BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The stream moved to a new worker (a restart): requests go there too.
|
|
324
|
+
def switch_worker(event)
|
|
325
|
+
@client = BridgeClient.new(session_id: @client.session_id, port: event[:port], host: @client.host)
|
|
326
|
+
@worker_changed = true
|
|
327
|
+
@view.finish_thinking_spinner
|
|
328
|
+
Log.info(:attached, "worker_changed", port: event[:port])
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
295
332
|
def next_item(queue)
|
|
296
333
|
queue.pop
|
|
297
334
|
rescue Interrupt
|
|
@@ -884,8 +921,13 @@ module Samagotchi
|
|
|
884
921
|
|
|
885
922
|
def render_snapshot(snapshot, reset:)
|
|
886
923
|
@view.finish_thinking_spinner
|
|
924
|
+
# A new worker's first snapshot is the session as it now stands.
|
|
925
|
+
moved = @worker_changed
|
|
926
|
+
reset ||= moved
|
|
927
|
+
@worker_changed = false
|
|
887
928
|
if @attached
|
|
888
|
-
@screen.commit("(resynced with the session)") if reset
|
|
929
|
+
@screen.commit("(resynced with the session)") if reset && !moved
|
|
930
|
+
@screen.commit(paint(VersionLines.restarted(snapshot[:chi_version]), 2)) if moved
|
|
889
931
|
else
|
|
890
932
|
@attached = true
|
|
891
933
|
# The recap first: what the session was about, then where it stopped.
|
|
@@ -894,6 +936,9 @@ module Samagotchi
|
|
|
894
936
|
render_join_header(Array(snapshot[:messages]))
|
|
895
937
|
@screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
|
|
896
938
|
@screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
|
|
939
|
+
version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
|
|
940
|
+
session_id: @client.session_id)
|
|
941
|
+
@screen.commit(paint(version_line, 2)) if version_line
|
|
897
942
|
end
|
|
898
943
|
if snapshot[:commands]
|
|
899
944
|
@command_registry = Commands::Registry.from_listing(snapshot[:commands], base: SessionCommands.builtin_registry)
|
|
@@ -9,20 +9,27 @@ module Samagotchi
|
|
|
9
9
|
# stays as typed; a token that isn't an image (a source file, a missing
|
|
10
10
|
# path, an email address) is just text.
|
|
11
11
|
#
|
|
12
|
-
# @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png'
|
|
12
|
+
# @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png' @my\ shot.png
|
|
13
|
+
#
|
|
14
|
+
# A bare path takes backslash escapes the way a shell does (what a
|
|
15
|
+
# Finder drag into the terminal types): `\ ` is a space, `\(` a `(`.
|
|
13
16
|
module ImageInput
|
|
14
|
-
# An @ at the start or after whitespace, then a quoted or bare path
|
|
15
|
-
|
|
17
|
+
# An @ at the start or after whitespace, then a quoted or bare path
|
|
18
|
+
# (a bare one runs on through backslash-escaped whitespace).
|
|
19
|
+
TOKEN_RE = /(?:\A|(?<=\s))@(?:"([^"]+)"|'([^']+)'|((?:\\\s|\S)+))/
|
|
16
20
|
|
|
17
21
|
# @return [Array<Hash>] {path:} per image, in order, each path once
|
|
18
22
|
def self.extract(text, cwd: Dir.pwd)
|
|
19
23
|
text.to_s.scan(TOKEN_RE).filter_map do |quoted_double, quoted_single, bare|
|
|
20
|
-
raw = quoted_double || quoted_single || bare.sub(/[,.;:!?)\]]+\z/, "")
|
|
24
|
+
raw = quoted_double || quoted_single || unescape(bare.sub(/(?<!\\)[,.;:!?)\]]+\z/, ""))
|
|
21
25
|
path = expand(raw, cwd)
|
|
22
26
|
path if path && ImageStore.image_file?(path)
|
|
23
27
|
end.uniq.map { |path| { path: path } }
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
def self.unescape(bare) = bare.gsub(/\\(.)/m, '\\1')
|
|
31
|
+
private_class_method :unescape
|
|
32
|
+
|
|
26
33
|
def self.expand(raw, cwd)
|
|
27
34
|
return nil if raw.empty?
|
|
28
35
|
|