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
@@ -4,6 +4,8 @@ require "fileutils"
4
4
  require "time"
5
5
  require_relative "session"
6
6
  require_relative "archive_store"
7
+ require_relative "worker_presence"
8
+ require_relative "starting_marker"
7
9
  require_relative "config"
8
10
  require_relative "log"
9
11
  require_relative "model_profile"
@@ -18,8 +20,8 @@ module Samagotchi
18
20
  # (session.retention_days), count (session.max_count, counting only the
19
21
  # sessions that stay), statuses kept (session.keep_status), sessions left
20
22
  # empty (SessionManager.discardable?, an hour on), leftover scratch
21
- # sessions (once past SCRATCH_START_GRACE) and skeleton-only directories; a session with a live owner
22
- # always stays.
23
+ # sessions and skeleton-only directories; a session with a live owner, or
24
+ # whose worker is being started (StartingMarker), always stays.
23
25
  module SessionRetention
24
26
  # The session.retention_days / max_count / keep_status defaults.
25
27
  DEFAULT_RETENTION_DAYS = 14
@@ -53,13 +55,19 @@ module Samagotchi
53
55
  dry_run: dry_run,
54
56
  test_only: test_only,
55
57
  any_age: any_age,
56
- alive_check: ->(sid) { !SessionManager.session_owner(sid, state_dir: sd).nil? },
58
+ alive_check: ->(sid) { owned?(sid, sd) },
57
59
  empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
58
60
  )
59
61
  result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
60
62
  result
61
63
  end
62
64
 
65
+ # Someone holds the session's owner lock (a worker, a one-turn run).
66
+ private_class_method def self.owned?(session_id, state_dir)
67
+ WorkerPresence.read(session_id, state_dir: state_dir,
68
+ owner: SessionManager.session_owner(session_id, state_dir: state_dir)).owned?
69
+ end
70
+
63
71
  private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
64
72
  path = Session.session_file(session_id, state_dir: state_dir)
65
73
  Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
@@ -80,7 +88,7 @@ module Samagotchi
80
88
  next unless Session.valid_id?(name) && File.directory?(dir)
81
89
  next if File.exist?(File.join(state_dir, "#{name}#{SessionPaths::FILE_EXT}"))
82
90
  next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
83
- next if SessionManager.session_owner(name, state_dir: state_dir)
91
+ next if owned?(name, state_dir) || starting?(name, state_dir)
84
92
 
85
93
  unless dry_run
86
94
  FileUtils.rm_rf(dir)
@@ -93,14 +101,18 @@ module Samagotchi
93
101
  end
94
102
  end
95
103
 
96
- # How long after its save an unowned scratch session may still be
97
- # waiting for the worker just spawned for it to take its owner lock.
98
- SCRATCH_START_GRACE = SessionManager::WORKER_START_GRACE
104
+ # A worker is being started for it (every spawn marks it, a new
105
+ # session's before its file is saved): no owner yet doesn't mean nobody
106
+ # runs it.
107
+ private_class_method def self.starting?(session_id, state_dir)
108
+ StartingMarker.fresh?(session_id, state_dir: state_dir)
109
+ end
99
110
 
100
- private_class_method def self.scratch_starting?(path, now)
101
- now - File.mtime(path) < SCRATCH_START_GRACE
102
- rescue SystemCallError
103
- false
111
+ # Being started or owned now (+alive_check+, when given, reads the owner).
112
+ private_class_method def self.held?(session_id, state_dir, alive_check)
113
+ starting?(session_id, state_dir) || alive_check&.call(session_id) == true
114
+ rescue StandardError
115
+ true
104
116
  end
105
117
 
106
118
  # Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
@@ -172,9 +184,11 @@ module Samagotchi
172
184
  # count (`chi sessions clean` with no --days: test runs are throwaway).
173
185
  #
174
186
  # A scratch session nobody owns (its terminal and worker were killed) goes whatever its
