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
@@ -6,6 +6,7 @@ require "time"
6
6
 
7
7
  require_relative "../session"
8
8
  require_relative "../session_manager"
9
+ require_relative "../session_continue"
9
10
  require_relative "../schedules"
10
11
  require_relative "../bridge_client"
11
12
  require_relative "../worker_link"
@@ -45,6 +46,7 @@ module Samagotchi
45
46
  # all sessions this often (a one-shot or an older chi's REPL that
46
47
  # opened an old session without saving it has no file signal until its first save).
47
48
  FULL_PROBE_INTERVAL = 10.0
49
+ SCHEDULE_TICK_STOP_WAIT = 5.0
48
50
 
49
51
  # What a subscriber gets: `session` with {session: summary} for an
50
52
  # upsert, `session_gone` with {id:} for a removal, `chi` with
@@ -107,6 +109,8 @@ module Samagotchi
107
109
  @root_cache = {} # folder → project root, for sessions saved before project_root
108
110
  @next_full_probe = nil
109
111
  @next_schedule_tick = nil
112
+ @schedule_tick_in_flight = false
113
+ @schedule_tick_thread = nil
110
114
  @wakeup = Queue.new # stop wakes the tick thread out of its sleep
111
115
  @thread = nil
112
116
  @stopped = false
@@ -129,6 +133,8 @@ module Samagotchi
129
133
  thread = @thread
130
134
  @thread = nil
131
135
  thread&.join(@scan_interval + 1.0)
136
+ schedule_thread = @monitor.synchronize { @schedule_tick_thread }
137
+ schedule_thread&.join(SCHEDULE_TICK_STOP_WAIT)
132
138
  nil
133
139
  end
134
140
 
@@ -143,6 +149,7 @@ module Samagotchi
143
149
  def scan
144
150
  @monitor.synchronize do
145
151
  full = full_probe_due?
152
+ SessionContinue.repair_interrupted(state_dir: @state_dir)
146
153
  scan_files
147
154
  @sessions.each_key { |id| refresh(id, probe: full) }
148
155
  if full
@@ -320,20 +327,42 @@ module Samagotchi
320
327
 
321
328
  # chi web can be the scheduler when the user opts in (schedule.auto_tick):
322
329
  # the same tick cron runs, gated by schedule.tick_interval_minutes and
323
- # sharing schedules.lock, so cron and the hub never both fire.
330
+ # sharing schedules.lock, so cron and the hub never both fire. The scan
331
+ # loop only launches one tick at a time; it does not wait for a firing.
324
332
  def schedule_tick
325
333
  return unless Config.get("schedule.auto_tick")
326
334
 
327
335
  now = @now.call
328
- return if @next_schedule_tick && now < @next_schedule_tick
336
+ @monitor.synchronize do
337
+ return if @schedule_tick_in_flight
338
+ return if @next_schedule_tick && now < @next_schedule_tick
329
339
 
330
- @next_schedule_tick = now + (Samagotchi::Schedules.tick_interval_minutes * 60)
331
- Samagotchi::Schedules.new(state_dir: @state_dir).tick
340
+ @next_schedule_tick = now + (Samagotchi::Schedules.tick_interval_minutes * 60)
341
+ @schedule_tick_in_flight = true
342
+ @schedule_tick_thread = Thread.new { run_schedule_tick }
343
+ end
332
344
  rescue StandardError => e
345
+ @monitor.synchronize do
346
+ if @schedule_tick_in_flight && @schedule_tick_thread.nil?
347
+ @schedule_tick_in_flight = false
348
+ @next_schedule_tick = nil
349
+ end
350
+ end
333
351
  Log.warn(:web, "schedule_tick_failed", error: e.class.name, msg: e.message)
334
352
  nil
335
353
  end
336
354
 
355
+ def run_schedule_tick
356
+ Samagotchi::Schedules.new(state_dir: @state_dir).tick
357
+ rescue StandardError => e
358
+ Log.warn(:web, "schedule_tick_failed", error: e.class.name, msg: e.message)
359
+ ensure
360
+ @monitor.synchronize do
361
+ @schedule_tick_in_flight = false
362
+ @schedule_tick_thread = nil
363
+ end
364
+ end
365
+
337
366
  # A change (the first look too, unless it finds nothing) is a `chi`
338
367
  # event: chi web's terminal line and the open tabs hear it.
339
368
  def check_installed
@@ -342,10 +371,25 @@ module Samagotchi
342
371
 
343
372
  @installed = newest
