samagotchi 0.7.0 → 0.8.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 +59 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +6 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos.rb +2 -3
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +9 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +13 -1
|
@@ -6,12 +6,15 @@ require "json"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require "rbconfig"
|
|
9
|
+
require_relative "atomic_file"
|
|
9
10
|
|
|
10
11
|
require_relative "session"
|
|
12
|
+
require_relative "session_inbox"
|
|
11
13
|
require_relative "session_metrics"
|
|
12
14
|
require_relative "turn_note"
|
|
13
15
|
require_relative "owner_lock"
|
|
14
16
|
require_relative "bridge_client"
|
|
17
|
+
require_relative "worker_sidecar"
|
|
15
18
|
require_relative "log"
|
|
16
19
|
require_relative "log_path"
|
|
17
20
|
require_relative "installed_gem"
|
|
@@ -29,46 +32,27 @@ module Samagotchi
|
|
|
29
32
|
# The session itself (messages + metadata) is always saved by Session at
|
|
30
33
|
# <sessions dir>/<session_id>.json, for foreground chats too. A sibling
|
|
31
34
|
# directory holds the owner lock (any owner, TUI included) and, for
|
|
32
|
-
# background workers, their IPC files
|
|
35
|
+
# background workers, their IPC files (input/, notes/ and output/ are
|
|
36
|
+
# SessionInbox's):
|
|
33
37
|
# ~/.local/state/samagotchi/sessions/
|
|
34
38
|
# ├── <session_id>.json # the session (Session#save)
|
|
35
39
|
# └── <session_id>/
|
|
36
40
|
# ├── owner.lock # flock held by the session's one owner (OwnerLock)
|
|
37
41
|
# ├── input/ # clients (web/terminal UI) write messages here
|
|
38
42
|
# │ └── <timestamp>.json # one file per user message: {prompt, client_id,
|
|
39
|
-
# │ # enqueued_id
|
|
43
|
+
# │ # enqueued_id, no_interrupt?, images?}
|
|
40
44
|
# ├── notes/ # context notes: background text the worker adds to
|
|
41
45
|
# │ └── <ts>-<rand>.json # the conversation between turns, never a turn
|
|
42
46
|
# │ # ({text, source, from_session?, from_cwd?, created_at})
|
|
43
47
|
# ├── output/ # agent writes responses here
|
|
44
48
|
# │ └── <timestamp>.txt # one file per agent response
|
|
45
|
-
# ├── pid # PID of the owner, written by the owner itself
|
|
46
49
|
# └── bridge.json # Bridge sidecar (how clients reach the worker)
|
|
47
50
|
# Needed only at call time (run_session_loop); worker.rb requires this file.
|
|
48
51
|
autoload :Worker, File.expand_path("worker", __dir__)
|
|
52
|
+
# session_retention requires this file.
|
|
53
|
+
autoload :SessionRetention, File.expand_path("session_retention", __dir__)
|
|
49
54
|
|
|
50
55
|
class SessionManager
|
|
51
|
-
INPUT_DIR = "input"
|
|
52
|
-
# Context notes live apart from input/, so nothing that reads input/
|
|
53
|
-
# (the mid-turn drain, the idle-exit hold, the Waker) ever sees one.
|
|
54
|
-
NOTES_DIR = "notes"
|
|
55
|
-
NOTE_MAX_BYTES = 16 * 1024
|
|
56
|
-
OUTPUT_DIR = "output"
|
|
57
|
-
PID_FILE = "pid"
|
|
58
|
-
# Input-file format this worker reads, advertised in the Bridge sidecar:
|
|
59
|
-
# 2 JSON with the sender's ids (and plain text)
|
|
60
|
-
# 3 images: refs too
|
|
61
|
-
# A worker that doesn't advertise one reads only .txt, and such workers
|
|
62
|
-
# never exit.
|
|
63
|
-
INPUT_FORMAT = 3
|
|
64
|
-
STRUCTURED_INPUT_FORMAT = 2
|
|
65
|
-
IMAGES_INPUT_FORMAT = 3
|
|
66
|
-
|
|
67
|
-
# A turn with images for a worker older than IMAGES_INPUT_FORMAT, which
|
|
68
|
-
# would drop them.
|
|
69
|
-
class ImagesUnsupported < StandardError
|
|
70
|
-
def initialize(msg = "this session's worker predates images: restart it (/exit, then resume)") = super
|
|
71
|
-
end
|
|
72
56
|
# Origin of the synthetic turn queued when reminders are due.
|
|
73
57
|
REMINDER_CLIENT_ID = "system:reminder"
|
|
74
58
|
|
|
@@ -85,10 +69,6 @@ module Samagotchi
|
|
|
85
69
|
end
|
|
86
70
|
end
|
|
87
71
|
|
|
88
|
-
# A note (or a `chi send` message) that can't go in: empty, or over
|
|
89
|
-
# NOTE_MAX_BYTES.
|
|
90
|
-
class NoteRejected < ArgumentError; end
|
|
91
|
-
|
|
92
72
|
# A delete that would pull the session from under its live worker.
|
|
93
73
|
# reason: :worker_running (not asked to stop it) or :still_stopping
|
|
94
74
|
# (stopped, but the worker outlived the wait).
|
|
@@ -222,7 +202,6 @@ module Samagotchi
|
|
|
222
202
|
offset: offset, project_root: project_root, include_archived: include_archived)
|
|
223
203
|
end
|
|
224
204
|
|
|
225
|
-
# Prune sessions per retention policy. Delegates to Session.prune with live-worker guard.
|
|
226
205
|
SUMMARY_DESC_LIMIT = 60
|
|
227
206
|
|
|
228
207
|
# Short summaries of sessions, newest first: the picker behind
|
|
@@ -322,7 +301,7 @@ module Samagotchi
|
|
|
322
301
|
|
|
323
302
|
private_class_method def self.archive_target(id_or_prefix, state_dir)
|
|
324
303
|
given = id_or_prefix.to_s
|
|
325
|
-
raise ArgumentError, "no session #{given}" unless
|
|
304
|
+
raise ArgumentError, "no session #{given}" unless Session.valid_id?(given)
|
|
326
305
|
|
|
327
306
|
id = Session.resolve_id(given, state_dir: state_dir)
|
|
328
307
|
raise ArgumentError, "no session #{given}" unless Session.exist?(id, state_dir: state_dir)
|
|
@@ -385,107 +364,9 @@ module Samagotchi
|
|
|
385
364
|
dir == root || dir.start_with?("#{root}/")
|
|
386
365
|
end
|
|
387
366
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
days = resolve_retention_days(days)
|
|
392
|
-
max_count = resolve_retention_max_count(max_count)
|
|
393
|
-
keep_status = resolve_retention_keep_status(keep_status)
|
|
394
|
-
discard = discard_empty?
|
|
395
|
-
default_model = discard ? (begin ModelProfile.required_model_name(nil) rescue nil end) : nil
|
|
396
|
-
result = Session.prune(
|
|
397
|
-
state_dir: sd,
|
|
398
|
-
days: days,
|
|
399
|
-
max_count: max_count,
|
|
400
|
-
keep_status: keep_status,
|
|
401
|
-
dry_run: dry_run,
|
|
402
|
-
test_only: test_only,
|
|
403
|
-
any_age: any_age,
|
|
404
|
-
alive_check: ->(sid) { worker_alive_for_session?(sid, state_dir: sd) },
|
|
405
|
-
empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
|
|
406
|
-
)
|
|
407
|
-
result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
|
|
408
|
-
result
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
# How long a session may sit empty before the sweep takes it: its
|
|
412
|
-
# worker (or a REPL) deletes it as it leaves, so the sweep only catches
|
|
413
|
-
# those killed first (a reboot, kill -9).
|
|
414
|
-
EMPTY_GRACE_SECONDS = 3600
|
|
415
|
-
|
|
416
|
-
private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
|
|
417
|
-
path = File.join(state_dir, "#{session_id}#{Session::FILE_EXT}")
|
|
418
|
-
Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
|
|
419
|
-
empty_session?(session_id, state_dir: state_dir, default_model: default_model)
|
|
420
|
-
rescue SystemCallError
|
|
421
|
-
false
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
# Directories with no session file and nothing but the skeleton, nobody
|
|
425
|
-
# owning them: a REPL killed before its first save, or a worker woken
|
|
426
|
-
# just as its session was discarded.
|
|
427
|
-
# @return [Array<String>] their ids
|
|
428
|
-
private_class_method def self.prune_orphan_dirs(state_dir, dry_run:)
|
|
429
|
-
return [] unless Dir.exist?(state_dir)
|
|
430
|
-
|
|
431
|
-
Dir.children(state_dir).filter_map do |name|
|
|
432
|
-
dir = File.join(state_dir, name)
|
|
433
|
-
next unless name.match?(/\A[\w-]+\z/) && File.directory?(dir)
|
|
434
|
-
next if File.exist?(File.join(state_dir, "#{name}#{Session::FILE_EXT}"))
|
|
435
|
-
next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && empty_session_dir?(dir)
|
|
436
|
-
next if session_owner(name, state_dir: state_dir)
|
|
437
|
-
|
|
438
|
-
FileUtils.rm_rf(dir) unless dry_run
|
|
439
|
-
name
|
|
440
|
-
rescue SystemCallError
|
|
441
|
-
nil
|
|
442
|
-
end
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
# Lazy sweep guard: runs prune at most once per RETENTION_SWEEP_INTERVAL_HOURS.
|
|
446
|
-
RETENTION_MARKER = ".last_retention"
|
|
447
|
-
RETENTION_SWEEP_INTERVAL_HOURS = 24
|
|
448
|
-
|
|
449
|
-
def self.retention_sweep_if_due(state_dir: nil)
|
|
450
|
-
sd = state_dir || Session.default_state_dir
|
|
451
|
-
return unless Dir.exist?(sd)
|
|
452
|
-
|
|
453
|
-
hours = Samagotchi::Config.get("session.sweep_interval_hours").to_i
|
|
454
|
-
interval = (hours.positive? ? hours : RETENTION_SWEEP_INTERVAL_HOURS) * 3600
|
|
455
|
-
marker = File.join(sd, RETENTION_MARKER)
|
|
456
|
-
if File.exist?(marker)
|
|
457
|
-
age = Time.now - File.mtime(marker)
|
|
458
|
-
return if age < interval
|
|
459
|
-
end
|
|
460
|
-
result = prune_sessions(state_dir: sd)
|
|
461
|
-
FileUtils.touch(marker)
|
|
462
|
-
if result[:deleted].any?
|
|
463
|
-
Log.info(:worker, "retention_pruned", echo: "[retention] pruned #{result[:deleted].size} sessions (kept #{result[:kept].size})", deleted: result[:deleted].size, kept: result[:kept].size)
|
|
464
|
-
end
|
|
465
|
-
result
|
|
466
|
-
rescue StandardError => e
|
|
467
|
-
Log.warn(:worker, "retention_failed", echo: "[retention] sweep failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
468
|
-
nil
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
# The caller's value (a sessions prune flag) or session.retention_days.
|
|
472
|
-
private_class_method def self.resolve_retention_days(val)
|
|
473
|
-
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
474
|
-
|
|
475
|
-
Samagotchi::Config.get("session.retention_days").to_i
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
private_class_method def self.resolve_retention_max_count(val)
|
|
479
|
-
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
480
|
-
|
|
481
|
-
Samagotchi::Config.get("session.max_count").to_i
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# A comma list of statuses never pruned; "" keeps none.
|
|
485
|
-
private_class_method def self.resolve_retention_keep_status(val)
|
|
486
|
-
raw = !val.nil? && val.to_s.strip != "" ? val.to_s : Samagotchi::Config.get("session.keep_status").to_s
|
|
487
|
-
raw.split(",").map(&:strip).reject(&:empty?)
|
|
488
|
-
end
|
|
367
|
+
# The retention sweep (SessionRetention.sweep_if_due), here for the web
|
|
368
|
+
# app and hub, which take this class as their manager.
|
|
369
|
+
def self.retention_sweep_if_due(state_dir: nil) = SessionRetention.sweep_if_due(state_dir: state_dir)
|
|
489
370
|
|
|
490
371
|
# Ensure an existing session has a live worker process.
|
|
491
372
|
# Returns the loaded session after state reconciliation.
|
|
@@ -508,17 +389,8 @@ module Samagotchi
|
|
|
508
389
|
# Read any new output files from a session directory.
|
|
509
390
|
# Optionally filters to only files newer than `since_time`.
|
|
510
391
|
def self.read_responses(session_id, since_time: nil, state_dir: nil)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
responses = []
|
|
514
|
-
return responses unless Dir.exist?(output_path)
|
|
515
|
-
|
|
516
|
-
Dir.glob(File.join(output_path, "*.txt")).sort.each do |f|
|
|
517
|
-
if since_time.nil? || File.mtime(f) > since_time
|
|
518
|
-
responses << File.read(f)
|
|
519
|
-
end
|
|
520
|
-
end
|
|
521
|
-
responses
|
|
392
|
+
SessionInbox.read_outputs(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir),
|
|
393
|
+
since_time: since_time)
|
|
522
394
|
end
|
|
523
395
|
|
|
524
396
|
# Stop a session by sending TERM to its process.
|
|
@@ -568,10 +440,12 @@ module Samagotchi
|
|
|
568
440
|
|
|
569
441
|
# What a session's directory holds before anything happened in it. Any
|
|
570
442
|
# other entry (or a file in one of the EMPTY_DIRS) is something the
|
|
571
|
-
# session keeps.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
443
|
+
# session keeps. "pid" is the pid file workers wrote until 2026-10-01:
|
|
444
|
+
# nothing writes or reads it now, but a folder from before still has one
|
|
445
|
+
# and is still empty.
|
|
446
|
+
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, "analytics.json"].freeze
|
|
447
|
+
EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
|
|
448
|
+
EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
|
|
575
449
|
|
|
576
450
|
# session.keep_empty off: sessions left with nothing in them are deleted
|
|
577
451
|
# (the worker as it exits, the TUI at /exit, the retention sweep).
|
|
@@ -581,6 +455,29 @@ module Samagotchi
|
|
|
581
455
|
false
|
|
582
456
|
end
|
|
583
457
|
|
|
458
|
+
# Whether a session being left (or found left) is deleted: session.keep_empty
|
|
459
|
+
# is off and nothing happened in it (#empty_session?). The worker as it
|
|
460
|
+
# leaves, the REPL at /exit and the retention sweep all ask this.
|
|
461
|
+
# @param default_model [String, nil] what a new session starts on
|
|
462
|
+
# @param model_name [String, nil] the model its owner runs now (the REPL
|
|
463
|
+
# keeps /model in its Engine); the saved one is checked either way
|
|
464
|
+
# @param used_memory_names [Array<String>] memory its owner's Engine used
|
|
465
|
+
# before any save
|
|
466
|
+
# @param unsaved [Session, nil] the REPL's working copy, judged instead
|
|
467
|
+
# when the session was never saved
|
|
468
|
+
def self.discardable?(session_id, default_model:, state_dir: nil, model_name: default_model, used_memory_names: [],
|
|
469
|
+
unsaved: nil)
|
|
470
|
+
return false unless discard_empty? && model_name == default_model && Array(used_memory_names).empty?
|
|
471
|
+
|
|
472
|
+
sd = state_dir || Session.default_state_dir
|
|
473
|
+
if unsaved && !File.exist?(Session.session_file(session_id, state_dir: sd))
|
|
474
|
+
return no_conversation?(unsaved.messages) && unsaved.last_prompt.to_s.strip.empty? &&
|
|
475
|
+
empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
empty_session?(session_id, state_dir: sd, default_model: default_model)
|
|
479
|
+
end
|
|
480
|
+
|
|
584
481
|
# A session nothing happened in: no conversation, no turn tried (a failed
|
|
585
482
|
# one leaves no messages but a last_prompt and analytics turns), nothing
|
|
586
483
|
# queued or attached, and the model and mode a new session gets. A
|
|
@@ -641,10 +538,10 @@ module Samagotchi
|
|
|
641
538
|
sd = state_dir || Session.default_state_dir
|
|
642
539
|
given = id_or_prefix.to_s
|
|
643
540
|
# Only a plain id: anything else could name a path outside the state dir.
|
|
644
|
-
raise ArgumentError, "no session #{given}" unless
|
|
541
|
+
raise ArgumentError, "no session #{given}" unless Session.valid_id?(given)
|
|
645
542
|
|
|
646
543
|
id = Session.resolve_id(given, state_dir: sd)
|
|
647
|
-
path =
|
|
544
|
+
path = Session.session_file(id, state_dir: sd)
|
|
648
545
|
dir = Session.session_dir(id, state_dir: sd)
|
|
649
546
|
raise ArgumentError, "no session #{given}" unless File.exist?(path) || Dir.exist?(dir)
|
|
650
547
|
|
|
@@ -713,7 +610,6 @@ module Samagotchi
|
|
|
713
610
|
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
714
611
|
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
|
|
715
612
|
result = begin
|
|
716
|
-
File.write(File.join(session_dir, PID_FILE), Process.pid.to_s)
|
|
717
613
|
worker.run
|
|
718
614
|
rescue StandardError, ScriptError => e
|
|
719
615
|
# The worker's stderr is /dev/null: the log is the only trace.
|
|
@@ -726,7 +622,7 @@ module Samagotchi
|
|
|
726
622
|
# Only after the release: a writer that still saw this worker as the
|
|
727
623
|
# owner may have queued input since the last check. Either it finds no
|
|
728
624
|
# owner after its write and wakes one, or this finds its input.
|
|
729
|
-
if %i[idle_exit exit_requested].include?(result) && !find_new_input_files(session_dir).empty?
|
|
625
|
+
if %i[idle_exit exit_requested].include?(result) && !SessionInbox.find_new_input_files(session_dir).empty?
|
|
730
626
|
resume_session(session_id, state_dir: sd)
|
|
731
627
|
elsif worker.discard?
|
|
732
628
|
discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
|
|
@@ -740,7 +636,7 @@ module Samagotchi
|
|
|
740
636
|
# landing between this check and the delete fails with "no session"
|
|
741
637
|
# (a window of an empty session's last moments, left as is).
|
|
742
638
|
private_class_method def self.discard_left_session(session_id, state_dir:, default_model:)
|
|
743
|
-
return unless
|
|
639
|
+
return unless discardable?(session_id, state_dir: state_dir, default_model: default_model)
|
|
744
640
|
|
|
745
641
|
delete_session(session_id, state_dir: state_dir)
|
|
746
642
|
Log.info(:worker, "discarded_empty", sid: session_id)
|
|
@@ -756,8 +652,8 @@ module Samagotchi
|
|
|
756
652
|
|
|
757
653
|
private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
|
|
758
654
|
FileUtils.mkdir_p(session_dir)
|
|
759
|
-
FileUtils.mkdir_p(File.join(session_dir, INPUT_DIR))
|
|
760
|
-
FileUtils.mkdir_p(File.join(session_dir, OUTPUT_DIR))
|
|
655
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
|
|
656
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
|
|
761
657
|
session.save(state_dir: state_dir)
|
|
762
658
|
end
|
|
763
659
|
|
|
@@ -776,13 +672,12 @@ module Samagotchi
|
|
|
776
672
|
private_class_method def self.spawn_worker_for_session(session, state_dir:)
|
|
777
673
|
session_dir = Session.session_dir(session.id, state_dir: state_dir)
|
|
778
674
|
FileUtils.mkdir_p(session_dir)
|
|
779
|
-
FileUtils.mkdir_p(File.join(session_dir, INPUT_DIR))
|
|
780
|
-
FileUtils.mkdir_p(File.join(session_dir, OUTPUT_DIR))
|
|
675
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
|
|
676
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
|
|
781
677
|
|
|
782
678
|
opts = spawn_options(session)
|
|
783
679
|
env = opts.delete(:env)
|
|
784
680
|
command = worker_command(session.id, state_dir: state_dir)
|
|
785
|
-
# The worker writes the pid file itself once it owns the session.
|
|
786
681
|
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
787
682
|
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
788
683
|
pid
|
|
@@ -802,7 +697,7 @@ module Samagotchi
|
|
|
802
697
|
exit_discards: nil)
|
|
803
698
|
require_relative "bridge"
|
|
804
699
|
Samagotchi::Bridge.new(
|
|
805
|
-
engine: engine, state_dir: state_dir, session_id: session_id, input_format: INPUT_FORMAT,
|
|
700
|
+
engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
|
|
806
701
|
on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards
|
|
807
702
|
).start
|
|
808
703
|
rescue StandardError => e
|
|
@@ -810,26 +705,20 @@ module Samagotchi
|
|
|
810
705
|
nil
|
|
811
706
|
end
|
|
812
707
|
|
|
813
|
-
|
|
814
|
-
|
|
815
708
|
# Write a user turn into a session's input directory via the same file IPC
|
|
816
709
|
# the worker polls. Reused by the bridge's POST surface so a turn is
|
|
817
710
|
# fire-and-forget and never calls run_turn across the thread/process
|
|
818
|
-
# boundary. The file is JSON carrying the sender's ids
|
|
819
|
-
# session's live worker predates structured input (plain text then).
|
|
711
|
+
# boundary. The file is JSON carrying the sender's ids.
|
|
820
712
|
# @param client_id [String, nil] the sending UI
|
|
821
713
|
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
822
714
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
823
|
-
# session's images/
|
|
715
|
+
# session's images/
|
|
824
716
|
# @return [String, false] the input file's path, or false.
|
|
825
717
|
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
826
718
|
images: [])
|
|
827
|
-
|
|
828
|
-
session_dir
|
|
829
|
-
|
|
830
|
-
raise ImagesUnsupported if !images.empty? && !images_input?(session_dir)
|
|
831
|
-
|
|
832
|
-
write_input_file(session_dir, prompt, client_id, enqueued_id, no_interrupt, images)
|
|
719
|
+
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
720
|
+
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
721
|
+
no_interrupt: no_interrupt, images: images)
|
|
833
722
|
end
|
|
834
723
|
|
|
835
724
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
@@ -852,16 +741,11 @@ module Samagotchi
|
|
|
852
741
|
# ack:} when it took the request but never answered (no file then), or
|
|
853
742
|
# {status: :failed} when the input file couldn't be written
|
|
854
743
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
855
|
-
# @raise [ImagesUnsupported] images for a worker that predates them
|
|
856
744
|
# @raise [ArgumentError] no such session
|
|
857
745
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
|
|
858
746
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
859
747
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
860
748
|
manager.resume_session(session_id, state_dir: state_dir) if manager.respond_to?(:resume_session)
|
|
861
|
-
# A worker that predates images would drop them (its Bridge ignores
|
|
862
|
-
# them): refuse, so the sender keeps the chips and the text.
|
|
863
|
-
raise ImagesUnsupported if images.any? && !images_input?(session_dir)
|
|
864
|
-
|
|
865
749
|
if (client = bridge.call)
|
|
866
750
|
begin
|
|
867
751
|
options = { prompt: prompt, client_id: client_id }
|
|
@@ -869,7 +753,7 @@ module Samagotchi
|
|
|
869
753
|
reply = client.post_turn(**options)
|
|
870
754
|
ack = reply.json
|
|
871
755
|
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
872
|
-
if ack.is_a?(Hash) &&
|
|
756
|
+
if ack.is_a?(Hash) && ack["error"] == "bad_images"
|
|
873
757
|
return { status: :refused, code: reply.status, ack: ack }
|
|
874
758
|
end
|
|
875
759
|
# Read after its deadline and dropped: a file would run it after all.
|
|
@@ -917,203 +801,18 @@ module Samagotchi
|
|
|
917
801
|
nil
|
|
918
802
|
end
|
|
919
803
|
|
|
920
|
-
private_class_method def self.write_input_file(session_dir, prompt, client_id, enqueued_id, no_interrupt, images)
|
|
921
|
-
input_dir = File.join(session_dir, INPUT_DIR)
|
|
922
|
-
FileUtils.mkdir_p(input_dir)
|
|
923
|
-
|
|
924
|
-
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
925
|
-
if structured_input?(session_dir)
|
|
926
|
-
path = File.join(input_dir, "#{timestamp}.json")
|
|
927
|
-
record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
|
|
928
|
-
"no_interrupt" => (no_interrupt ? true : nil),
|
|
929
|
-
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
|
|
930
|
-
write_atomic(path, JSON.generate(record))
|
|
931
|
-
else
|
|
932
|
-
path = File.join(input_dir, "#{timestamp}.txt")
|
|
933
|
-
write_atomic(path, prompt.to_s)
|
|
934
|
-
end
|
|
935
|
-
path
|
|
936
|
-
rescue StandardError
|
|
937
|
-
false
|
|
938
|
-
end
|
|
939
|
-
|
|
940
|
-
# Queue a context note for a session: background text its worker adds
|
|
941
|
-
# to the conversation between turns. It never starts a turn.
|
|
942
|
-
# @param source [String] where it came from ("cli", "slack", "session")
|
|
943
|
-
# @param from_session [String, nil] the sending session, for a peer's note
|
|
944
|
-
# @return [String] the note file's path
|
|
945
|
-
# @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
|
|
946
|
-
def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil)
|
|
947
|
-
body = checked_text(text)
|
|
948
|
-
sd = state_dir || Session.default_state_dir
|
|
949
|
-
notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
|
|
950
|
-
FileUtils.mkdir_p(notes_dir)
|
|
951
|
-
|
|
952
|
-
# The random part keeps two writers in one nanosecond apart; the
|
|
953
|
-
# timestamp keeps the names in arrival order.
|
|
954
|
-
name = "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
|
|
955
|
-
path = File.join(notes_dir, name)
|
|
956
|
-
record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
|
|
957
|
-
"from_cwd" => from_cwd, "created_at" => Time.now.iso8601 }.compact
|
|
958
|
-
write_atomic(path, JSON.generate(record))
|
|
959
|
-
path
|
|
960
|
-
end
|
|
961
|
-
|
|
962
|
-
# The same empty and 16 KiB checks for a note and a sent message.
|
|
963
|
-
# @param noun [String] what the errors call the text
|
|
964
|
-
# @return [String] the stripped text
|
|
965
|
-
# @raise [NoteRejected]
|
|
966
|
-
def self.checked_text(text, noun: "note")
|
|
967
|
-
body = text.to_s.strip
|
|
968
|
-
raise NoteRejected, "the #{noun} is empty" if body.empty?
|
|
969
|
-
if body.bytesize > NOTE_MAX_BYTES
|
|
970
|
-
raise NoteRejected, "the #{noun} is #{body.bytesize} bytes; the limit is 16 KiB (#{NOTE_MAX_BYTES} bytes)"
|
|
971
|
-
end
|
|
972
|
-
|
|
973
|
-
body
|
|
974
|
-
end
|
|
975
|
-
|
|
976
|
-
# Queued notes, oldest first, plus any a crashed worker claimed and left
|
|
977
|
-
# (the absorber skips a note id the conversation already holds).
|
|
978
|
-
def self.find_new_note_files(session_dir)
|
|
979
|
-
notes_dir = File.join(session_dir, NOTES_DIR)
|
|
980
|
-
return [] unless Dir.exist?(notes_dir)
|
|
981
|
-
|
|
982
|
-
Dir.glob(File.join(notes_dir, "*.{json,json.processing}")).sort_by { |p| File.basename(p) }
|
|
983
|
-
end
|
|
984
|
-
|
|
985
|
-
# @return [String, nil] the claimed path, or nil when another claimed it
|
|
986
|
-
def self.claim_note_file(note_file)
|
|
987
|
-
return note_file if note_file.end_with?(".processing")
|
|
988
|
-
|
|
989
|
-
claim_input_file(note_file)
|
|
990
|
-
end
|
|
991
|
-
|
|
992
|
-
# @return [Hash, nil] {note_id:, text:, source:, from_session:, from_cwd:,
|
|
993
|
-
# created_at:}, or nil for a file that holds no usable note
|
|
994
|
-
def self.read_note(note_file)
|
|
995
|
-
data = JSON.parse(File.read(note_file))
|
|
996
|
-
text = data["text"].to_s.strip
|
|
997
|
-
return nil if text.empty?
|
|
998
|
-
|
|
999
|
-
{ note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
|
|
1000
|
-
source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
|
|
1001
|
-
from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
|
|
1002
|
-
rescue JSON::ParserError, SystemCallError, NoMethodError, TypeError
|
|
1003
|
-
nil
|
|
1004
|
-
end
|
|
1005
|
-
|
|
1006
|
-
def self.write_output(session_dir, response)
|
|
1007
|
-
output_dir = File.join(session_dir, OUTPUT_DIR)
|
|
1008
|
-
FileUtils.mkdir_p(output_dir)
|
|
1009
|
-
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
1010
|
-
write_atomic(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
|
|
1011
|
-
end
|
|
1012
|
-
|
|
1013
|
-
def self.find_new_input_files(session_dir)
|
|
1014
|
-
input_dir = File.join(session_dir, INPUT_DIR)
|
|
1015
|
-
return [] unless Dir.exist?(input_dir)
|
|
1016
|
-
|
|
1017
|
-
Dir.glob(File.join(input_dir, "*.{txt,json}"))
|
|
1018
|
-
end
|
|
1019
|
-
|
|
1020
|
-
# A worker's sidecar advertises the input format it reads; no sidecar
|
|
1021
|
-
# means no worker yet, and the next one (this code) reads JSON.
|
|
1022
|
-
private_class_method def self.structured_input?(session_dir)
|
|
1023
|
-
worker_input_format(session_dir) >= STRUCTURED_INPUT_FORMAT
|
|
1024
|
-
end
|
|
1025
|
-
|
|
1026
|
-
# Whether the session's worker (or the next one) reads images: refs.
|
|
1027
|
-
def self.images_input?(session_dir)
|
|
1028
|
-
worker_input_format(session_dir) >= IMAGES_INPUT_FORMAT
|
|
1029
|
-
end
|
|
1030
|
-
|
|
1031
|
-
# The input format the session's worker advertises; no sidecar means no
|
|
1032
|
-
# worker yet, and the next one (this code) reads INPUT_FORMAT.
|
|
1033
|
-
private_class_method def self.worker_input_format(session_dir)
|
|
1034
|
-
sidecar = File.join(session_dir, "bridge.json")
|
|
1035
|
-
return INPUT_FORMAT unless File.file?(sidecar)
|
|
1036
|
-
|
|
1037
|
-
JSON.parse(File.read(sidecar))["input_format"].to_i
|
|
1038
|
-
rescue JSON::ParserError, SystemCallError
|
|
1039
|
-
INPUT_FORMAT
|
|
1040
|
-
end
|
|
1041
|
-
|
|
1042
|
-
# @return [Array(String, Hash|nil, Boolean, Array<Hash>)] a claimed input
|
|
1043
|
-
# file's prompt, origin ({client_id:, enqueued_id:}, nil for plain
|
|
1044
|
-
# text), whether its turn runs with the raised iteration limit
|
|
1045
|
-
# (--no-interrupt), and its image refs ({file:, name:})
|
|
1046
|
-
def self.read_input(claimed_file)
|
|
1047
|
-
raw = File.read(claimed_file).to_s
|
|
1048
|
-
return [raw, nil, false, []] unless claimed_file.end_with?(".json.processing")
|
|
1049
|
-
|
|
1050
|
-
data = JSON.parse(raw)
|
|
1051
|
-
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
|
|
1052
|
-
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
1053
|
-
[data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images]
|
|
1054
|
-
rescue JSON::ParserError
|
|
1055
|
-
[nil, nil, false, []]
|
|
1056
|
-
end
|
|
1057
|
-
|
|
1058
|
-
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
1059
|
-
# leaves it for its own turn).
|
|
1060
|
-
def self.input_has_images?(input_file)
|
|
1061
|
-
return false unless input_file.end_with?(".json")
|
|
1062
|
-
|
|
1063
|
-
data = JSON.parse(File.read(input_file))
|
|
1064
|
-
data.is_a?(Hash) && Array(data["images"]).any?
|
|
1065
|
-
rescue JSON::ParserError, SystemCallError
|
|
1066
|
-
false
|
|
1067
|
-
end
|
|
1068
|
-
|
|
1069
|
-
def self.claim_input_file(input_file)
|
|
1070
|
-
processing_path = "#{input_file}.processing"
|
|
1071
|
-
File.rename(input_file, processing_path)
|
|
1072
|
-
processing_path
|
|
1073
|
-
rescue Errno::ENOENT, Errno::EACCES
|
|
1074
|
-
nil
|
|
1075
|
-
end
|
|
1076
|
-
|
|
1077
804
|
def self.stopped_on_disk?(session_id, state_dir:)
|
|
1078
805
|
Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
|
|
1079
806
|
rescue ArgumentError
|
|
1080
807
|
false
|
|
1081
808
|
end
|
|
1082
809
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
File.rename(temp_path, path)
|
|
1087
|
-
end
|
|
1088
|
-
|
|
1089
|
-
# The session's live owner: the OwnerLock holder, or a live pid-only
|
|
1090
|
-
# worker started before the lock existed (such workers never exit).
|
|
810
|
+
# The session's live owner: the OwnerLock holder. The pid file workers
|
|
811
|
+
# once wrote is never read: a stale one whose pid the OS reused would
|
|
812
|
+
# make a session look owned.
|
|
1091
813
|
# @return [Hash, nil] {"pid", "kind" ("worker"/"tui"), ...} or nil
|
|
1092
814
|
def self.session_owner(session_id, state_dir: nil)
|
|
1093
|
-
|
|
1094
|
-
return OwnerLock.owner(session_dir) if OwnerLock.lock_file?(session_dir)
|
|
1095
|
-
|
|
1096
|
-
pid = legacy_worker_pid(session_dir)
|
|
1097
|
-
pid && { "pid" => pid, "kind" => "worker" }
|
|
1098
|
-
end
|
|
1099
|
-
|
|
1100
|
-
private_class_method def self.worker_alive_for_session?(session_id, state_dir:)
|
|
1101
|
-
!session_owner(session_id, state_dir: state_dir).nil?
|
|
1102
|
-
end
|
|
1103
|
-
|
|
1104
|
-
private_class_method def self.legacy_worker_pid(session_dir)
|
|
1105
|
-
pid_file = File.join(session_dir, PID_FILE)
|
|
1106
|
-
return nil unless File.exist?(pid_file)
|
|
1107
|
-
|
|
1108
|
-
pid = File.read(pid_file).strip.to_i
|
|
1109
|
-
return nil if pid <= 0
|
|
1110
|
-
|
|
1111
|
-
Process.kill(0, pid)
|
|
1112
|
-
pid
|
|
1113
|
-
rescue Errno::EPERM
|
|
1114
|
-
pid
|
|
1115
|
-
rescue Errno::ESRCH
|
|
1116
|
-
nil
|
|
815
|
+
OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
|
|
1117
816
|
end
|
|
1118
817
|
end
|
|
1119
818
|
end
|
|
@@ -5,6 +5,7 @@ require "monitor"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "securerandom"
|
|
8
|
+
require_relative "atomic_file"
|
|
8
9
|
require_relative "session"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -247,9 +248,7 @@ module Samagotchi
|
|
|
247
248
|
# File.join.
|
|
248
249
|
FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
|
|
249
250
|
path = File.join(dir, "analytics.json")
|
|
250
|
-
|
|
251
|
-
File.write(temp_path, JSON.pretty_generate(snapshot.merge(active_turn: nil, active_tools: [])) + "\n")
|
|
252
|
-
File.rename(temp_path, path)
|
|
251
|
+
AtomicFile.write(path, JSON.pretty_generate(snapshot.merge(active_turn: nil, active_tools: [])) + "\n")
|
|
253
252
|
true
|
|
254
253
|
rescue StandardError
|
|
255
254
|
false
|