175
- # age, its status and the count; +test_only+ takes it too. One saved
176
- # within SCRATCH_START_GRACE (its file's mtime, against +now+) stays: its
177
- # worker may not have taken the owner lock yet.
187
+ # age, its status and the count; +test_only+ takes it too.
188
+ #
189
+ # A session whose worker is being started (StartingMarker, fresh) stays,
190
+ # whatever it is, and takes no count slot: its worker may not have taken
191
+ # the owner lock yet.
178
192
  #
179
193
  # +max_count+ counts the sessions that stay: one deleted whatever its
180
194
  # place (scratch, left empty, expired, +any_age+) takes no slot.
@@ -186,6 +200,9 @@ module Samagotchi
186
200
  def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
187
201
  keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
188
202
  empty_check: nil, any_age: false, now: Time.now)
203
+ require_relative "schedules"
204
+ schedules = Schedules.new(state_dir: state_dir)
205
+ scheduled_targets = schedules.protected_session_ids
189
206
  keep_status = Array(keep_status).map(&:to_s)
190
207
  # Fetch all sessions sorted newest-first for count logic
191
208
  all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
@@ -200,6 +217,7 @@ module Samagotchi
200
217
  deleted = []
201
218
  kept = []
202
219
  skipped = []
220
+ purge_ids = []
203
221
  passed_over = 0
204
222
 
205
223
  all.each_with_index do |session, idx|
@@ -210,6 +228,11 @@ module Samagotchi
210
228
  next
211
229
  end
212
230
 
231
+ if scheduled_targets.include?(session.id)
232
+ kept << session.id
233
+ next
234
+ end
235
+
213
236
  # Protected by keep_status
214
237
  if keep_status.include?(session.status.to_s) && !session.scratch
215
238
  kept << session.id
@@ -228,10 +251,9 @@ module Samagotchi
228
251
  end
229
252
  end
230
253
 
231
- # A scratch session saved within SCRATCH_START_GRACE: `chi scratch`
232
- # saves it, then spawns the worker that takes its owner lock, so no
233
- # owner yet doesn't mean a leftover. Kept, it takes no count slot.
234
- if session.scratch && scratch_starting?(path, now)
254
+ # Being started (a resume, a new or scratch session): its worker
255
+ # takes the owner lock in a moment. Kept, it takes no count slot.
256
+ if starting?(session.id, state_dir)
235
257
  kept << session.id
236
258
  passed_over += 1
237
259
  next
@@ -277,20 +299,28 @@ module Samagotchi
277
299
  # Eligible for deletion
278
300
  if dry_run
279
301
  deleted << session.id
302
+ # Looked at again right before its files go (the list was read
303
+ # once, at the start): resumed or taken by a worker since, it stays.
304
+ elsif held?(session.id, state_dir, alive_check)
305
+ kept << session.id
306
+ elsif scheduled_targets.include?(session.id)
307
+ kept << session.id
280
308
  else
281
309
  begin
282
- FileUtils.rm_f(path)
283
- sidecar = File.join(state_dir, session.id)
284
- FileUtils.rm_rf(sidecar)
285
- PluginSessionState.remove(session.id)
286
- ContextSources.remove_session(session.id, state_dir: state_dir)
310
+ # Under the owner lock, the start read again (as a delete).
311
+ SessionManager.remove_files(session.id, path, File.join(state_dir, session.id), state_dir: state_dir,
312
+ purge_runs: false)
287
313
  deleted << session.id
314
+ purge_ids << session.id
315
+ rescue SessionManager::DeleteRefused
316
+ kept << session.id
288
317
  rescue StandardError
289
318
  skipped << session.id
290
319
  end
291
320
  end
292
321
  end
293
322
 
323
+ SessionManager.purge_removal_stores(purge_ids, state_dir: state_dir)
294
324
  { deleted: deleted, kept: kept, skipped: skipped }
295
325
  end
296
326
  end
@@ -24,20 +24,27 @@ module Samagotchi
24
24
  # +parent_answers+ the session's own guardrails.parent_answers (a level
