samagotchi 0.160.0 → 0.161.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +7 -3
  4. data/docs/cli.md +6 -4
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +12 -3
  7. data/docs/internals/bridge.md +21 -6
  8. data/docs/internals/bundles.md +24 -5
  9. data/docs/internals/objectives.md +8 -2
  10. data/docs/internals/remote-sessions.md +2 -1
  11. data/docs/memory.md +4 -2
  12. data/docs/plugins.md +13 -4
  13. data/docs/releasing.md +7 -4
  14. data/docs/scheduling.md +37 -5
  15. data/docs/sessions.md +14 -11
  16. data/docs/testing.md +9 -1
  17. data/lib/samagotchi/bridge/card_store.rb +31 -3
  18. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  19. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  20. data/lib/samagotchi/bridge.rb +92 -42
  21. data/lib/samagotchi/bridge_client.rb +9 -6
  22. data/lib/samagotchi/broadcast_command.rb +24 -8
  23. data/lib/samagotchi/bundle_command.rb +56 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/child_reports.rb +29 -23
  26. data/lib/samagotchi/client_id.rb +2 -0
  27. data/lib/samagotchi/compat.rb +32 -0
  28. data/lib/samagotchi/context_candidates_log.rb +10 -1
  29. data/lib/samagotchi/context_command.rb +1 -1
  30. data/lib/samagotchi/context_move.rb +6 -1
  31. data/lib/samagotchi/engine/events.rb +12 -0
  32. data/lib/samagotchi/engine/plugins.rb +1 -1
  33. data/lib/samagotchi/engine/steering.rb +2 -2
  34. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/targets.rb +25 -6
  37. data/lib/samagotchi/live_versions.rb +7 -2
  38. data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
  39. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  40. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  41. data/lib/samagotchi/memory_bundle/installer.rb +79 -12
  42. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  44. data/lib/samagotchi/memory_bundle/status.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
  47. data/lib/samagotchi/memory_orphans.rb +40 -9
  48. data/lib/samagotchi/note_command.rb +9 -1
  49. data/lib/samagotchi/note_delivery.rb +34 -24
  50. data/lib/samagotchi/note_move.rb +205 -0
  51. data/lib/samagotchi/objectives_command.rb +3 -1
  52. data/lib/samagotchi/owner_lock.rb +7 -1
  53. data/lib/samagotchi/project_init.rb +3 -3
  54. data/lib/samagotchi/review_start.rb +5 -3
  55. data/lib/samagotchi/schedule_command.rb +12 -0
  56. data/lib/samagotchi/schedules.rb +286 -48
  57. data/lib/samagotchi/send_command.rb +2 -15
  58. data/lib/samagotchi/session.rb +42 -5
  59. data/lib/samagotchi/session_access/local.rb +45 -17
  60. data/lib/samagotchi/session_access/remote.rb +11 -4
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  62. data/lib/samagotchi/session_access.rb +16 -3
  63. data/lib/samagotchi/session_continue.rb +155 -21
  64. data/lib/samagotchi/session_delete_command.rb +3 -1
  65. data/lib/samagotchi/session_inbox.rb +41 -1
  66. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  67. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  68. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  69. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  70. data/lib/samagotchi/session_manager/listing.rb +4 -1
  71. data/lib/samagotchi/session_manager/removal.rb +130 -26
  72. data/lib/samagotchi/session_manager/spawn.rb +164 -104
  73. data/lib/samagotchi/session_manager.rb +1 -0
  74. data/lib/samagotchi/session_retention.rb +53 -23
  75. data/lib/samagotchi/session_setup.rb +27 -7
  76. data/lib/samagotchi/starting_marker.rb +93 -24
  77. data/lib/samagotchi/state_format.rb +18 -7
  78. data/lib/samagotchi/system_one/runs.rb +6 -1
  79. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  80. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  81. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  82. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  83. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  84. data/lib/samagotchi/terminal_ui.rb +2 -3
  85. data/lib/samagotchi/tools/delegate.rb +9 -4
  86. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  87. data/lib/samagotchi/tools/send_note.rb +10 -4
  88. data/lib/samagotchi/turn_state.rb +108 -4
  89. data/lib/samagotchi/update_command.rb +7 -2
  90. data/lib/samagotchi/verify_fresh.rb +27 -9
  91. data/lib/samagotchi/version.rb +1 -1
  92. data/lib/samagotchi/web/app.rb +1 -1
  93. data/lib/samagotchi/web/public/app.js +2 -7
  94. data/lib/samagotchi/web/public/chains.js +4 -4
  95. data/lib/samagotchi/web/public/data.js +18 -2
  96. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  97. data/lib/samagotchi/web/public/session_fields.js +3 -2
  98. data/lib/samagotchi/web/public/session_state.js +2 -0
  99. data/lib/samagotchi/web/public/update_notice.js +2 -2
  100. data/lib/samagotchi/web/session_access_routes.rb +12 -8
  101. data/lib/samagotchi/web/session_control.rb +9 -3
  102. data/lib/samagotchi/web/session_create.rb +8 -4
  103. data/lib/samagotchi/web/session_hub.rb +50 -5
  104. data/lib/samagotchi/web/session_summary.rb +5 -2
  105. data/lib/samagotchi/worker.rb +45 -7
  106. data/lib/samagotchi/worker_boot.rb +22 -11
  107. data/lib/samagotchi/worker_inbound.rb +41 -8
  108. data/lib/samagotchi/worker_link.rb +4 -1
  109. data/lib/samagotchi/worker_presence.rb +106 -0
  110. data/lib/samagotchi/worker_sidecar.rb +3 -4
  111. metadata +5 -1
