samagotchi 0.159.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -673,7 +673,7 @@ module Samagotchi
673
673
  return false
674
674
  end
675
675
  unless result.accepted?
676
- @info.puts("#{session.id} #{refused_word(result)}: #{result.detail || "could not queue it"}")
676
+ @info.puts("#{session.id} #{result.refusal_text}")
677
677
  return false
678
678
  end
679
679
 
@@ -685,19 +685,6 @@ module Samagotchi
685
685
 
686
686
  SENT_COMMAND = "sent as a session command"
687
687
 
688
- # A remote's refusals of what this machine checks before sending (a
689
- # parent's /continue yes, a loosening /parent-answers, images to a
690
- # model that can't see them): its turn route's error codes.
691
- REMOTE_REFUSALS = %w[parent_answers_refused parent_continue_refused no_vision].freeze
692
-
693
- # How a delivery that didn't go in reads: refused (a worker older than
694
- # queueing would steer it; a remote refused it as chi send refuses it
695
- # here) or failed.
696
- def refused_word(result)
697
- refused = result.error == Delivery::QUEUE_UNAVAILABLE["error"] || REMOTE_REFUSALS.include?(result.error)
698
- refused ? "refused" : "failed"
699
- end
700
-
701
688
  # chi send's client id, or chi answer's for a parent agent's /continue
702
689
  # yes (the worker holds that to its guardrails.parent_answers).
703
690
  def client_id_for(prompt) = ParentGate.parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
@@ -773,7 +760,7 @@ module Samagotchi
773
760
  end
774
761
  result = @access.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, delivery: delivery)
775
762
  unless result.accepted?
776
- @info.puts("#{short} #{refused_word(result)}: #{result.detail || "could not queue it"}")
763
+ @info.puts("#{short} #{result.refusal_text}")
777
764
  return false
778
765
  end
779
766
 
@@ -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"
@@ -166,6 +167,11 @@ module Samagotchi
166
167
  # deleted folder no longer leads to its repository. nil outside a repo,
167
168
  # and in files written before the field existed (see #project_root).
168
169
  SessionField.plain("project_root", nil),
170
+ # The project's id (ProjectId: a marked project's `id:`), saved beside
171
+ # project_root at creation, else nil: its memories and project context
172
+ # sources are keyed by it (MemoryPaths.key_for), so a session resumed
173
+ # after its folder moved finds them under the id, not its dead path.
174
+ SessionField.plain("project_id", nil, optional: true),
169
175
  # Memories the session was started with (--memory) and memories hidden
170
176
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
171
177
  # Names as given; the engine normalizes them.
@@ -271,6 +277,14 @@ module Samagotchi
271
277
  # chi web's create) can name it (`--wait --turn T ID`). nil for a
272
278
  # session created idle, and for one saved before the field existed.
273
279
  SessionField.plain("first_turn_id", nil, optional: true),
280
+ # Who sent that prompt when no person did (a ClientId: a delegating
281
+ # parent's delegate:<parent8>, a schedule's chain link
282
+ # system:schedule:<id>): the first turn's origin, so the command
283
+ # intake treats it as theirs (CommandIntake) and last_turn names
284
+ # them. nil for a person's prompt (chi send --new, the web, a TUI, a
285
+ # continue) and an idle session. Set once, by spawn_session; a
286
+ # continue and a fork don't carry it.
287
+ SessionField.plain("first_turn_client_id", nil, optional: true),
274
288
  # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
275
289
  # nil for any other session. Set once, at spawn (VerifyFresh.start).
276
290
  # Autoloaded on the first one (the bundle test kit's load budget).
@@ -290,7 +304,7 @@ module Samagotchi
290
304
  attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
291
305
  :created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
292
306
  :used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
293
- :continues, :first_turn_id
307
+ :continues, :first_turn_id, :first_turn_client_id, :project_id
294
308
  attr_writer :project_root, :delegate
295
309
  attr_reader :appended_prompt_files, :written_by
296
310
 
@@ -472,6 +486,10 @@ module Samagotchi
472
486
  @archived = false
473
487
  # #save's lock: one snapshot-and-write at a time (see #save).
474
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
475
493
  end
476
494
 
477
495
  # Whether a chi newer than this one saved the loaded file: its
@@ -566,12 +584,12 @@ module Samagotchi
566
584
  # @raise [Toolkit::Refused] its toolkit (setup's, else the configured
567
585
  # one SessionManager.spawn_session gives it) builds on a config kit
568
586
  # with an invalid groups: (Toolkit.check_new!)