344
373
  emit("chi", chi_state)
374
+ refresh_worker_compat
345
375
  rescue StandardError => e
346
376
  Log.warn(:web, "installed_check_failed", error: e.class.name, msg: e.message)
347
377
  end
348
378
 
379
+ def refresh_worker_compat
380
+ # An installed-version change can stale a worker without changing its files.
381
+ @sessions.each_value do |entry|
382
+ summary = entry.summary
383
+ next unless summary&.dig(:worker_version)
384
+
385
+ compatibility = Compat.verdict(worker_version: summary[:worker_version], version: @installed || VERSION).to_s
386
+ next if summary[:worker_compat] == compatibility
387
+
388
+ entry.summary = summary.merge(worker_compat: compatibility)
389
+ emit("session", session: entry.summary)
390
+ end
391
+ end
392
+
349
393
  # The state dir's files: a new id is parsed and emitted, an id gone
350
394
  # from disk is dropped, a changed file is re-parsed. Skipped when the
351
395
  # dir's mtime says nothing was renamed in or unlinked since.
@@ -414,7 +458,8 @@ module Samagotchi
414
458
 
415
459
  entry.owner = owner
416
460
  entry.live = live
417
- summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir, registry: hub_registry)
461
+ summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir, registry: hub_registry,
462
+ compat_version: @installed || VERSION)
418
463
  # A new worker (pid) or a new Bridge for the same session is an
419
464
  # event for the page even when the summary reads the same: it
420
465
  # reconnects its stream on it.
@@ -11,6 +11,7 @@ require_relative "../effective_model"
11
11
  require_relative "../host_registry"
12
12
  require_relative "../bridge_client"
13
13
  require_relative "../worker_link"
14
+ require_relative "../compat"
14
15
  require_relative "../session_row"
15
16
  require_relative "../bridge/pending_card"
16
17
 
@@ -26,7 +27,7 @@ module Samagotchi
26
27
  WEB_KEYS = %i[id status mode model_name working_directory created_at updated_at last_prompt short_id test_run
27
28
  used_memory_names preloaded_memory_names muted_memory_names prompt_notes parent_id delegate
28
29
  continues review first_preview title owner recap auto_title ctx_pct tokens memory_index side
29
- archived project_root bridge_up worker_version worker_features pending_question pending_card
30
+ archived project_root bridge_up worker_version worker_features worker_compat pending_question pending_card
30
31
  last_turn verdicts verify_fresh last_verdict].freeze
31
32
 
32
33
  module_function
@@ -45,9 +46,10 @@ module Samagotchi
45
46
  # the smaller of that budget and the window, as the live meter
46
47
  # does). nil: one is built here; a caller that builds many summaries
47
48
  # (the session hub) keeps one and passes it.
49
+ # @param compat_version [String] the newest chi the worker badge compares with
48
50
  # @return [Hash] symbol keys
49
51
  def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil,
50
- registry: nil)
52
+ registry: nil, compat_version: VERSION)
51
53
  link = WorkerLink.resolve(session_dir, unlink: false, owner: owner)
52
54
  up = link.live?
53
55
  sidecar = up ? link.sidecar : nil
@@ -116,6 +118,7 @@ module Samagotchi
116
118
  # nil and [] without one): the page's stale-worker badge.
117
119
  worker_version: sidecar&.version,
118
120
  worker_features: sidecar ? sidecar.features : [],
121
+ worker_compat: sidecar && Compat.verdict(worker_version: sidecar.version, version: compat_version).to_s,
119
122
  # For the tab's notifications (notify.js): an open question, the
120
123
  # running turn's open card with actions, and how the last turn
121
124
  # ended. A question saved by a worker that died is not open, nor a
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "securerandom"
5
5
 
6
6
  require_relative "client_id"
7
+ require_relative "command_intake"
7
8
  require_relative "config"
8
9
  require_relative "engine"
9
10
  require_relative "guardrails/parent_policy"
@@ -285,6 +286,7 @@ module Samagotchi
285
286
  end
286
287
  prune_tombstones
287
288
  settle_interrupted_turn
289
+ settle_stale_status
288
290
  # Before the Bridge serves anything: a UI joining a resumed worker's
289
291
  # stream gets the session's history and status in its snapshot, not
290
292
  # an empty session until the first turn.
@@ -338,7 +340,7 @@ module Samagotchi
338
340
  predecessor: @session.continues)
339
341
  # Its attached context's notes (ContextSources).
340
342
  @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