25
25
  # String, tighten-only: Guardrails::ParentPolicy.effective) or nil,
26
26
  # +working_set_pins+ the items the user pinned in the working set
27
- # (WorkingSetPin; [] with none).
27
+ # (WorkingSetPin; [] with none), +preloaded_memory_names+ and
28
+ # +muted_memory_names+ the names --memory and --mute selected ([] with
29
+ # none). A delegate source's reserved memory is not copied to a normal
30
+ # child, and a fork cannot inherit it because its parent_id would mark it
31
+ # as a delegate.
28
32
  #
29
33
  # It doesn't save itself: the session keeps each setting in its own field
30
34
  # and file key, and resolves it on its own. A delegate child starts
31
35
  # without one (but the toolkit its delegate call named, and its parent's
32
36
  # parent_answers); a continue and a fork copy their session's (.of).
33
- SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
34
- :toolkit_origin, :settings, :parent_answers, :working_set_pins) do
37
+ SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files,
38
+ :preloaded_memory_names, :muted_memory_names, :toolkit, :toolkit_origin, :settings,
39
+ :parent_answers, :working_set_pins) do
35
40
  def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
36
- toolkit_origin: nil, settings: nil, parent_answers: nil, working_set_pins: [])
41
+ preloaded_memory_names: [], muted_memory_names: [], toolkit_origin: nil, settings: nil,
42
+ parent_answers: nil, working_set_pins: [])
37
43
  toolkit_origin ||= "chosen" if toolkit
38
44
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
39
45
  appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
40
- toolkit_origin: toolkit_origin&.to_s,
46
+ preloaded_memory_names: name_list(preloaded_memory_names),
47
+ muted_memory_names: name_list(muted_memory_names), toolkit_origin: toolkit_origin&.to_s,
41
48
  settings: SessionConfig::Settings.of(settings), parent_answers: parent_answers&.to_s,
42
49
  working_set_pins: WorkingSetPin.list(working_set_pins))
43
50
  end
@@ -51,12 +58,25 @@ module Samagotchi
51
58
  # always: one from before toolkits is full (Toolkit.of), not whatever
52
59
  # config's default is now.
53
60
  # @param session [Session, nil]
61
+ # @param forked [Boolean] whether the new session is a forked child
54
62
  # @return [SessionSetup]
55
- def self.of(session)
63
+ def self.of(session, forked: false)
56
64
  return new if session.nil?
57
65
 
58
- new(**members.to_h { |member| [member, self::CARRY.fetch(member) { ->(s) { s.public_send(member) } }.call(session)] })
66
+ values = members.to_h do |member|
67
+ [member, self::CARRY.fetch(member) { ->(s) { s.public_send(member) } }.call(session)]
68
+ end
69
+ if forked || session.delegate?
70
+ values[:preloaded_memory_names] = values[:preloaded_memory_names].reject do |name|
71
+ name == Session::DELEGATE_MEMORY
72
+ end
73
+ end
74
+ new(**values)
59
75
  end
76
+
77
+ private
78
+
79
+ def name_list(names) = Array(names).map { |name| name.to_s.strip }.reject(&:empty?).uniq.freeze
60
80
  end
61
81
 
62
82
  # How SessionSetup.of carries a member that isn't its session's field as
@@ -2,51 +2,120 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "securerandom"
5
6
  require "time"
6
7
  require_relative "atomic_file"
7
8
  require_relative "session"
8
9
 
9
10
  module Samagotchi