569
- def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
570
- 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: [],
571
588
  scratch: false, model_typed: nil, delegate: false, setup: nil, continues: nil,
572
589
  review: nil, now: Time.now)
573
590
  own = setup.to_h
574
591
  own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
592
+ project = ProjectScope.project_for(working_directory)
575
593
  Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
576
594
  now = now.iso8601(3)
577
595
  resolved_test = if test_run.nil?
@@ -591,9 +609,8 @@ module Samagotchi
591
609
  status: STATUS_IDLE,
592
610
  first_preview: "",
593
611
  test_run: resolved_test,
594
- project_root: ProjectScope.root_for(working_directory),
595
- preloaded_memory_names: preloaded_memory_names,
596
- muted_memory_names: muted_memory_names,
612
+ project_root: project&.root,
613
+ project_id: project&.id,
597
614
  parent_id: parent_id,
598
615
  scratch: scratch,
599
616
  delegate: delegate,
@@ -621,6 +638,7 @@ module Samagotchi
621
638
  raise NotFound, session_id unless File.exist?(path)
622
639
 
623
640
  session = from_h(JSON.parse(File.read(path)))
641
+ session.persisted!
624
642
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
625
643
  session.parent_id = parent_override(session.id, state_dir: state_dir) || session.parent_id
626
644
  session
@@ -749,6 +767,7 @@ module Samagotchi
749
767
  return nil unless valid_id?(data["id"])
750
768
 
751
769
  session = from_h(data, messages: false)
770
+ session.persisted!
752
771
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
753
772
  session.parent_id = parent_override(session.id, state_dir: File.dirname(path)) || session.parent_id
754
773
  session
@@ -756,6 +775,10 @@ module Samagotchi
756
775
  nil
757
776
  end
758
777
 
778
+ # This session was read from its file (#save then refuses to recreate
779
+ # a deleted one).
780
+ def persisted! = (@persisted = true)
781
+
759
782
  # Persist the session atomically. Updates +updated_at+ in place.
760
783
  #
761
784
  # One save at a time per Session: a worker saves its session from the
@@ -767,6 +790,11 @@ module Samagotchi
767
790
  # Never call it from a trap handler (Mutex#lock raises ThreadError
768
791
  # there; WorkerSignals saves on a thread of its own) or from inside a
769
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.
770
798
  # @param now [Time, nil] the updated_at to stamp (a spec's clock); nil,
771
799
  # the time the lock is taken
772
800
  def save(state_dir: self.class.default_state_dir, now: nil)
@@ -781,7 +809,13 @@ module Samagotchi
781
809
  # Auto-compute first_preview if not yet cached and messages contain a user entry.
782
810
  compute_first_preview!
783
811
 
812
+ if @persisted && !File.exist?(path)
813
+ Log.warn(:worker, "save_skipped_deleted", sid: @id)
814
+ return self
815
+ end
816
+
784
817
  AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
818
+ @persisted = true
785
819
  end
786
820
  self
787
821
  end
@@ -845,7 +879,25 @@ module Samagotchi
845
879
 
846
880
  dir = session_dir(session_id, state_dir: state_dir)
847
881
  FileUtils.mkdir_p(dir)
848
- 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
849
901
  end
850
902
 
851
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
@@ -353,7 +351,8 @@ module Samagotchi
353
351
  def context_candidates(target)
354
352
  return [] if target.project?
355
353
 
356
- ContextCandidates.with_status(target.session_id, project_root: target.project_root, state_dir: @state_dir)
354
+ ContextCandidates.with_status(target.session_id, project_root: target.project_root, project_id: target.project_id,
355
+ state_dir: @state_dir)
357
356
  .map { |candidate, status| ContextCandidate.new(candidate: candidate, status: status) }
358
357
  end
359
358
 
@@ -371,7 +370,9 @@ module Samagotchi
371
370
  ContextSources.check_name!(name)
372
371
  return :removed if context_location(target).remove(name)
373
372
 
374
- project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
373
+ project = !target.project? && ContextSources.project_location_for(target.project_root,
374
+ project_id: target.project_id,
375
+ state_dir: @state_dir)
375
376
  project&.source(name) ? :project : :missing
376
377
  end
377
378
 
@@ -394,7 +395,7 @@ module Samagotchi
394
395
  # live in.
395
396
  def context_location(target)
396
397
  if target.project?
397
- ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
398
+ ContextSources.project_location_for(target.project_root, project_id: target.project_id, state_dir: @state_dir)
398
399
  else
399
400
  ContextSources.session_location(target.session_id, state_dir: @state_dir)
400
401
  end