341
- project_root: @session.project_root,
343
+ project_root: @session.project_root, project_id: @session.project_id,
342
344
  peer_sources: @engine.peer_sources, peer_turns: -> { @wakes.peer_turns })
343
345
  # What comes in between turns, and how each kind is saved.
344
346
  @inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
@@ -349,6 +351,9 @@ module Samagotchi
349
351
  @bridge.queue_command(line, client_id: client_id, after_file: after_file,
350
352
  enqueued_id: enqueued_id, author: author)
351
353
  })
354
+ # Notes its predecessor got after a continue that died before its
355
+ # move, or from a writer that knew no head (ChildMove.adopt's twin).
356
+ @inbound.adopt_notes
352
357
  # /model's default is the config's; the Engine started
353
358
  # on the session's model. nil when there is no default.model.
354
359
  @default_model = ModelProfile.default_model_name
@@ -468,8 +473,8 @@ module Samagotchi
468
473
  # Attached context's commands, in the background; a new text wakes
469
474
  # the loop to absorb it. Not activity: the idle exit stops it.
470
475
  @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
471
- project_root: @session.project_root, cwd: @session.working_directory,
472
- on_change: -> { @waker.wake }).start
476
+ project_root: @session.project_root, project_id: @session.project_id,
477
+ cwd: @session.working_directory, on_change: -> { @waker.wake }).start
473
478
  @scratch_watch = start_scratch_watch if @session.scratch
474
479
  end
475
480
 
@@ -694,6 +699,31 @@ module Samagotchi
694
699
  save_or_log(:interrupted_turn) { save_session }
695
700
  end
696
701
 
702
+ # No turn runs yet: a status a turn would be behind is from before this
703
+ # worker, and goes idle under the lock (resume_session writes nothing to
704
+ # the file, which this worker owns): a stopped one with no stop marker
705
+ # (Session.load reads it stopped while the marker is there; a stop that
706
+ # landed before an earlier worker took the lock saved it so), an error,
707
+ # a running whose worker died. Not a new session's running whose first
708
+ # prompt waits (WorkerInbound.initial_prompt_pending?).
709
+ STALE_STATUSES = [Session::STATUS_STOPPED, Session::STATUS_ERROR, Session::STATUS_RUNNING].freeze
710
+
711
+ #
712
+ # Saved whatever the status: the save stamps updated_at, which a resume
713
+ # used to move (an old session woken with no turn, an attach or a web
714
+ # wake, stays young for the sweep and moves up the lists). Not a
715
+ # stopped one: this worker leaves at once, and would write "stopped".
716
+ def settle_stale_status
717
+ return if Session.stopped_marker?(@session_id, state_dir: @state_dir)
718
+
719
+ status = @session.status
720
+ if STALE_STATUSES.include?(status) && !(status == Session::STATUS_RUNNING && WorkerInbound.initial_prompt_pending?(@session))
721
+ @session.status = Session::STATUS_IDLE
722
+ Log.info(:worker, "stale_status_settled", status: status)
723
+ end
724
+ save_or_log(:worker_start) { save_session }
725
+ end
726
+
697
727
  # The per-input tombstones (removed, settled, merged) older than
698
728
  # SessionInbox::TOMBSTONE_MAX_AGE, once per worker start: they only go
699
729
  # with the session dir otherwise.
@@ -759,14 +789,15 @@ module Samagotchi
759
789
  end
760
790
 
761
791
  # The first prompt (spawn_session's): a delegate child's task, which is
762
- # its parent's although no client sent it. Its turn carries the id
763
- # spawn_session gave it, so a wait for it (--new --wait) names it.
792
+ # its parent's. Its turn carries the id spawn_session gave it, so a
793
+ # wait for it (--new --wait) names it, and its sender
794
+ # (WorkerInbound#initial_origin).
764
795
  # A Verify fresh child's is a Verify turn on its claim (its verdict is
765
796
  # recorded and forwarded to the parent).
766
797
  def run_initial_prompt(prompt)
767
798
  @initial_turn = true
768
799
  fresh = @session.verify_fresh
769
- run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: TurnOrigin.new(enqueued_id: @session.first_turn_id),
800
+ run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: @inbound.initial_origin,
770
801
  anchors: { verify: fresh&.anchor }))
771
802
  ensure
772
803
  @initial_turn = false
@@ -1222,6 +1253,14 @@ module Samagotchi
1222
1253
  end
1223
1254
  end
1224
1255
 
