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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/context-telemetry.md +3 -1
  9. data/docs/memory.md +48 -1
  10. data/docs/plugins.md +7 -5
  11. data/docs/releasing.md +5 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/archive_store.rb +3 -3
  14. data/lib/samagotchi/atomic_file.rb +34 -0
  15. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  16. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  17. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  18. data/lib/samagotchi/bootstrap_command.rb +78 -8
  19. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  20. data/lib/samagotchi/bridge.rb +56 -116
  21. data/lib/samagotchi/bridge_client.rb +7 -11
  22. data/lib/samagotchi/bundle_command.rb +81 -21
  23. data/lib/samagotchi/bundle_needs.rb +2 -11
  24. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  25. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  26. data/lib/samagotchi/bundles/system/identity.md +1 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  29. data/lib/samagotchi/client.rb +3 -23
  30. data/lib/samagotchi/config.rb +6 -11
  31. data/lib/samagotchi/context_note.rb +1 -1
  32. data/lib/samagotchi/context_status.rb +255 -0
  33. data/lib/samagotchi/desktop/macos.rb +2 -3
  34. data/lib/samagotchi/engine.rb +6 -7
  35. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  36. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  37. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  38. data/lib/samagotchi/hooks/loader.rb +2 -1
  39. data/lib/samagotchi/hooks/registry.rb +21 -0
  40. data/lib/samagotchi/image_store.rb +2 -3
  41. data/lib/samagotchi/kernel_loop.rb +21 -283
  42. data/lib/samagotchi/live_versions.rb +9 -13
  43. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  44. data/lib/samagotchi/llm/native_backend.rb +8 -8
  45. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  46. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  47. data/lib/samagotchi/log_path.rb +2 -3
  48. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  49. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  50. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  51. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  52. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  53. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  55. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  56. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/note_command.rb +5 -4
  59. data/lib/samagotchi/owner_lock.rb +0 -6
  60. data/lib/samagotchi/paths.rb +32 -0
  61. data/lib/samagotchi/plugin/api.rb +3 -6
  62. data/lib/samagotchi/plugin/context.rb +2 -3
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/question_desk.rb +3 -2
  65. data/lib/samagotchi/recap_store.rb +2 -2
  66. data/lib/samagotchi/reply_wait.rb +2 -1
  67. data/lib/samagotchi/self_report.rb +2 -3
  68. data/lib/samagotchi/send_command.rb +3 -2
  69. data/lib/samagotchi/session.rb +41 -143
  70. data/lib/samagotchi/session_inbox.rb +175 -0
  71. data/lib/samagotchi/session_manager.rb +62 -363
  72. data/lib/samagotchi/session_metrics.rb +2 -3
  73. data/lib/samagotchi/session_retention.rb +259 -0
  74. data/lib/samagotchi/sessions_command.rb +3 -2
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  76. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  77. data/lib/samagotchi/terminal_ui.rb +10 -20
  78. data/lib/samagotchi/tools/memory.rb +3 -2
  79. data/lib/samagotchi/tools/send_note.rb +4 -3
  80. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  81. data/lib/samagotchi/turn_note.rb +13 -2
  82. data/lib/samagotchi/update_command.rb +9 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +44 -76
  85. data/lib/samagotchi/web/info_probe.rb +33 -0
  86. data/lib/samagotchi/web/public/app.js +1 -1
  87. data/lib/samagotchi/web/public/index.html +1 -0
  88. data/lib/samagotchi/web/public/stage_model.js +22 -3
  89. data/lib/samagotchi/web/public/stage_view.js +25 -10
  90. data/lib/samagotchi/web/server.rb +8 -11
  91. data/lib/samagotchi/web/session_hub.rb +8 -7
  92. data/lib/samagotchi/web/session_summary.rb +4 -3
  93. data/lib/samagotchi/web/token.rb +2 -5
  94. data/lib/samagotchi/worker.rb +34 -20
  95. data/lib/samagotchi/worker_sidecar.rb +42 -0
  96. 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} (plain <timestamp>.txt for old workers)
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 given.match?(/\A[\w-]+\z/)
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
- def self.prune_sessions(state_dir: nil, days: nil, max_count: nil, keep_status: nil, dry_run: false, test_only: false,
389
- any_age: false)
390
- sd = state_dir || Session.default_state_dir
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
- session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
512
- output_path = File.join(session_dir, OUTPUT_DIR)
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
- EMPTY_SKELETON_FILES = %w[pid owner.lock bridge.json analytics.json].freeze
573
- EMPTY_DIRS = [INPUT_DIR, NOTES_DIR, "images"].freeze
574
- EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [OUTPUT_DIR]).freeze
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 given.match?(/\A[\w-]+\z/)
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 = File.join(sd, "#{id}#{Session::FILE_EXT}")
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 empty_session?(session_id, state_dir: state_dir, default_model: default_model)
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, unless the
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/ (raises ImagesUnsupported for an older worker)
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
- sd = state_dir || Session.default_state_dir
828
- session_dir = Session.session_dir(session_id, state_dir: sd)
829
- images = Array(images)
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) && %w[bad_images images_unsupported].include?(ack["error"])
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
- private_class_method def self.write_atomic(path, content)
1084
- temp_path = "#{path}.tmp"
1085
- File.write(temp_path, content)
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
- session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
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
- temp_path = "#{path}.tmp"
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