10
- # A continue's "starting" marker on its new link: written before the
11
- # continue moves delegates there (ChildMove) and spawns the link's worker,
12
- # removed by that worker once it holds the owner lock (Worker#start). While
13
- # it is #fresh?, nothing else spawns a worker for the link (SpawnPart's
14
- # resume_session and wake_for_report): a ring from a moved child must not
15
- # start a second one. CROSS-VERSION: the file name and its {pid, at} are
16
- # read and cleared by other chi versions (spec/worker_contract_spec.rb).
11
+ # A session's "starting" marker: a worker is being started for it. Every
12
+ # spawn writes it (SpawnPart's spawn_worker_for_session: the writer's pid
13
+ # before the spawn, the starter's after; a continue's before it moves
14
+ # delegates to its new link), and the worker removes it once it holds the
15
+ # owner lock (Worker#start). While it is #fresh?, nothing else spawns a
16
+ # worker for the session (WorkerPresence#starting?). CROSS-VERSION: the
17
+ # file name and its {pid, at} are read and cleared by other chi versions
18
+ # (spec/worker_contract_spec.rb).
17
19
  module StartingMarker
18
- # <new link dir>/starting: {pid, at}, from the move to the worker's spawn.
20
+ # <session dir>/starting: {pid, at}, from the decision to spawn to the
21
+ # worker holding the lock.
19
22
  FILE = "starting"
20
23
  # A starting marker older than this, or whose process is gone, is left
21
- # over from a continue that died: it holds nothing back.
24
+ # over from a start that died: it holds nothing back.
22
25
  STALE = 60.0
23
26
 
24
27
  module_function
25
28
 
26
- # Hold off a worker for the continue's new link (#fresh?) until its
27
- # own worker holds it: written before the move, removed by that worker
28
- # once it has the owner lock (Worker#start).
29
- def mark(session_id, state_dir:)
29
+ # Hold off any other worker for +session_id+ (#fresh?) until the one
30
+ # being started holds it, for as long as +pid+ runs: removed by that
31
+ # worker once it has the owner lock (Worker#start).
32
+ # @param pid [Integer] the process the hold lasts for: the writer, or
33
+ # the worker's starter once it is spawned
34
+ def mark(session_id, state_dir:, pid: Process.pid)
30
35
  dir = Session.session_dir(session_id, state_dir: state_dir)
31
36
  FileUtils.mkdir_p(dir)
32
- AtomicFile.write(File.join(dir, FILE), JSON.generate({ "pid" => Process.pid, "at" => Time.now.iso8601(3) }))
37
+ AtomicFile.write(File.join(dir, FILE), JSON.generate({ "pid" => pid, "at" => Time.now.iso8601(6) }))
33
38
  end
34
39
 
35
- def clear(session_id, state_dir:)
36
- FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), FILE))
40
+ # Claim the start of +session_id+'s worker: write the marker only when
41
+ # no fresh one holds it (a link(2) of a written temp file: of starters
42
+ # racing, one wins). A stale or dead one is taken over (renamed away,
43
+ # then checked to be the one judged, so a takeover never removes a new
44
+ # claim), and so is a fresh one written before +over_before+ (a stop
45
+ # cancels the starts before it, not the ones after it).
46
+ # @param pid [Integer] the process the hold lasts for (#mark)
47
+ # @param over_before [Time, nil] take over a start marked at or before it
48
+ # @return [Boolean] whether this caller holds the start now
49
+ def claim(session_id, state_dir:, pid: Process.pid, over_before: nil)
50
+ dir = Session.session_dir(session_id, state_dir: state_dir)
51
+ FileUtils.mkdir_p(dir)
52
+ path = File.join(dir, FILE)
53
+ temp = File.join(dir, "#{FILE}.#{Process.pid}.#{SecureRandom.hex(4)}")
54
+ File.write(temp, JSON.generate({ "pid" => pid, "at" => Time.now.iso8601(6) }))
55
+ 3.times do
56
+ File.link(temp, path)
57
+ return true
58
+ rescue Errno::EEXIST
59
+ seen = read_raw(path) or next
60
+ return false if holds?(seen, over_before)
61
+ return false unless take_over(path, seen)
62
+ end
63
+ false
64
+ ensure
65
+ FileUtils.rm_f(temp) if temp
37
66
  end
38
67
 