1256
+ # A shell line from a sender that may not run one (CommandIntake: a
1257
+ # delegate's task, a schedule's prompt, a plugin's message): not run,
1258
+ # its command_ran an error with the one refusal text.
1259
+ def shell_refused(command)
1260
+ Log.warn(:worker, "shell_line_refused", client_id: command.client_id)
1261
+ SessionCommands::Result.new(status: :error, output: CommandIntake::REFUSED, changed: [])
1262
+ end
1263
+
1225
1264
  # Whether +command+ asks before a costly /thinking or /reasoning-history
1226
1265
  # change (CacheConfirm): only a person at a web tab or a TUI sees the
1227
1266
  # question; chi send, a delegate, a plugin and a card's action apply it.
@@ -1242,6 +1281,8 @@ module Samagotchi
1242
1281
  # command for the line, is an error Result saying so.
1243
1282
  # @return [SessionCommands::Result]
1244
1283
  def command_result(command)
1284
+ return shell_refused(command) if CommandIntake.refused?(command.line, command.client_id)
1285
+
1245
1286
  yield || SessionCommands::Result.new(status: :error, output: "not a session command", changed: [])
1246
1287
  rescue StandardError => e
1247
1288
  SessionCommands::Result.new(status: :error, output: "#{command.line.split.first}: #{e.message}", changed: [])
@@ -1577,11 +1618,20 @@ module Samagotchi
1577
1618
 
1578
1619
  begin
1579
1620
  input = SessionInbox.read_input(claimed_file)
1580
- prompt = input.prompt.to_s.strip
1581
- # Which turn took it, on disk before its file goes (a wait for
1582
- # it reads this, not an inference: ReplyWait::Poll#follow_merge).
1583
- SessionInbox.mark_merged(@session_dir, input.origin.enqueued_id, into: @turn_enqueued_id) unless prompt.empty?
1584
- prompt.empty? ? nil : [prompt, input.origin]
1621
+ enqueued_id = input.origin.enqueued_id
1622
+ if SessionInbox.input_taken?(@session_dir, @session, enqueued_id) || !@engine.claim_enqueued_id(enqueued_id)
1623
+ Log.info(:worker, "duplicate_input_dropped", enqueued_id: enqueued_id)
1624
+ next
1625
+ end
1626
+ begin
1627
+ prompt = input.prompt.to_s.strip
1628
+ # Which turn took it, on disk before its file goes (a wait for
1629
+ # it reads this, not an inference: ReplyWait::Poll#follow_merge).
1630
+ SessionInbox.mark_merged(@session_dir, enqueued_id, into: @turn_enqueued_id) unless prompt.empty?
1631
+ prompt.empty? ? nil : [prompt, input.origin]
1632
+ ensure
1633
+ @engine.finish_enqueued_id(enqueued_id)
1634
+ end
1585
1635
  ensure
1586
1636
  FileUtils.rm_f(claimed_file)
1587
1637
  end
@@ -93,7 +93,7 @@ module Samagotchi
93
93
  # wakes a new worker, as after an idle exit.
94
94
  exit(0) if result == :stopped
95
95
  if result == :signal
96
- SessionManager.resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
96
+ respawn(session_id, state_dir: sd, session_dir: session_dir, result: result) unless SessionInbox.find_new_input_files(session_dir).empty?
97
97
  exit(0)
98
98
  end
99
99
  exit(1) if result == :crashed
@@ -103,21 +103,31 @@ module Samagotchi
103
103
  # A restart starts its successor here, on the newest chi installed
104
104
  # (worker_command), once this one's lock is free.
105
105
  # Delegate reports that rang while it left wake one too.