@@ -5,6 +5,7 @@ require "json"
5
5
  require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
+ require_relative "log"
8
9
  require_relative "context_note"
9
10
  require_relative "interrupted_turn"
10
11
  require_relative "last_turn"
@@ -485,6 +486,10 @@ module Samagotchi
485
486
  @archived = false
486
487
  # #save's lock: one snapshot-and-write at a time (see #save).
487
488
  @save_lock = Mutex.new
489
+ # Read from its file (.load, .summary_from_file) or saved once: its
490
+ # file is there to rewrite, and one gone was deleted (#save doesn't
491
+ # bring it back).
492
+ @persisted = false
488
493
  end
489
494
 
490
495
  # Whether a chi newer than this one saved the loaded file: its
@@ -579,8 +584,7 @@ module Samagotchi
579
584
  # @raise [Toolkit::Refused] its toolkit (setup's, else the configured
580
585
  # one SessionManager.spawn_session gives it) builds on a config kit
581
586
  # with an invalid groups: (Toolkit.check_new!)
582
- def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
583
- preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
587
+ def self.new_session(mode:, model_name:, working_directory:, test_run: nil, parent_id: nil, messages: [],
584
588
  scratch: false, model_typed: nil, delegate: false, setup: nil, continues: nil,
585
589
  review: nil, now: Time.now)
586
590
  own = setup.to_h
@@ -607,8 +611,6 @@ module Samagotchi
607
611
  test_run: resolved_test,
608
612
  project_root: project&.root,
609
613
  project_id: project&.id,
610
- preloaded_memory_names: preloaded_memory_names,
611
- muted_memory_names: muted_memory_names,
612
614
  parent_id: parent_id,
613
615
  scratch: scratch,
614
616
  delegate: delegate,
@@ -636,6 +638,7 @@ module Samagotchi
636
638
  raise NotFound, session_id unless File.exist?(path)
637
639
 
638
640
  session = from_h(JSON.parse(File.read(path)))
641
+ session.persisted!
639
642
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
640
643
  session.parent_id = parent_override(session.id, state_dir: state_dir) || session.parent_id
641
644
  session
@@ -764,6 +767,7 @@ module Samagotchi
764
767
  return nil unless valid_id?(data["id"])
765
768
 
766
769
  session = from_h(data, messages: false)
770
+ session.persisted!
767
771
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
768
772
  session.parent_id = parent_override(session.id, state_dir: File.dirname(path)) || session.parent_id
769
773
  session
@@ -771,6 +775,10 @@ module Samagotchi
771
775
  nil
772
776
  end
773
777
 
778
+ # This session was read from its file (#save then refuses to recreate
779
+ # a deleted one).
780
+ def persisted! = (@persisted = true)
781
+
774
782
  # Persist the session atomically. Updates +updated_at+ in place.
775
783
  #
776
784
  # One save at a time per Session: a worker saves its session from the
@@ -782,6 +790,11 @@ module Samagotchi
782
790
  # Never call it from a trap handler (Mutex#lock raises ThreadError
783
791
  # there; WorkerSignals saves on a thread of its own) or from inside a
784
792
  # save (the lock isn't re-entrant).
793
+ #
794
+ # A session read from its file, or saved once, whose file is gone was
795
+ # deleted (SessionManager.delete_session, the retention sweep): the save
796
+ # is skipped (and logged) rather than bring it back. Only a new
797
+ # session's first save creates the file.
785
798
  # @param now [Time, nil] the updated_at to stamp (a spec's clock); nil,
786
799
  # the time the lock is taken
787
800
  def save(state_dir: self.class.default_state_dir, now: nil)
@@ -796,7 +809,13 @@ module Samagotchi
796
809
  # Auto-compute first_preview if not yet cached and messages contain a user entry.
797
810
  compute_first_preview!
798
811
 
812
+ if @persisted && !File.exist?(path)
813
+ Log.warn(:worker, "save_skipped_deleted", sid: @id)
814
+ return self
815
+ end
816
+
799
817
  AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