39
- # Whether a continue is moving delegates to +session_id+ right now: its
40
- # marker is there, fresh, and the process that wrote it runs.
41
- def fresh?(session_id, state_dir:)
42
- file = File.join(Session.session_dir(session_id, state_dir: state_dir), FILE)
43
- return false unless File.exist?(file)
68
+ # Move the marker +seen+ out of the way: false when what was renamed is
69
+ # another claim's (put back), true when it was +seen+ or already gone.
70
+ def take_over(path, seen)
71
+ tomb = "#{path}.old.#{Process.pid}.#{SecureRandom.hex(4)}"
72
+ File.rename(path, tomb)
73
+ return true if File.read(tomb) == seen
74
+
75
+ begin
76
+ File.link(tomb, path)
77
+ rescue Errno::EEXIST
78
+ nil
79
+ end
80
+ false
81
+ rescue Errno::ENOENT
82
+ true
83
+ ensure
84
+ FileUtils.rm_f(tomb) if tomb
85
+ end
86
+ private_class_method :take_over
44
87
 
45
- data = JSON.parse(File.read(file))
46
- return false if Time.now - Time.iso8601(data["at"].to_s) > STALE
88
+ def read_raw(path)
89
+ File.read(path)
90
+ rescue Errno::ENOENT
91
+ nil
92
+ end
93
+ private_class_method :read_raw
94
+
95
+ # Whether the marker text +raw+ holds a start: fresh, its process
96
+ # running, and (with +over_before+) marked after it.
97
+ def holds?(raw, over_before = nil)
98
+ data = JSON.parse(raw)
99
+ at = Time.iso8601(data["at"].to_s)
100
+ return false if Time.now - at > STALE
101
+ return false if over_before && at <= over_before
47
102
 
48
103
  process_alive?(data["pid"].to_i)
49
- rescue JSON::ParserError, ArgumentError, SystemCallError
104
+ rescue JSON::ParserError, ArgumentError, TypeError, RangeError, SystemCallError
105
+ false
106
+ end
107
+ private_class_method :holds?
108
+
109
+ def clear(session_id, state_dir:)
110
+ FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), FILE))
111
+ end
112
+
113
+ # Whether a worker is being started for +session_id+ right now: its
114
+ # marker is there, fresh, and the process it names runs.
115
+ def fresh?(session_id, state_dir:)
116
+ raw = read_raw(File.join(Session.session_dir(session_id, state_dir: state_dir), FILE))
117
+ !raw.nil? && holds?(raw)
118
+ rescue SystemCallError
50
119
  false
51
120
  end
52
121
 
@@ -22,7 +22,7 @@ module Samagotchi
22
22
  end
23
23
  end
24
24
 
25
- Document = Data.define(:version, :extra, :entries)
25
+ Document = Data.define(:version, :extra, :entries, :attributes)
26
26
 
27
27
  class InvalidDocument < StandardError; end
28
28
 
@@ -38,16 +38,18 @@ module Samagotchi
38
38
  Member.new(name: name.to_sym, introduced_in: in_version, default: ->(_attributes) { {} }, persisted: false)
39
39
  end
40
40
 
41
- attr_reader :members
41
+ attr_reader :members, :document_members
42
42
 
43
- def initialize(version:, members:)
43
+ def initialize(version:, members:, document_members: [])
44
44
  @version = version
45
45
  @members = members.freeze
46
+ @document_members = document_members.freeze
46
47
  @extra_member = members.find { |member| !member.persisted? }
47
48
  raise ArgumentError, "a state format can declare only one extra member" if members.count { |member| !member.persisted? } > 1
48
49
  raise ArgumentError, "duplicate state format members" unless members.map(&:name).uniq == members.map(&:name)
50
+ raise ArgumentError, "duplicate state format document members" unless document_members.map(&:name).uniq == document_members.map(&:name)
49
51
 
50
- members.each do |member|
52
+ (members + document_members).each do |member|
51
53
  required = member.required? && member.default.equal?(REQUIRED)
52
54
  defaulted = member.defaulted? && member.introduced_in.is_a?(Integer) &&