106
- if result == :restart && Session.stopped_marker?(session_id, state_dir: sd)
107
- # A stop landed as it left: no successor (resume_session would clear
108
- # the stop), and the reminders go with it.
109
- ReminderCarry.remove(session_dir)
110
- Log.info(:worker, "restart_dropped", why: :stopped)
111
- elsif result == :restart ||
112
- (%i[idle_exit exit_requested].include?(result) &&
106
+ if result == :restart ||
107
+ (%i[idle_exit exit_requested].include?(result) &&
113
108
  (!SessionInbox.find_new_input_files(session_dir).empty? || worker.wake_due?))
114
- SessionManager.resume_session(session_id, state_dir: sd)
109
+ respawn(session_id, state_dir: sd, session_dir: session_dir, result: result)
115
110
  elsif worker.discard?
116
111
  discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
117
112
  end
118
113
  result
119
114
  end
120
115
 
116
+ # Start this session's next worker as this one leaves (+result+), on
117
+ # the session inbox's leave row (SessionInbox::WakePolicy), read on a
118
+ # fresh WorkerPresence now the lock is free: a stop that landed as it
119
+ # left wins for every way out (resume_session would clear it; a
120
+ # restart's reminders go with it), and a worker that took the session
121
+ # since, or one being started, gets no second one. What waits (input,
122
+ # rings) stays on disk for the next start.
123
+ private def respawn(session_id, state_dir:, session_dir:, result:)
124
+ decided = SessionInbox::WakePolicy.for(:leave).decide(WorkerPresence.read(session_id, state_dir: state_dir))
125
+ return SessionManager.resume_session(session_id, state_dir: state_dir) if decided == :start
126
+
127
+ ReminderCarry.remove(session_dir) if result == :restart && decided == :stopped
128
+ Log.info(:worker, result == :restart ? "restart_dropped" : "respawn_dropped", why: decided, reason: result)
129
+ end
130
+
121
131
  # A spawned worker leaves its spawner's process tree: a killer that
122
132
  # walks the tree by parent pid (a terminal closing, a tool's teardown
123
133
  # of a `chi send --wait` it ran in the background) no longer reaches it.
@@ -125,8 +135,9 @@ module Samagotchi
125
135
  # forks the worker, which starts a session of its own (setsid: no
126
136
  # controlling terminal, no SIGHUP from one) and returns to run the loop;
127
137
  # this one waits until the worker holds the session's OwnerLock, or has
128
- # exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
129
- # stays alive exactly while the worker starts (#still_starting?). The
138
+ # exited, or WORKER_START_GRACE passed, and exits: this process (the
139
+ # pid the session's starting marker names, StartingMarker) lives
140
+ # exactly while the worker starts (WorkerPresence#starting?). The
130
141
  # worker is reparented to init (launchd), which reaps it.
131
142
  private def detach_from_spawner(session_dir)
132
143
  worker = Process.fork
@@ -6,9 +6,11 @@ require "time"
6
6
 
7
7
  require_relative "child_reports"
8
8
  require_relative "client_id"
9
+ require_relative "command_intake"
9
10
  require_relative "config"
10
11
  require_relative "context_note"
11
12
  require_relative "log"
13
+ require_relative "note_move"
12
14
  require_relative "objectives"
13
15
  require_relative "session"
14
16
  require_relative "session_inbox"
@@ -107,6 +109,21 @@ module Samagotchi
107
109
  @queue_command = queue_command
108
110
  @initial_prompt_taken = false
109
111
  @context_wake_waiting = false
112
+ # The link this one continues, when its notes may still come here
113
+ # (NoteMove.adoption): one listing, at the start.
114
+ @note_adoption = NoteMove.adoption(session, state_dir: state_dir)
115
+ end
116
+
117
+ # Take the notes written to the link this session continues since it
118
+ # was continued (NoteMove::Adoption): a writer that resolved the old id
119
+ # just before this link was saved, an older chi's. Each pass, before
120
+ # the notes are read; a failure is logged, the pass goes on.
121
+ # @return [Array<String>] the note files adopted
122
+ def adopt_notes
123
+ @note_adoption ? @note_adoption.call : []
124
+ rescue StandardError => e
125
+ Log.warn(:worker, "notes_adopt_failed", sid: @session.id[0, 8], error: e.class.name, msg: e.message)
126
+ []
110
127
  end
111
128
 
112
129
  # Add the queued context notes to the conversation (between turns only,
@@ -118,7 +135,9 @@ module Samagotchi
118
135
  # starts a turn nor keeps an idle worker up. Of the objectives notes in
119
136
  # a batch only the newest goes in: each is a whole list, so the older
120
137
  # ones (edits made during a turn) would only be sent for nothing.
138
+ # The predecessor's late notes come first (#adopt_notes).
121
139
  def absorb_notes
140
+ adopt_notes
122
141
  files = SessionInbox.find_new_note_files(@session_dir)
123
142
  return if files.empty? || @stopped.call
124
143
 
@@ -196,14 +215,20 @@ module Samagotchi
196
215
  # spawn_session hands the first prompt over in last_prompt, but
197
216
  # last_prompt also records every later turn's prompt (and mark_error's
198
217
  # reason), so only a session with no conversation yet has one pending; a
199
- # resumed session must not replay its last turn. Taken once.
218
+ # resumed session must not replay its last turn. A context note may
219
+ # have come before the first prompt ran.
220
+ # @param session [Session]
221
+ def self.initial_prompt_pending?(session)
222
+ session.messages.all? { |m| ContextNote.note?(m) } && !session.last_prompt.to_s.strip.empty?
223
+ end
224
+
225
+ # The first prompt (.initial_prompt_pending?), taken once.
200
226
  # @return [String, nil]
201
227
  def take_initial_prompt
202
228
  return nil if @initial_prompt_taken
203
229
 
204
230
  @initial_prompt_taken = true
205
- # A context note may have come before the first prompt ran.
206
- return nil unless @session.messages.all? { |m| ContextNote.note?(m) } && !@session.last_prompt.to_s.strip.empty?
231
+ return nil unless self.class.initial_prompt_pending?(@session)
207
232
 
208
233
  prompt = @session.last_prompt
209
234
  @session.last_prompt = ""
@@ -211,11 +236,24 @@ module Samagotchi
211
236
  prompt
212
237
  end
213
238
 
239
+ # The first prompt's origin: the turn id spawn_session gave it (a wait
240
+ # for it, --new --wait, names it) and who sent it when no person did
241
+ # (Session#first_turn_client_id: a delegating parent, a schedule). A
242
+ # delegate child's task is its parent's even without the field (one
243
+ # spawned by an older chi, or by a caller that names no sender). NONE
244
+ # for a person's session from before either field.
245
+ # @return [TurnOrigin]
246
+ def initial_origin
247
+ client_id = @session.first_turn_client_id
248
+ client_id ||= ClientId.delegate(@session.parent_id) if @session.delegate? && @session.parent_id
249
+ TurnOrigin.of(client_id: client_id, enqueued_id: @session.first_turn_id)
250
+ end
251
+
214
252
  # The first prompt as a command (queued): the session was saved as
215
253
  # running for a turn that won't run.
216
254
  # @return [Boolean] whether it was one
217
255
  def initial_command?(prompt)
218
- return false unless queue_as_command(prompt, TurnOrigin::NONE)
256
+ return false unless queue_as_command(prompt, initial_origin)
219
257
 
220
258
  @session.status = Session::STATUS_IDLE
221
259
  save(:initial_command, unless_stopped: true)
@@ -231,11 +269,20 @@ module Samagotchi
231
269
 
232
270
  begin
233
271
  input = SessionInbox.read_input(claimed_file)
234
- return if input.prompt.to_s.strip.empty?
235
- return if input.images.empty? && queue_as_command(input.prompt, input.origin, File.basename(input_file))
236
-
237
- yield Prompt.new(text: input.prompt, origin: input.origin, no_interrupt: input.no_interrupt, images: input.images,
238
- delivery: input.delivery, anchors: input.anchors)
272
+ enqueued_id = input.origin.enqueued_id
273
+ if SessionInbox.input_taken?(@session_dir, @session, enqueued_id) || !@engine.claim_enqueued_id(enqueued_id)
274
+ Log.info(:worker, "duplicate_input_dropped", enqueued_id: enqueued_id)
275
+ return
276
+ end
277
+ begin
278
+ return if input.prompt.to_s.strip.empty?
279
+ return if input.images.empty? && queue_as_command(input.prompt, input.origin, File.basename(input_file))
280
+
281
+ yield Prompt.new(text: input.prompt, origin: input.origin, no_interrupt: input.no_interrupt, images: input.images,
282
+ delivery: input.delivery, anchors: input.anchors)
283
+ ensure
284
+ @engine.finish_enqueued_id(enqueued_id)
285
+ end
239
286
  ensure
240
287
  FileUtils.rm_f(claimed_file)
241
288
  end
@@ -264,7 +311,11 @@ module Samagotchi
264
311
  # POST /turn; an unknown /word stays a prompt. Queued: the loop's next
265
312
  # pass runs it. A participant's is refused (dropped, with its audit
266
313
  # line): commands and shell lines are the owner's (chi web and the
267
- # Bridge refuse them first; this is the worker's own layer).
314
+ # Bridge refuse them first; this is the worker's own layer). From a
315
+ # sender that may not send commands (CommandIntake.allowed?: a
316
+ # delegate, a schedule, a plugin...) a /word line is a prompt, and a
317
+ # shell line is queued for Worker#run_command_line to refuse, so its
318
+ # sender and the UIs see the refusal as the command's error.
268
319
  # @return [Boolean] whether +text+ was one
269
320
  def queue_as_command(text, origin, after_file = nil)
270
321
  return false unless @engine.command_registry.command?(text.to_s)
@@ -275,6 +326,7 @@ module Samagotchi
275
326
  what: "command", reason: "not_permitted")