818
+ @persisted = true
800
819
  end
801
820
  self
802
821
  end
@@ -860,7 +879,25 @@ module Samagotchi
860
879
 
861
880
  dir = session_dir(session_id, state_dir: state_dir)
862
881
  FileUtils.mkdir_p(dir)
863
- FileUtils.touch(File.join(dir, STOPPED_FILE))
882
+ # Its own at, on the clock the starting marker's at is written with
883
+ # (StartingMarker; resume_session compares them): a file's mtime may be
884
+ # coarse or skewed. Older chi readers ask only whether it exists.
885
+ AtomicFile.write(File.join(dir, STOPPED_FILE), JSON.generate({ "at" => Time.now.iso8601(6) }))
886
+ end
887
+
888
+ # When the stop marker was written: its own at, or (an older chi's empty
889
+ # file) its mtime; nil without one.
890
+ # @return [Time, nil]
891
+ def self.stopped_at(session_id, state_dir: default_state_dir)
892
+ path = File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE)
893
+ own = begin
894
+ Time.iso8601(JSON.parse(File.read(path))["at"].to_s)
895
+ rescue JSON::ParserError, ArgumentError, TypeError, NoMethodError
896
+ nil
897
+ end
898
+ own || File.mtime(path)
899
+ rescue SystemCallError
900
+ nil
864
901
  end
865
902
 
866
903
  # Remove the stop marker (a resume, before its worker starts).
@@ -19,6 +19,7 @@ require_relative "../send_command/busy"
19
19
  require_relative "../chi_invocation"
20
20
  require_relative "../worker_sidecar"
21
21
  require_relative "../worker_link"
22
+ require_relative "../worker_presence"
22
23
  require_relative "../review_start"
23
24
  require_relative "../launch_mode"
24
25
  require_relative "../participants"
@@ -103,7 +104,7 @@ module Samagotchi
103
104
  session = show(id)
104
105
  raise Refused.new(Session::SCRATCH_REFUSED, code: "scratch") if session.scratch
105
106
 
106
- link = WorkerLink.resolve(session_dir(id), unlink: true)
107
+ link = WorkerPresence.read(id, state_dir: state_dir!, link: :unlink).link
107
108
  case link
108
109
  when WorkerLink::Live then return Woken.new(woke: false, sidecar: link.sidecar)
109
110
  when WorkerLink::Skew then raise Refused.new(link.message(id), code: "worker_newer")
@@ -126,8 +127,8 @@ module Samagotchi
126
127
  # @raise [ArgumentError] no such session
127
128
  def worker(id)
128
129
  show(id)
129
- WorkerState.new(link: WorkerLink.resolve(session_dir(id), unlink: true),
130
- stopped: Session.stopped_marker?(id, state_dir: state_dir!))
130
+ presence = WorkerPresence.read(id, state_dir: state_dir!, link: :unlink)
131
+ WorkerState.new(link: presence.link, stopped: presence.stop_marked?)
131
132
  end
132
133
 
133
134
  # --- create and send
@@ -143,11 +144,11 @@ module Samagotchi
143
144
 
144
145
  start = spec.preview ? { prompt: spec.prompt, preview: spec.preview } : { prompt: spec.prompt }
145
146
  if spec.continues
146
- return SessionContinue.run(spec.continues, **start, model: spec.model_name, **dir)
147
+ return SessionContinue.run(spec.continues, **start, model: spec.model_name, client_id: spec.client_id, **dir)
147
148
  end
148
149
 
149
150
  SessionManager.spawn_session(**start, working_directory: spec.working_directory, model_name: spec.model_name,
150
- setup: spec.setup, **dir)
151
+ setup: spec.setup, client_id: spec.client_id, **dir)
151
152
  end
152
153
 
153
154
  # A review session (ReviewStart): a fork of review.from, or a fresh
@@ -313,20 +314,17 @@ module Samagotchi
313
314
  return ContextPush.new(status: :missing) unless attached
314
315
  return ContextPush.new(status: :not_push) unless attached.source.push?
315
316
 