53
55
  member.introduced_in.positive? && member.introduced_in <= version &&
@@ -63,17 +65,26 @@ module Samagotchi
63
65
 
64
66
  collection = collection.to_s
65
67
  entries = Array(data[collection]).map { |raw| read_record(raw) }
66
- known = %w[version updated_at] + [collection]
68
+ attributes = {}
69
+ document_members.each do |member|
70
+ key = member.name.to_s
71
+ attributes[member.name] = if data.key?(key)
72
+ data[key]
73
+ else
74
+ default_value(member.default, attributes)
75
+ end
76
+ end
77
+ known = %w[version updated_at] + [collection] + document_members.map { |member| member.name.to_s }
67
78
  stored_version = data["version"]
68
79
  stored_version = 0 unless stored_version.is_a?(Integer) && stored_version >= 0
69
80
 
70
81
  Document.new(version: [@version, stored_version].max,
71
82
  extra: data.reject { |key, _value| known.include?(key.to_s) },
72
- entries: entries)
83
+ entries: entries, attributes: attributes)
73
84
  end
74
85
 
75
86
  def write(document, collection:, updated_at:)
76
- document.extra.transform_keys(&:to_s).merge(
87
+ document.extra.transform_keys(&:to_s).merge(document.attributes.transform_keys(&:to_s)).merge(
77
88
  "version" => [document.version, @version].max,
78
89
  "updated_at" => updated_at,
79
90
  collection.to_s => document.entries.map(&:to_h)
@@ -69,7 +69,12 @@ module Samagotchi
69
69
  # Explicit `chi sessions delete`: that session's runs and their later
70
70
  # lines go, whatever system_one.runs says now.
71
71
  def self.purge_session(session_id, sessions_dir:)
72
- RunsStore.new(File.dirname(sessions_dir)).purge_session(session_id)
72
+ purge_sessions([session_id], sessions_dir: sessions_dir)
73
+ end
74
+
75
+ # A retention sweep's runs, purged with one pass over the store.
76
+ def self.purge_sessions(session_ids, sessions_dir:)
77
+ RunsStore.new(File.dirname(sessions_dir)).purge_sessions(session_ids)
73
78
  end
74
79
 
75
80
  # Whether a bundle may declare +name+ (safe as a folder, not core's).
@@ -114,16 +114,29 @@ module Samagotchi
114
114
  # day files under the lock.
115
115
  # @return [Integer] the runs purged
116
116
  def purge_session(session_id)
117
- needle = session_id.to_s
118
- return 0 if needle.empty?
117
+ purge_sessions([session_id])
118
+ end
119
+
120
+ # Many sessions' runs, found with one pass over the judgment day files.
121
+ # @return [Integer] the runs purged
122
+ def purge_sessions(session_ids)
123
+ needles = Array(session_ids).map(&:to_s).reject(&:empty?).uniq
124
+ return 0 if needles.empty?
119
125
 
120
126
  names.sum do |name|
121
- files = day_files(name).select { |path| read(path).include?(needle) }
122
- ids = files.flat_map { |path| session_run_ids(path, needle) }
127
+ files = day_files(name)
128
+ ids = files.flat_map do |path|
129
+ read(path).each_line.filter_map do |text|
130
+ next unless needles.any? { |needle| text.include?(needle) }
131
+
132
+ data = parse(text)
133
+ data["id"] if data && data["kind"] == "run" && needles.include?(data["session_id"])
134
+ end
135
+ end.uniq
123
136
  next 0 if ids.empty?
124
137
 
125
138
  known = ids.to_h { |id| [id, true] }
126
- day_files(name).each { |path| rewrite_without(path, known) }
139
+ files.each { |path| rewrite_without(path, known) }
127
140
  remove_if_empty(name)
128
141
  ids.size
129
142
  end
@@ -197,15 +210,6 @@ module Samagotchi
197
210
  nil
198
211
  end
199
212
 
200
- def session_run_ids(path, session_id)
201
- read(path).each_line.filter_map do |text|
202
- next unless text.include?(session_id)
203
-
204
- data = parse(text)
205
- data["id"] if data && data["kind"] == "run" && data["session_id"] == session_id
206
- end
207
- end
208
-
209
213
  def rewrite_without(path, ids)
210
214
  with_file(path) do |file|
211
215
  kept = file.each_line.reject do |text|
@@ -7,6 +7,7 @@ require_relative "../session_manager"
7
7
  require_relative "../thinking_command"
8
8
  require_relative "../bridge_client"
9
9
  require_relative "../worker_link"
10
+ require_relative "../worker_presence"
10
11
  require_relative "../parent_report"
11
12
  require_relative "../cli/exit"
12
13
  require_relative "../launch_mode"
@@ -154,7 +155,7 @@ module Samagotchi
154
155
  def worker_running?(session_id, state_dir)
155
156
  return false unless session_id && Session.valid_id?(session_id)
156
157
 
157
- WorkerLink.resolve(Session.session_dir(session_id, state_dir: state_dir), unlink: false).live?
158
+ WorkerPresence.read(session_id, state_dir: state_dir, link: :look).live?
158
159
  end
159
160
 
160
161
  # The session's debug records, dim, above the live region.
@@ -261,8 +262,10 @@ module Samagotchi
261
262
  SessionManager.resume_session(resume, state_dir: sd)
262
263
  else
263
264
  SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
264
- memories: memories, muted_memories: muted_memories,
265
- setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
265
+ client_id: nil,
266
+ setup: SessionSetup.new(preloaded_memory_names: memories,
267
+ muted_memory_names: muted_memories,
268
+ llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
266
269
  thinking: Thinking.session_level(thinking),
267
270
  appended_prompt_files: appended_prompt_files,
268
271
  toolkit: toolkit, settings: settings,
@@ -319,7 +322,7 @@ module Samagotchi
319
322
  # @raise [Error] its worker is newer than this chi (Skew)
320
323
  def connect_existing(session_id, state_dir)
321
324
  Session.load(session_id, state_dir: state_dir)
322
- link = WorkerLink.resolve(Session.session_dir(session_id, state_dir: state_dir), unlink: true)
325
+ link = WorkerPresence.read(session_id, state_dir: state_dir, link: :unlink).link
323
326
  raise Error, link.message(session_id) if link.is_a?(WorkerLink::Skew)
324
327
 
325
328
  link.live? ? link.client(session_id) : nil
@@ -12,7 +12,7 @@ module Samagotchi
12
12
  # Input composition and Ctrl-C handling for the attached loop.
13
13
  # Reads @client, @clock, @command_registry, @continue_offer, @exit_ask,
14
14
  # @gaps, @link, @prefilled, @question, @queue, @queued_ids, @reader,
15
- # @running, @scratch, @screen; writes @prefilled, @last_idle_interrupt_at.
15
+ # @running, @running_turn_id, @scratch, @screen; writes @prefilled, @last_idle_interrupt_at.
16
16
  # Calls the loop helpers #answer_continue, #answer_exit_ask, #answer_question,
17
17
  # #archive_after_turn, #command_registry, #detach, #echo_answer, #exit_and_archive,
18
18
  # #end_scratch, #exit_and_delete, #exit_worker, #refuse_scratch_archive, #gap_divider,
@@ -185,7 +185,10 @@ module Samagotchi
185
185
  typed = pressed&.fetch(:text, nil).to_s
186
186
  if @running
187
187
  begin
188
- @client.cancel(reason: "ctrl_c")
188
+ # Names the turn this terminal shows, so a Ctrl-C as it ends
189
+ # doesn't stop the next one; a snapshot without an id sends none.
190
+ reply = @client.cancel(reason: "ctrl_c", **{ turn_id: @running_turn_id }.compact)
191
+ @screen.commit("a new turn started; Ctrl-C again stops it") if reply&.status == 409 && reply.json&.dig("error") == "turn_mismatch"
189
192
  rescue SystemCallError, IOError => e
190
193
  @screen.commit("could not cancel the turn (#{worker_down(e)})")
191
194
  end
@@ -138,6 +138,7 @@ module Samagotchi
138
138
  @renderer = EventRenderer.new(@view)
139
139
  @shown_enqueued = Set.new
140
140
  @running = false
141
+ @running_turn_id = nil
141
142
  @joined_mid_turn = false
142
143
  @attached = false
143
144
  # The stream moved to a new worker; its snapshot resyncs (#switch_worker).
@@ -593,6 +594,7 @@ module Samagotchi
593
594
 
594
595
  def render_current_turn(turn)
595
596
  @running = !turn.nil?
597
+ @running_turn_id = turn&.dig(:turn_id)
596
598
  @joined_mid_turn = @running
597
599
  # A turn running at the join: the status row's steer hint.
598
600
  @status.update(running: @running)
@@ -628,6 +630,7 @@ module Samagotchi
628
630
 
629
631
  def start_turn(event)
630
632
  @running = true
633
+ @running_turn_id = event[:turn_id]
631
634
  @joined_mid_turn = false
632
635
  origin = origin_of(event[:origin])
633
636
  @my_queued.delete(origin.enqueued_id)
@@ -663,12 +666,14 @@ module Samagotchi
663
666
  # The turn's summary carries its last context estimate.
664
667
  @status.update(context: @view.context_status) if @view.context_status
665
668
  @running = false
669
+ @running_turn_id = nil
666
670
  @joined_mid_turn = false
667
671
  close_question("(the turn ended)") if @question
668
672
  end
669
673
 
670
674
  def end_turn
671
675
  @running = false
676
+ @running_turn_id = nil
672
677
  @joined_mid_turn = false
673
678
  close_question("(the turn ended)") if @question
674
679
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../installed_versions"
4
+ require_relative "../compat"
4
5
  require_relative "../version"
5
6
  require_relative "../chi_invocation"
6
7
 
@@ -24,7 +25,7 @@ module Samagotchi
24
25
 
25
26
  newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
26
27
  .max_by { |v| Gem::Version.new(v) }
27
- worker_old = InstalledVersions.newer?(newest, worker)
28
+ worker_old = Compat.verdict(worker_version: worker, version: newest) == :restart_suggested
28
29
  terminal_old = InstalledVersions.newer?(newest, terminal)
29
30
  return nil unless worker_old || terminal_old
30
31
 
@@ -51,7 +52,8 @@ module Samagotchi
51
52
  def across_machines(worker:, installed:, session_id:, terminal:, via:)
52
53
  there = [installed, worker].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
53
54
  .max_by { |v| Gem::Version.new(v) }
54
- worker_old = InstalledVersions.newer?(installed, worker)
55
+ worker_old = installed && !installed.to_s.empty? &&
56
+ Compat.verdict(worker_version: worker, version: installed) == :restart_suggested
55
57
  terminal_old = InstalledVersions.newer?(there, terminal)
56
58
  return nil unless worker_old || terminal_old
57
59
 
@@ -189,12 +189,11 @@ module Samagotchi
189
189
  mode: "assist",
190
190
  model_name: @effective_model_name,
191
191
  working_directory: Dir.pwd,
192
- preloaded_memory_names: @requested_memories,
193
- muted_memory_names: @muted_memory_names,
194
192
  scratch: @scratch,
195
193
  setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
196
194
  toolkit_origin: @toolkit ? "chosen" : "default", settings: @session_settings,
197
- parent_answers: @parent_answers)
195
+ parent_answers: @parent_answers, preloaded_memory_names: @requested_memories,
196
+ muted_memory_names: @muted_memory_names)
198
197
  ))
199
198
  # --llm-context*: the session's own values, over a resumed one's.
200
199
  # Saved at once on a resumed one: leaving before a turn keeps them.