@@ -465,6 +466,36 @@ module Samagotchi
465
466
 
466
467
  private
467
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
+
468
499
  def grants = @grants ||= Grants.new(Grants.path(state_dir: state_dir!))
469
500
 
470
501
  LOCAL_PROBE_TIMEOUT = 1.0
@@ -534,8 +565,8 @@ module Samagotchi
534
565
  end
535
566
 
536
567
  def attached_to(target, shadowed: false)
537
- ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
538
- shadowed: shadowed)
568
+ ContextSources.attached(target.session_id, project_root: target.project_root, project_id: target.project_id,
569
+ state_dir: @state_dir, shadowed: shadowed)
539
570
  end
540
571
 
541
572
  # The block's post to the session's live worker, again on a 408 (the
@@ -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"
@@ -809,7 +816,8 @@ module Samagotchi
809
816
  owner = json["owner"] && OwnerLock::Owner.new(pid: nil, kind: json["owner"].to_s, started_at: nil)
810
817
  SessionView.new(id: json["id"], status: json["status"], title: json["title"],
811
818
  display_title: json["display_title"] || json["title"] || json["first_preview"].to_s,
812
- project_root: json["project_root"], working_directory: json["working_directory"],
819
+ project_root: json["project_root"], project_id: json["project_id"],
820
+ working_directory: json["working_directory"],
813
821
  model_name: json["model_name"], model_typed: json["model_typed"],
814
822
  parent_answers: json["parent_answers"], owner: owner,
815
823
  pending_question: PendingQuestion.from_file(pending))
@@ -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
@@ -166,7 +166,10 @@ module Samagotchi
166
166
  # @!attribute owner [OwnerLock::Owner, nil] its worker or one-turn chi run
167
167
  # @!attribute pending_question [PendingQuestion, nil] the file's
168
168
  SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
169
- :model_typed, :parent_answers, :owner, :pending_question)
169
+ :model_typed, :parent_answers, :owner, :pending_question, :project_id) do
170
+ def initialize(id:, status:, title:, display_title:, project_root:, working_directory:, model_name:,
171
+ model_typed:, parent_answers:, owner:, pending_question:, project_id: nil) = super
172
+ end
170
173
 
171
174
  # What chi send --new created (Remote; Local gives the Session): its
172
175
  # id, a model warning, the session it continues, the delegates it took
@@ -268,9 +271,15 @@ module Samagotchi
268
271
  # chi context push's outcome: :pushed (+before+ the revision it had,
269
272
  # +revision+ the new one), :missing (no such source), :not_push (a
270
273
  # command source, whose text is its command's) or :continued (the
271
- # session was continued, as +next_id+: nothing was written).
272
- ContextPush = Data.define(:status, :before, :revision, :next_id) do
273
- 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]
274
283
 
275
284
  # Why a push to +name+ was refused as :not_push: a command source's
276
285
  # text is its command's, and a push between two fetches would read
@@ -286,5 +295,12 @@ module Samagotchi
286
295
  "attach it to #{next_id} (or pair again with #{next_id}) and push there"
287
296
  end
288
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
289
305
  end
290
306
  end
@@ -473,7 +473,8 @@ module Samagotchi
473
473
 
474
474
  peers = Tools::Peers.new(session_id: session.id, cwd: session.working_directory, state_dir: @engine.peer_state_dir)
475
475
  listing = Tools::ContextRead.call("", peers: peers)
476
- candidates = ContextCandidates.listing(session.id, project_root: session.project_root, state_dir: @engine.peer_state_dir)
476
+ candidates = ContextCandidates.listing(session.id, project_root: session.project_root, project_id: session.project_id,
477
+ state_dir: @engine.peer_state_dir)
477
478
  candidates ? "#{listing}\n\n#{candidates}" : listing
478
479
  end
479
480
 
@@ -618,9 +619,12 @@ module Samagotchi
618
619
 
619
620
  def approval_line(entry)
620
621
  what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
621
- where = if entry["scope"] == "session" then "session #{entry["session_id"].to_s[0, 8]}"
622
+ where = if entry["scope"] == "session"
623
+ place = entry["repo_root"] ? " in #{entry["repo_root"]}" : ""
624
+ "session #{entry["session_id"].to_s[0, 8]}#{place}#{" with its env:" if entry["env"]}"
622
625
  elsif entry["repo"] then "in repo #{entry["repo"]}"
623
- else "in #{entry["repo_root"]}"
626
+ else
627
+ "in #{entry["repo_root"]}"
624
628
  end
625
629
  rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
626
630
  "#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"