316
- before, written = attached.location.locked(name) do
317
- [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
318
- end
319
- if fetched.wake && before != written.revision && !target.project? && attached.location.session? &&
320
- attached.source.scope == "session"
321
- begin
322
- subscription = attached.location.subscription(name)
323
- recent_wake = context_wake_recent?(subscription)
324
- SessionManager.wake_for_context(target.session_id, **dir) unless recent_wake || Config.get("context.wake") == false
325
- rescue StandardError => e
326
- Log.exception(:context, "context_push_wake_failed", e, sid: target.session_id, source: name)
317
+ record = lambda do
318
+ attached.location.locked(name) do
319
+ [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
327
320
  end
328
321
  end
329
- ContextPush.new(status: :pushed, before: before, revision: written.revision)
322
+ unless !target.project? && attached.location.session? && attached.source.scope == "session"
323
+ before, written = record.call
324
+ return ContextPush.new(status: :pushed, before: before, revision: written.revision)
325
+ end
326
+
327
+ push_delivery(target.session_id, name, fetched, attached.location, record)
330
328
  end
331
329
 
332
330
  # The target's sources: a session's own, then its project's (shadowed
@@ -468,6 +466,36 @@ module Samagotchi
468
466
 
469
467
  private
470
468
 
469
+ # A session source's push, as a push delivery (SessionInbox.deliver
470
+ # :push): the text recorded, then a wake when it asks one (a change
471
+ # with wake, context.wake on, the source not woken lately) as the
472
+ # push WakePolicy decides (an idle-exited session starts; a stopped
473
+ # one never). A failed wake is logged: the push stands.
474
+ # @return [ContextPush]
475
+ def push_delivery(session_id, name, fetched, location, record)
476
+ before = written = asked = nil
477
+ delivered = begin
478
+ SessionInbox.deliver(:push, session_id, state_dir: state_dir!) do
479
+ before, written = record.call
480
+ asked = fetched.wake && before != written.revision && context_wake_allowed?(location, name)
481
+ end
482
+ rescue StandardError => e
483
+ raise unless written
484
+
485
+ Log.exception(:context, "context_push_wake_failed", e, sid: session_id, source: name)
486
+ nil
487
+ end
488
+ # Its session file is gone (:gone): the source's folder takes it, as before.
489
+ before, written = record.call if delivered&.refused?
490
+ ContextPush.new(status: :pushed, before: before, revision: written.revision, wake: asked ? delivered&.wake : nil)
491
+ end
492
+
493
+ def context_wake_allowed?(location, name)
494
+ return false if Config.get("context.wake") == false
495
+
496
+ !context_wake_recent?(location.subscription(name))
497
+ end
498
+
471
499
  def grants = @grants ||= Grants.new(Grants.path(state_dir: state_dir!))
472
500
 
473
501
  LOCAL_PROBE_TIMEOUT = 1.0
@@ -317,14 +317,17 @@ module Samagotchi
317
317
  def note(id, text:, source:)
318
318
  reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
319
319
  json = reply.json.is_a?(Hash) ? reply.json : {}
320
+ # id: the link that has it, or refused it (a chi web before it names none: +id+).
321
+ head = Session.valid_id?(json["id"]) ? json["id"] : id
320
322
  case reply.status
321
323
  when 200
322
324
  raise_refusal(reply) unless json["status"].is_a?(String)
323
325
 
324
- NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
326
+ NoteDelivery::Result.new(id: head, asked: id, status: json["status"].to_sym, queued: json["queued"].to_i)
325
327
  when 409
326
328
  if json["error"] == "owned_by_tui"
327
- return NoteDelivery::Result.new(id: id, status: :refused, queued: 0, scratch: json["scratch"] == true)
329
+ return NoteDelivery::Result.new(id: head, asked: id, status: :refused, queued: 0,
330
+ scratch: json["scratch"] == true)
328
331
  end
329
332
 
330
333
  raise_refusal(reply)
@@ -508,7 +511,10 @@ module Samagotchi
508
511
  reply = request(:post, "#{session_path(target.session_id)}/context/#{name}/push",
509
512
  body: { text: fetched.text, summary: fetched.summary, wake: fetched.wake, hint: fetched.hint })
510
513
  case reply.status
511
- when 200 then ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"])
514
+ when 200
515
+ # An older chi web's answer names no wake: the line is as it was.
516
+ wake = ContextPush::WAKES.find { |known| known.to_s == reply.json["wake"] }
517
+ ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"], wake: wake)
512
518
  when 404
513
519
  return ContextPush.new(status: :missing) if context_missing?(reply)
514
520
 
@@ -736,7 +742,8 @@ module Samagotchi
736
742
  raise SessionManager::ScratchSession, sid if json["scratch"] == true
737
743
 
738
744
  raise SessionManager::OwnedByTUI, sid
739
- when "worker_running", "still_stopping" then raise SessionManager::DeleteRefused.new(sid, reason)
745
+ when "worker_running", "still_stopping", "scheduled"
746
+ raise SessionManager::DeleteRefused.new(sid, reason, message: message)
740
747
  when "busy", "scratch"
741
748
  raise SessionManager::ArchiveRefused.new(sid, reason, busy_id: json["busy_id"]) if reason
742
749
  when "continued", "open_children", "folder_gone"
@@ -141,8 +141,8 @@ module Samagotchi
141
141
 
142
142
  # POST …/cancel.
143
143
  # @return [BridgeClient::Response]
144
- def cancel(reason:)
145
- post(:cancel, "cancel", { reason: reason })
144
+ def cancel(reason:, turn_id: nil)
145
+ post(:cancel, "cancel", { reason: reason, turn_id: turn_id }.compact)
146
146
  end
147
147
 
148
148
  # POST …/exit: the worker asked to exit (chi web passes client_id on
@@ -271,9 +271,15 @@ module Samagotchi
271
271
  # chi context push's outcome: :pushed (+before+ the revision it had,
272
272
  # +revision+ the new one), :missing (no such source), :not_push (a
273
273
  # command source, whose text is its command's) or :continued (the
274
- # session was continued, as +next_id+: nothing was written).
275
- ContextPush = Data.define(:status, :before, :revision, :next_id) do
276
- def initialize(status:, before: nil, revision: nil, next_id: nil) = super
274
+ # session was continued, as +next_id+: nothing was written). +wake+:
275
+ # what a push that asked for a wake did (SessionInbox.deliver :push:
276
+ # :woken, :live, :stopped, :starting, :archived, :scratch, :owned,
277
+ # :gone), nil when it asked none (or an older chi web's answer has none).
278
+ ContextPush = Data.define(:status, :before, :revision, :next_id, :wake) do
279
+ def initialize(status:, before: nil, revision: nil, next_id: nil, wake: nil) = super
280
+
281
+ # What chi context push adds to its line for the wake, else nil.
282
+ def wake_line = ContextPush::WAKE_LINES[wake]
277
283
 
278
284
  # Why a push to +name+ was refused as :not_push: a command source's
279
285
  # text is its command's, and a push between two fetches would read
@@ -289,5 +295,12 @@ module Samagotchi
289
295
  "attach it to #{next_id} (or pair again with #{next_id}) and push there"
290
296
  end
291
297
  end
298
+
299
+ # The wakes a chi web's push answer may name (Remote reads no other).
300
+ ContextPush::WAKES = %i[woken live stopped starting archived scratch owned gone].freeze
301
+ # chi context push's words for a wake; none for the others (:live: the
302
+ # worker sees it as any push).
303
+ ContextPush::WAKE_LINES = { woken: "chi woken", stopped: "chi is stopped: it sees this at its next start",
304
+ starting: "chi is starting" }.freeze
292
305
  end
293
306
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require_relative "atomic_file"
4
7
  require_relative "session"
5
8
  require_relative "session_setup"
6
9
  require_relative "session_inbox"
@@ -9,6 +12,7 @@ require_relative "objectives"
9
12
  require_relative "child_move"
10
13
  require_relative "starting_marker"
11
14
  require_relative "context_move"
15
+ require_relative "note_move"
12
16
  require_relative "archive_store"
13
17
  require_relative "recap_store"
14
18
  require_relative "bridge_client"
@@ -56,6 +60,8 @@ module Samagotchi
56
60
  RECAP_WAIT = 30.0
57
61
  # In the previous link's folder: one continue of a session at a time.
58
62
  LOCK = "continue.lock"
63
+ # A durable record in the previous link's folder, written before archive.
64
+ CONTINUE_INTENT = "continue.json"
59
65
  # How often a last:<id> continue looks again when another continue
60
66
  # moved the chain's end while it waited for the lock.
61
67
  TRIES = 3
@@ -67,6 +73,29 @@ module Samagotchi
67
73
  # (ChildMove::Plan) and the new link's model (ModelChoice).
68
74
  Checked = Data.define(:plan, :choice)
69
75
 
76
+ # Repair an interrupted continue before showing sessions or starting
77
+ # another continue. An active continue holds LOCK, so its intent is left
78
+ # alone; after a crash, an unsaved successor means the previous link is
79
+ # unarchived.
80
+ def self.repair_interrupted(state_dir:)
81
+ return unless Dir.exist?(state_dir)
82
+
83
+ paths = Dir.glob(File.join(state_dir, "*", CONTINUE_INTENT))
84
+ return if paths.empty?
85
+
86
+ sessions = Session.list(state_dir: state_dir, include_archived: true)
87
+ by_id = sessions.to_h { |session| [session.id, session] }
88
+ paths.each do |path|
89
+ id = File.basename(File.dirname(path))
90
+ session = by_id[id]
91
+ if session
92
+ repair_intent(session, state_dir, path, sessions)
93
+ else
94
+ quarantine_intent(path, id, message: "session file could not be loaded")
95
+ end
96
+ end
97
+ end
98
+
70
99
  # Start the next link of a chain: a new session that continues
71
100
  # +id_or_ref+ (Session#continues) in its folder, on its model (the name
72
101
  # it was typed as, so an alias holds) and with its own setup (SessionSetup),
@@ -105,6 +134,7 @@ module Samagotchi
105
134
  def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5,
106
135
  client_id: nil)
107
136
  sd = state_dir || Session.default_state_dir
137
+ repair_interrupted(state_dir: sd)
108
138
  TRIES.times do |try|
109
139
  id = SessionChain.resolve(id_or_ref, state_dir: sd)
110
140
  result = with_lock(id, sd) do
@@ -140,24 +170,100 @@ module Samagotchi
140
170
  already = [id, *SessionManager.descendant_ids(id, state_dir, except: plan.move)].select do |sid|
141
171
  ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
142
172
  end
173
+ successor_id = SecureRandom.uuid
174
+ intent_path = continue_intent_path(id, state_dir)
175
+ intent_data = { successor_id: successor_id, previously_archived: already }
176
+ AtomicFile.write(intent_path, "#{JSON.generate(intent_data)}\n", perm: 0o600)
143
177
  archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
144
178
  archived = archive.archived - already
179
+ intent_data[:archived_ids] = archived
180
+ AtomicFile.write(intent_path, "#{JSON.generate(intent_data)}\n", perm: 0o600)
181
+ # The notes the new link took from this one (NoteMove.move).
182
+ notes = []
145
183
  begin
146
- spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
147
- move: plan.move, client_id: client_id)
148
- rescue StandardError
184
+ link = spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
185
+ move: plan.move, client_id: client_id, notes: notes, session_id: successor_id)
186
+ rescue StandardError, Interrupt
149
187
  # Nothing started: the delegates go back, what this archived goes