276
327
  return true
277
328
  end
329
+ return false unless CommandIntake.allowed?(origin.client_id) || CommandIntake.shell_line?(text)
278
330
 
279
331
  @queue_command.call(text, origin.client_id, after_file, origin.enqueued_id)
280
332
  true
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "time"
5
5
  require_relative "owner_lock"
6
6
  require_relative "version"
7
+ require_relative "compat"
7
8
  require_relative "worker_sidecar"
8
9
  require_relative "bridge_client"
9
10
  require_relative "chi_invocation"
@@ -137,7 +138,9 @@ module Samagotchi
137
138
  return Starting.new(owner: owner, stuck: false) unless owner.worker?
138
139
  return Leaving.new(owner: owner) if owner.leaving?
139
140
  return Starting.new(owner: owner, stuck: stuck?(owner)) unless ours?(sidecar, owner)
140
- return Skew.new(sidecar: sidecar, owner: owner, reason: :protocol) if sidecar.protocol.to_i > PROTOCOL
141
+ if Compat.verdict(worker_version: sidecar.version, worker_protocol: sidecar.protocol, protocol: PROTOCOL) == :refused
142
+ return Skew.new(sidecar: sidecar, owner: owner, reason: :protocol)
143
+ end
141
144
 
142
145
  Live.new(sidecar: sidecar, owner: owner)
