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
@@ -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
@@ -286,6 +286,7 @@ module Samagotchi
286
286
  end
287
287
  prune_tombstones
288
288
  settle_interrupted_turn
289
+ settle_stale_status
289
290
  # Before the Bridge serves anything: a UI joining a resumed worker's
290
291
  # stream gets the session's history and status in its snapshot, not
291
292
  # an empty session until the first turn.
@@ -350,6 +351,9 @@ module Samagotchi
350
351
  @bridge.queue_command(line, client_id: client_id, after_file: after_file,
351
352
  enqueued_id: enqueued_id, author: author)
352
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
353
357
  # /model's default is the config's; the Engine started
354
358
  # on the session's model. nil when there is no default.model.
355
359
  @default_model = ModelProfile.default_model_name
@@ -695,6 +699,31 @@ module Samagotchi
695
699
  save_or_log(:interrupted_turn) { save_session }
696
700
  end
697
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
+
698
727
  # The per-input tombstones (removed, settled, merged) older than
699
728
  # SessionInbox::TOMBSTONE_MAX_AGE, once per worker start: they only go
700
729
  # with the session dir otherwise.
@@ -1217,8 +1246,6 @@ module Samagotchi
1217
1246
  # the cards and notices it showed, held: they follow its command_ran,
1218
1247
  # as its output
1219
1248
  def run_command_line(command)
1220
- return [shell_refused(command), []] if CommandIntake.refused?(command.line, command.client_id)
1221
-
1222
1249
  @engine.holding_announcements do
1223
1250
  command_result(command) do
1224
1251
  @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command), user_ui: user_ui?(command))
@@ -1254,6 +1281,8 @@ module Samagotchi
1254
1281
  # command for the line, is an error Result saying so.
1255
1282
  # @return [SessionCommands::Result]
1256
1283
  def command_result(command)
1284
+ return shell_refused(command) if CommandIntake.refused?(command.line, command.client_id)
1285
+
1257
1286
  yield || SessionCommands::Result.new(status: :error, output: "not a session command", changed: [])
1258
1287
  rescue StandardError => e
1259
1288
  SessionCommands::Result.new(status: :error, output: "#{command.line.split.first}: #{e.message}", changed: [])
@@ -1589,11 +1618,20 @@ module Samagotchi
1589
1618
 
1590
1619
  begin
1591
1620
  input = SessionInbox.read_input(claimed_file)
1592
- prompt = input.prompt.to_s.strip
1593
- # Which turn took it, on disk before its file goes (a wait for
1594
- # it reads this, not an inference: ReplyWait::Poll#follow_merge).
1595
- SessionInbox.mark_merged(@session_dir, input.origin.enqueued_id, into: @turn_enqueued_id) unless prompt.empty?
1596
- 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
1597
1635
  ensure
1598
1636
  FileUtils.rm_f(claimed_file)
1599
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
@@ -10,6 +10,7 @@ require_relative "command_intake"
10
10
  require_relative "config"
11
11
  require_relative "context_note"
12
12
  require_relative "log"
13
+ require_relative "note_move"
13
14
  require_relative "objectives"
14
15
  require_relative "session"
15
16
  require_relative "session_inbox"
@@ -108,6 +109,21 @@ module Samagotchi
108
109
  @queue_command = queue_command
109
110
  @initial_prompt_taken = false
110
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
+ []
111
127
  end
112
128
 
113
129
  # Add the queued context notes to the conversation (between turns only,
@@ -119,7 +135,9 @@ module Samagotchi
119
135
  # starts a turn nor keeps an idle worker up. Of the objectives notes in
120
136
  # a batch only the newest goes in: each is a whole list, so the older
121
137
  # ones (edits made during a turn) would only be sent for nothing.
138
+ # The predecessor's late notes come first (#adopt_notes).
122
139
  def absorb_notes
140
+ adopt_notes
123
141
  files = SessionInbox.find_new_note_files(@session_dir)
124
142
  return if files.empty? || @stopped.call
125
143
 
@@ -197,14 +215,20 @@ module Samagotchi
197
215
  # spawn_session hands the first prompt over in last_prompt, but
198
216
  # last_prompt also records every later turn's prompt (and mark_error's
199
217
  # reason), so only a session with no conversation yet has one pending; a
200
- # 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.
201
226
  # @return [String, nil]
202
227
  def take_initial_prompt
203
228
  return nil if @initial_prompt_taken
204
229
 
205
230
  @initial_prompt_taken = true
206
- # A context note may have come before the first prompt ran.
207
- 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)
208
232
 
209
233
  prompt = @session.last_prompt
210
234
  @session.last_prompt = ""
@@ -245,11 +269,20 @@ module Samagotchi
245
269
 
246
270
  begin
247
271
  input = SessionInbox.read_input(claimed_file)
248
- return if input.prompt.to_s.strip.empty?
249
- return if input.images.empty? && queue_as_command(input.prompt, input.origin, File.basename(input_file))
250
-
251
- yield Prompt.new(text: input.prompt, origin: input.origin, no_interrupt: input.no_interrupt, images: input.images,
252
- 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
253
286
  ensure
254
287
  FileUtils.rm_f(claimed_file)
255
288
  end
@@ -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?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.160.0
4
+ version: 0.161.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -317,6 +317,7 @@ files:
317
317
  - lib/samagotchi/command_intake.rb
318
318
  - lib/samagotchi/command_steps.rb
319
319
  - lib/samagotchi/commands/registry.rb
320
+ - lib/samagotchi/compat.rb
320
321
  - lib/samagotchi/composer_route.rb
321
322
  - lib/samagotchi/config.rb
322
323
  - lib/samagotchi/config/restart_watch.rb
@@ -556,6 +557,7 @@ files:
556
557
  - lib/samagotchi/muted_memories.rb
557
558
  - lib/samagotchi/note_command.rb
558
559
  - lib/samagotchi/note_delivery.rb
560
+ - lib/samagotchi/note_move.rb
559
561
  - lib/samagotchi/objectives.rb
560
562
  - lib/samagotchi/objectives_audit.rb
561
563
  - lib/samagotchi/objectives_check.rb
@@ -668,6 +670,7 @@ files:
668
670
  - lib/samagotchi/session_delete_command.rb
669
671
  - lib/samagotchi/session_field.rb
670
672
  - lib/samagotchi/session_inbox.rb
673
+ - lib/samagotchi/session_inbox_delivery.rb
671
674
  - lib/samagotchi/session_manager.rb
672
675
  - lib/samagotchi/session_manager/delivery.rb
673
676
  - lib/samagotchi/session_manager/emptiness.rb
@@ -1113,6 +1116,7 @@ files:
1113
1116
  - lib/samagotchi/worker_idle_exit.rb
1114
1117
  - lib/samagotchi/worker_inbound.rb
1115
1118
  - lib/samagotchi/worker_link.rb
1119
+ - lib/samagotchi/worker_presence.rb
1116
1120
  - lib/samagotchi/worker_sidecar.rb
1117
1121
  - lib/samagotchi/worker_signals.rb
1118
1122
  - lib/samagotchi/worker_task_wakes.rb