150
188
  # back to the lists, and a new link saved before its worker failed
151
189
  # to spawn goes (left, it would hold the chain: "continued already",
152
- # last: to it).
153
- undo_move(id, state_dir, moved: plan.move)
154
- begin
155
- discard_failed_link(id, state_dir)
156
- ensure
157
- archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
158
- wake_for_rings(id, state_dir) unless plan.move.empty?
190
+ # last: to it), its notes given back first.
191
+ undo_failed_continue(id, state_dir, moved: plan.move, archived: archived) do
192
+ discard_failed_link(id, state_dir, notes: notes)
159
193
  end
194
+ FileUtils.rm_f(intent_path)
160
195
  raise
196
+ else
197
+ FileUtils.rm_f(intent_path)
198
+ link
199
+ end
200
+ end
201
+
202
+ private_class_method def self.continue_intent_path(id, state_dir)
203
+ File.join(Session.session_dir(id, state_dir: state_dir), CONTINUE_INTENT)
204
+ end
205
+
206
+ private_class_method def self.repair_intent(session, state_dir, path, sessions)
207
+ lock_path = File.join(Session.session_dir(session.id, state_dir: state_dir), LOCK)
208
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
209
+ next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
210
+ next unless File.file?(path)
211
+
212
+ intent = JSON.parse(File.read(path))
213
+ intent = {} unless intent.is_a?(Hash)
214
+ successor_id = intent["successor_id"]
215
+ successor_path = Session.session_file(successor_id, state_dir: state_dir) if Session.valid_id?(successor_id)
216
+ successor = JSON.parse(File.read(successor_path)) if successor_path && File.file?(successor_path)
217
+ unless successor.is_a?(Hash) && successor["continues"] == session.id
218
+ previously_archived = Array(intent["previously_archived"]).select { |id| Session.valid_id?(id) }
219
+ archived = if intent["archived_ids"].is_a?(Array)
220
+ intent["archived_ids"].select { |id| Session.valid_id?(id) }
221
+ else
222
+ archived_descendants(session.id, sessions, state_dir) - previously_archived
223
+ end
224
+ undo_failed_continue(session.id, state_dir, moved: [], archived: archived)
225
+ end
226
+ FileUtils.rm_f(path)
227
+ end
228
+ rescue JSON::ParserError => e
229
+ quarantine_intent(path, session.id, error: e)
230
+ rescue Errno::ENOENT
231
+ nil
232
+ end
233
+
234
+ private_class_method def self.quarantine_intent(path, session_id, error: nil, message: nil)
235
+ File.rename(path, "#{path}.bad")
236
+ Log.warn(:worker, "continue_repair_failed", sid: session_id, path: path,
237
+ error: error&.class&.name || "SessionUnavailable",
238
+ msg: (message || error.message).to_s[0, 200])
239
+ rescue Errno::ENOENT
240
+ nil
241
+ end
242
+
243
+ private_class_method def self.archived_descendants(id, sessions, state_dir)
244
+ children = sessions.group_by(&:parent_id)
245
+ queue = [id]
246
+ descendants = []
247
+ until queue.empty?
248
+ children.fetch(queue.shift, []).each do |child|
249
+ next if descendants.include?(child.id)
250
+
251
+ descendants << child.id
252
+ queue << child.id
253
+ end
254
+ end
255
+ [id, *descendants].select do |session_id|
256
+ ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
257
+ end
258
+ end
259
+
260
+ private_class_method def self.undo_failed_continue(id, state_dir, moved:, archived:)
261
+ undo_move(id, state_dir, moved: moved)
262
+ begin
263
+ yield if block_given?
264
+ ensure
265
+ archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
266
+ wake_for_rings(id, state_dir) unless moved.empty?
161
267
  end