143
146
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "owner_lock"
5
+ require_relative "worker_link"
6
+ require_relative "starting_marker"
7
+ require_relative "archive_store"
8
+
9
+ module Samagotchi
10
+ # Whether a session's worker is there, coming or held off, read once for
11
+ # one decision: the one reader of the liveness facts that the deciders
12
+ # ("start a worker for this session?", "is one up?") combine. Each fact is
13
+ # read from disk by WorkerPresence.read; the predicates are named after what
14
+ # the deciders ask, and each decider keeps its own subset and order (the
15
+ # session inbox's WakePolicy is the table of most of them).
16
+ #
17
+ # "Stopped" is #stop_marked?, the `<id>/stopped` marker (a `chi sessions
18
+ # stop`; a resume removes it): no decider reads the session file's status
19
+ # (turn state). Nor is the file read here: the one decider that needs it
20
+ # (a scratch session, WakePolicy#decide) reads it last, itself.
21
+ #
22
+ # @!attribute session_id [String]
23
+ # @!attribute exists [Boolean] its session file is there (Session.exist?)
24
+ # @!attribute owner [OwnerLock::Owner, nil] the owner lock's holder: a
25
+ # worker, a one-turn chi run (kind tui) or nil when free
26
+ # @!attribute stop_marked [Boolean] the `stopped` marker (Session.stopped_marker?)
27
+ # @!attribute starting_marked [Boolean] the `starting` marker, fresh
28
+ # (StartingMarker.fresh?): a worker is being started for it (every spawn
29
+ # writes it, until the worker holds the lock)
30
+ # @!attribute archived [Boolean] ArchiveStore.archived?
31
+ # @!attribute link [WorkerLink::Live, WorkerLink::Starting, WorkerLink::Leaving,
32
+ # WorkerLink::None, WorkerLink::Stale, WorkerLink::Skew, nil] the Bridge
33
+ # outcome, when read (+link:+), else nil
34
+ WorkerPresence = Data.define(:session_id, :exists, :owner, :stop_marked, :starting_marked, :archived, :link) do
35
+ # Read the facts for +session_id+ now.
36
+ # @param state_dir [String, nil] nil: Session.default_state_dir
37
+ # @param owner [OwnerLock::Owner, nil, :read, #call] the lock's holder as
38
+ # the caller already read it (SessionManager.session_owner, a stand-in
39
+ # manager's), or a reader of it called only for a valid id; :read reads
40
+ # it here (OwnerLock.owner)
41
+ # @param link [nil, :look, :unlink] also resolve the WorkerLink:
42
+ # :look leaves a stale sidecar, :unlink removes it (WorkerLink.resolve,
43
+ # given the owner read here or by the caller: one moment's snapshot)
44
+ # @return [WorkerPresence]
45
+ def self.read(session_id, state_dir: nil, owner: :read, link: nil)
46
+ return absent(session_id, link: link) unless Session.valid_id?(session_id)
47
+
48
+ owner = owner.call if owner.respond_to?(:call)
49
+ sd = state_dir || Session.default_state_dir
50
+ dir = Session.session_dir(session_id, state_dir: sd)
51
+ owner = OwnerLock.owner(dir) if owner == :read
52
+ new(session_id: session_id,
53
+ exists: Session.exist?(session_id, state_dir: sd),
54
+ owner: owner,
55
+ stop_marked: Session.stopped_marker?(session_id, state_dir: sd),
56
+ starting_marked: StartingMarker.fresh?(session_id, state_dir: sd),
57
+ archived: ArchiveStore.archived?(dir),
58
+ link: link && WorkerLink.resolve(dir, unlink: link == :unlink, owner: owner))
59
+ end
60
+
61
+ # An id that isn't one names no session: nothing on disk is read
62
+ # (Session.session_dir would raise), and a link asked for is None.
63
+ # @return [WorkerPresence]
64
+ def self.absent(session_id, link: nil)
65
+ new(session_id: session_id, exists: false, owner: nil, stop_marked: false, starting_marked: false,
66
+ archived: false, link: link && WorkerLink::None.new)
67
+ end
68
+
69
+ def initialize(session_id:, exists:, owner:, stop_marked:, starting_marked:, archived:, link: nil) = super
70
+
71
+ def exists? = exists
72
+ def archived? = archived
73
+
74
+ # Someone holds the owner lock: a worker or a one-turn chi run.
75
+ def owned? = !owner.nil?
76
+
77
+ # A one-turn chi run (`chi -p X --non-interactive`, kind tui) holds it:
78
+ # nothing else may resume, stop or delete it.
79
+ def tui_owned? = owner&.tui? == true
80
+
81
+ # The `<id>/stopped` marker: a user's stop, not the turn's status.
82
+ def stop_marked? = stop_marked
83
+
84
+ # A worker is being started for it: the fresh `starting` marker
85
+ # (StartingMarker) a spawn writes until its worker holds the lock.
86
+ def starting? = starting_marked
87
+
88
+ # A worker being started (#starting?, from any process) may not hold the
89
+ # lock yet, and no stop came since: a stop cancels the hold-off (a
90
+ # resume after it clears the stop and spawns; the starting worker and
91
+ # the new one race the lock, one wins). Of resumes after one stop, one
92
+ # spawns: resume_session's claim takes over only a start marked before
93
+ # the stop.
94
+ def start_pending? = starting? && !stop_marked?
95
+
96
+ # resume_session's hold-off, after the owner: a start pending.
97
+ def resume_held_off? = start_pending?
98
+
99
+ # The Bridge answers (WorkerLink::Live). False when the link wasn't read.
100
+ def live? = link&.live? == true
101
+
102
+ # A worker holds the lock, Bridge or not (Live, Starting, Leaving, Skew).
103
+ # False when the link wasn't read.
104
+ def worker_owned? = link&.worker_owned? == true
105
+ end
106
+ end
@@ -8,8 +8,7 @@ module Samagotchi
8
8
  # A worker's bridge.json in its session directory: how clients reach its