162
268
  end
163
269
 
@@ -175,10 +281,16 @@ module Samagotchi
175
281
  # The link a failed #run saved (no other continues +id+:
176
282
  # check! made sure under the lock): its worker stopped if one
177
283
  # started, then the rings a moved delegate wrote into it just before
178
- # the undo go back to +id+, then the link goes. A worker that outlives
179
- # the stop is raised (SessionManager::DeleteRefused): the link stays,
180
- # and the caller hears it.
181
- private_class_method def self.discard_failed_link(id, state_dir)
284
+ # the undo go back to +id+. Then its session file goes first, so the
285
+ # link is out of the chain (a note to +id+ stays there:
286
+ # SessionInbox::Head) and a writer that resolved it before finds it gone,
287
+ # then the notes it moved there (+notes+) go back with any sent to it
288
+ # meanwhile (NoteMove.give_back), then the link's folder goes, and one
289
+ # last look gives back a note an older chi wrote after the delete
290
+ # (its write_note recreates the folder). A worker that outlives the
291
+ # stop is raised (SessionManager::DeleteRefused): the link stays, and
292
+ # the caller hears it.
293
+ private_class_method def self.discard_failed_link(id, state_dir, notes:)
182
294
  orphan = SessionChain.next_of(id, state_dir: state_dir) or return
183
295
  if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
184
296
  !SessionManager.stop_session(orphan, state_dir: state_dir, wait: SessionManager::STOP_WAIT)
@@ -186,13 +298,26 @@ module Samagotchi
186
298
  end
187
299
 
188
300
  ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
189
- SessionManager.delete_session(orphan, state_dir: state_dir)
301
+ FileUtils.rm_f(Session.session_file(orphan, state_dir: state_dir))
302
+ NoteMove.give_back(from: orphan, to: id, moved: notes, state_dir: state_dir)
303
+ SessionManager.delete_session(orphan, state_dir: state_dir, allow_scheduled: true)
304
+ give_back_strays(orphan, id, state_dir)
190
305
  rescue SessionManager::DeleteRefused
191
306
  raise
192
307
  rescue StandardError => e
193
308
  Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
194
309
  end
195
310
 
311
+ # A failed link's folder an older chi's note brought back after its
312
+ # delete: the note goes back to +id+, the folder goes again.
313
+ private_class_method def self.give_back_strays(orphan, id, state_dir)
314
+ dir = Session.session_dir(orphan, state_dir: state_dir)
315
+ return unless Dir.exist?(dir) && !Session.exist?(orphan, state_dir: state_dir)
316
+
317
+ NoteMove.give_back(from: orphan, to: id, moved: [], state_dir: state_dir)
318
+ FileUtils.rm_rf(dir)
319
+ end
320
+
196
321
  # A failed continue's previous link, back in the lists, with rings that
197
322
  # came in meanwhile: wake it for them, as the ring's own wake would have.
198
323
  private_class_method def self.wake_for_rings(id, state_dir)
@@ -203,26 +328,35 @@ module Samagotchi
203
328
  Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
204
329
  end
205
330
 
206
- # The new link, holding off any worker but its own (StartingMarker.fresh?)
207
- # while +move+ goes to it.
208
- private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:, client_id: nil)
331
+ # The new link, holding off any worker but its own while +move+ goes to
332
+ # it (spawn_session marks it starting before its file is saved:
333
+ # StartingMarker.fresh?).
334
+ # @param notes [Array<String>] gets the names of the notes moved to it
335
+ private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:, notes:,
336
+ session_id:, client_id: nil)
209
337
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
210
338
  start = prompt.to_s.strip.empty? ? nil : prompt
211
339
  preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
212
340
  moved_context = nil
213
341
  before_spawn = lambda do |link|
214
- StartingMarker.mark(link.id, state_dir: state_dir)
215
342
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
216
343
  # Its movable context too, with a note saying what moved and stayed.
217
344
  moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
218
- # Its objectives last: the list reads as the latest word.
345
+ # Its objectives: the list reads as the latest word.
219
346
  Objectives.carry(previous, to: link.id, state_dir: state_dir)
347
+ # Its waiting notes last: the carry above read a pending /objectives
348
+ # edit from them first. They keep their names, so they still sort
349
+ # ahead of the link's own notes. A note sent to the previous link
350
+ # from here on goes to this one (SessionInbox::Head), and a
351
+ # straggler is adopted by its worker (NoteMove.adoption).
352
+ notes.concat(NoteMove.move(from: previous.id, to: link.id, state_dir: state_dir))
220
353
  end