9
9
  # Bridge (port, bind), when it started (a new worker behind the same
10
10
  # "worker" owner), the chi it runs (chi update, the web's stale-worker
11
- # badge), the input-file format it reads (SessionInbox::INPUT_FORMAT) and
12
- # what else it can do (Bridge::FEATURES), and its Bridge's epoch (the
11
+ # badge), what else it can do (Bridge::FEATURES), and its Bridge's epoch (the
13
12
  # one worker identity: the epoch in every event id, so a remote client
14
13
  # tells a restarted worker apart; nil from a worker before it), and its
15
14
  # Bridge's secret (every request names it in X-Chi-Bridge, Bridge#secret_ok?;
@@ -57,12 +56,12 @@ module Samagotchi
57
56
 
58
57
  # Written whole or not at all, readable by this user only (0600 from the
59
58
  # moment the file exists, AtomicFile: no other account learns the
60
- # worker's port or its secret from it); input_format, epoch, secret, pid
59
+ # worker's port or its secret from it); epoch, secret, pid
61
60
  # and protocol only when they name one.
62
61
  def write(session_dir)
63
62
  FileUtils.mkdir_p(session_dir)
64
63
  record = to_h.transform_keys(&:to_s)
65
- record.delete("input_format") if input_format.nil?
64
+ record.delete("input_format")
66
65
  record.delete("epoch") if epoch.nil?
67
66
  record.delete("secret") if secret.nil?
68
67
  record.delete("pid") if pid.nil?