221
354
  link = SessionManager.spawn_session(
222
355
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
223
356
  model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
224
357
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
225
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id
358
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id,
359
+ session_id: session_id
226
360
  )
227
361
  link.moved_children = move
228
362
  link.moved_context = moved_context
@@ -70,7 +70,9 @@ module Samagotchi
70
70
  @stdout.puts("#{short} refused: it is #{OwnerLock::TUI_BUSY}; let it finish first")
71
71
  false
72
72
  rescue SessionManager::DeleteRefused => e
73
- line = if e.reason == :still_stopping then "its worker is still shutting down; try again in a moment"
73
+ line = case e.reason
74
+ when :still_stopping then "its worker is still shutting down; try again in a moment"
75
+ when :scheduled then e.message
74
76
  else "its worker is running (--force stops it first)"
75
77
  end
76
78
  @stdout.puts("#{short} refused: #{line}")
@@ -44,6 +44,20 @@ module Samagotchi
44
44
  # NOTE_MAX_BYTES.
45
45
  class NoteRejected < ArgumentError; end
46
46
 
47
+ # The one delivery path (session_inbox_delivery.rb), loaded on first
48
+ # use: the bundle test kit's load budget stays as it was.
49
+ delivery = File.expand_path("session_inbox_delivery", __dir__)
50
+ %i[Dispatch Delivered Head Resolved WakePolicy].each { |name| autoload name, delivery }
51
+
52
+ # A delivery of +kind+ to session +target+ (Dispatch.deliver: resolve,
53
+ # refuse, the block's write, one presence read, the WakePolicy's wake).
54
+ # @return [Delivered]
55
+ def self.deliver(kind, target, **, &) = Dispatch.deliver(kind, target, **, &)
56
+
57
+ # The wake half of #deliver alone (Dispatch.wake).
58
+ # @return [Delivered]
59
+ def self.wake(kind, target, **) = Dispatch.wake(kind, target, **)
60
+
47
61
  # Queue a turn's input file: JSON with the prompt and the sender's ids.
48
62
  # +delivery+ is only written when it is "queue": a step-boundary
49
63
  # message is the default an older worker assumes, and a cut is done by
@@ -89,11 +103,17 @@ module Samagotchi
89
103
  # @param now [Time] the time the file is named and stamped by (a spec's clock)
90
104
  # @return [String] the note file's path
91
105
  # @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
106
+ # @raise [Session::NotFound] for a session that is gone: no file and no
107
+ # folder (deleted since the writer resolved it), which a note would
108
+ # otherwise bring back as a stray folder (as Session#save skips one)
92
109
  def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil,
93
110
  objectives: nil, now: Time.now)
94
111
  body = checked_text(text)
95
112
  sd = state_dir || Session.default_state_dir
96
- notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
113
+ session_dir = Session.session_dir(session_id, state_dir: sd)
114
+ raise Session::NotFound, session_id unless Dir.exist?(session_dir) || Session.exist?(session_id, state_dir: sd)
115
+
116
+ notes_dir = File.join(session_dir, NOTES_DIR)
97
117
  FileUtils.mkdir_p(notes_dir)
98
118
 
99
119
  # The random part keeps two writers in one nanosecond apart; the
@@ -203,6 +223,11 @@ module Samagotchi
203
223
  # An id that is safe in a file name (a Bridge's or deliver_turn's
204
224
  # uuid); anything else leaves the name bare.
205
225
  ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
226
+ ENQUEUED_ID_UUID = /\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z/i
227
+
228
+ def self.new_enqueued_id = SecureRandom.uuid
229
+
230
+ def self.valid_enqueued_id?(id) = id.is_a?(String) && ENQUEUED_ID_UUID.match?(id)
206
231
 
207
232
  def self.output_name(timestamp, enqueued_id)
208
233
  id = enqueued_id.to_s
@@ -221,6 +246,21 @@ module Samagotchi
221
246
  Dir.children(dir).select { |f| f.end_with?(suffix) }.max
222
247
  end
223
248
 
249
+ # Evidence that a prior worker took this input: a saved or interrupted
250
+ # turn, its reply, or a mid-turn merge record. A restarted worker's
251
+ # in-memory id ledger cannot cover these deliveries.
252
+ def self.input_taken?(session_dir, session, enqueued_id)
253
+ id = enqueued_id.to_s
254
+ return false if id.empty?
255
+ return true if [session.last_turn&.enqueued_id, session.interrupted_turn&.enqueued_id].include?(id)
256
+ return true if reply_for(session_dir, id)
257
+
258
+ require_relative "turn_checkpoint"
259
+ return true if TurnCheckpoint.settled_turn(session_dir, id)
260
+
261
+ !merged_into(session_dir, id).nil?
262
+ end
263
+
224
264
  # The output files' text, oldest first; with +since_time+ only files
225
265
  # modified after it.
226
266
  def self.read_outputs(session_dir, since_time: nil)