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,12 +6,20 @@ module Samagotchi
6
6
  # reason: :worker_running (not asked to stop it) or :still_stopping
7
7
  # (stopped, but the worker outlived the wait).
8
8
  class DeleteRefused < StandardError
9
- attr_reader :session_id, :reason
9
+ attr_reader :session_id, :reason, :schedule_id
10
10
 
11
- def initialize(session_id, reason)
11
+ def initialize(session_id, reason, message: nil, schedule_id: nil)
12
12
  @session_id = session_id
13
13
  @reason = reason
14
- super(reason == :still_stopping ? "session #{session_id}'s worker is still shutting down" : "session #{session_id}'s worker is running")
14
+ @schedule_id = schedule_id
15
+ message ||= case reason
16
+ when :still_stopping then "session #{session_id}'s worker is still shutting down"
17
+ when :scheduled
18
+ "this session is the target of an enabled schedule" \
19
+ "#{"; cancel it with chi schedule cancel #{schedule_id}" if schedule_id}"
20
+ else "session #{session_id}'s worker is running"
21
+ end
22
+ super(message)
15
23
  end
16
24
  end
17
25
 
@@ -37,6 +45,8 @@ module Samagotchi
37
45
  when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
38
46
  when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
39
47
  when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
48
+ when :starting then "its worker is starting; try again in a moment"
49
+ when :starting_child then "its delegate #{@busy_id[0, 8]}'s worker is starting; try again in a moment"
40
50
  else "a turn is running; wait for it or cancel it first"
41
51
  end
42
52
  end
@@ -47,6 +57,12 @@ module Samagotchi
47
57
  # workers stopped first; discarded: empty sessions its stopping worker deleted.
48
58
  ArchiveResult = Data.define(:id, :archived, :stopped, :discarded)
49
59
 
60
+ # Every store with session-scoped state. Deletion, retention and failed
61
+ # continuation cleanup all pass through this registry.
62
+ SESSION_STATE_STORES = %i[
63
+ session_files plugin_session_state context_sources participants system_one_runs context_candidates_log
64
+ ].freeze
65
+
50
66
  # Removal: archive, unarchive and delete a session (an archive takes its
51
67
  # delegates too), and the retention sweep's entry. Calls LifecyclePart's
52
68
  # stop_session, ListingPart's descendant_ids and the facade's ownership
@@ -87,23 +103,29 @@ module Samagotchi
87
103
  raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
88
104
 
89
105
  tree = [id, *descendant_ids(id, state_dir, except: except)]
90
- owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
106
+ presences = tree.to_h do |sid|
107
+ [sid, WorkerPresence.read(sid, state_dir: state_dir, owner: session_owner(sid, state_dir: state_dir))]
108
+ end
91
109
  tree.each do |sid|
92
- refusal = archive_refusal(sid, owners[sid], state_dir)
110
+ refusal = archive_refusal(sid, presences[sid], state_dir)
93
111
  raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
94
112
  end
95
- [tree, owners]
113
+ [tree, presences.transform_values(&:owner)]
96
114
  end
97
115
 
98
- # What keeps +sid+ (in an archive's tree) from being archived: :busy (a
116
+ # What keeps +sid+ (in an archive's tree) from being archived:
117
+ # :starting (a worker is being started for it, holding no lock yet: a
118
+ # continue's new link, a resume), :busy (a
99
119
  # turn runs), :queued (input/ holds a prompt, which outlives the stop and
100
120
  # runs on a resume, live worker or not), :offer (a live worker's continue
101
121
  # offer, which the stop would drop), or nil.
122
+ # The status is read only while someone owns it (+presence+'s owner).
102
123
  # @raise [OwnedByTUI] a one-turn chi run owns it
103
- private def archive_refusal(sid, owner, state_dir)
104
- raise OwnedByTUI, sid if owner&.tui?
124
+ private def archive_refusal(sid, presence, state_dir)
125
+ raise OwnedByTUI, sid if presence.tui_owned?
126
+ return :starting if presence.starting? && !presence.owned?
105
127
 
106
- session = owner && Session.load(sid, state_dir: state_dir)
128
+ session = presence.owned? ? Session.load(sid, state_dir: state_dir) : nil
107
129
  return :busy if session&.status == Session::STATUS_RUNNING
108
130
  return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
109
131
 
@@ -135,12 +157,14 @@ module Samagotchi
135
157
  # @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
136
158
  # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
137
159
  # @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
138
- # @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
139
- # outlived the wait
160
+ # @raise [DeleteRefused] a worker owns it or is being started for it
161
+ # (the starting marker) and +stop+ is false, or it outlived the wait
140
162
  # @param purge_runs [Boolean] also delete System 1's saved runs of it and
141
163
  # its candidate-log lines; a session a worker left empty has none worth
142
164
  # the read
143
- def delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true)
165
+ # @param allow_scheduled [Boolean] internal rollback of a failed continue
166
+ def delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true,
167
+ allow_scheduled: false)
144
168
  sd = state_dir || Session.default_state_dir
145
169
  given = id_or_prefix.to_s
146
170
  # Only a plain id: anything else could name a path outside the state dir.
@@ -152,12 +176,81 @@ module Samagotchi
152
176
  # A folder with no file left is still deleted (not resolve!).
153
177
  raise Session::NotFound, given unless File.exist?(path) || Dir.exist?(dir)
154
178
 
155
- owner = refuse_tui!(id, state_dir: sd)
156
- if owner
179
+ unless allow_scheduled
180
+ require_relative "../schedules"
181
+ schedule = Schedules.new(state_dir: sd).protecting(id)
182
+ if schedule
183
+ raise DeleteRefused.new(id, :scheduled, schedule_id: schedule.id)
184
+ end
185
+ end
186
+
187
+ presence = WorkerPresence.read(id, state_dir: sd, owner: session_owner(id, state_dir: sd))
188
+ raise OwnedByTUI, id if presence.tui_owned?
189
+
190
+ held = presence.owned? || presence.starting?
191
+ if held
157
192
  raise DeleteRefused.new(id, :worker_running) unless stop
158
- raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
193
+ raise DeleteRefused.new(id, :still_stopping) unless stop_held(id, state_dir: sd, wait: wait)
159
194
  end
160
195
 
196
+ removed = remove_files(id, path, dir, state_dir: sd, purge_runs: purge_runs)
197
+ { id: id, removed: removed, stopped: held }
198
+ end
199
+
200
+ # Remove every store with state scoped to +id+. The file and sidecar
201
+ # directory keep the deleting owner lock; the other stores have their
202
+ # own locking where they need it.
203
+ # @return [Array<String>] the session file, sidecar and context paths removed
204
+ def remove_files(id, path, dir, state_dir:, purge_runs: true)
205
+ removed = []
206
+ SESSION_STATE_STORES.each do |store|
207
+ case store
208
+ when :session_files
209
+ removed.concat(remove_session_files(id, path, dir, state_dir: state_dir))
210
+ when :plugin_session_state
211
+ PluginSessionState.remove(id)
212
+ when :context_sources
213
+ removed.concat(ContextSources.remove_session(id, state_dir: state_dir))
214
+ when :participants
215
+ Participants.end_sessions([id], by: "delete", state_dir: state_dir)
216
+ when :system_one_runs
217
+ next
218
+ when :context_candidates_log
219
+ next
220
+ else
221
+ raise ArgumentError, "no cleanup for session store #{store.inspect}"
222
+ end
223
+ end
224
+ purge_removal_stores([id], state_dir: state_dir) if purge_runs
225
+ removed
226
+ end
227
+
228
+ # The session-scoped logs are expensive to scan; callers removing a
229
+ # batch purge them once after the session files are gone.
230
+ def purge_removal_stores(ids, state_dir:)
231
+ return if ids.empty?
232
+
233
+ purge_session_runs(ids, state_dir)
234
+ ContextCandidatesLog.purge_sessions(ids, state_dir: state_dir)
235
+ end
236
+
237
+ # The session's file and directory, removed while this process holds
238
+ # its owner lock (kind deleting, which a stop never signals), so no
239
+ # worker takes the session in the middle; the starting marker is read
240
+ # again under it (a resume that claimed the start since the caller's
241
+ # check keeps the session; one after it sees the lock held). Taken
242
+ # without waiting: one that took it since the check keeps the session.
243
+ # The lock file goes with the directory; a worker starting after that
244
+ # makes a new one, and finds no session (Session.load). The delete and
245
+ # the retention sweep and failed-continue cleanup use #remove_files.
246
+ # @return [Array<String>] the paths removed
247
+ # @raise [DeleteRefused] a worker took the lock, or a start was
248
+ # claimed, after the caller's check
249
+ def remove_session_files(id, path, dir, state_dir:)
250
+ lock = Dir.exist?(dir) ? OwnerLock.acquire(dir, kind: OwnerLock::DELETING, wait: 0) : nil
251
+ raise DeleteRefused.new(id, :worker_running) if Dir.exist?(dir) && lock.nil?
252
+ raise DeleteRefused.new(id, :worker_running) if StartingMarker.fresh?(id, state_dir: state_dir)
253
+
161
254
  removed = []
162
255
  if File.exist?(path)
163
256
  FileUtils.rm_f(path)
@@ -167,21 +260,32 @@ module Samagotchi
167
260
  FileUtils.rm_rf(dir)
168
261
  removed << dir
169
262
  end
170
- PluginSessionState.remove(id)
171
- removed.concat(ContextSources.remove_session(id, state_dir: sd))
172
- Participants.end_sessions([id], by: "delete", state_dir: sd)
173
- if purge_runs
174
- purge_session_runs(id, sd)
175
- ContextCandidatesLog.purge_session(id, state_dir: sd)
263
+ removed
264
+ ensure
265
+ lock&.release
266
+ end
267
+
268
+ # Stop the worker that owns +id+ or is being started for it (the stop
269
+ # marker: one that takes the lock leaves at once), and wait for both to
270
+ # be gone.
271
+ # @return [Boolean] whether no owner and no start was left within +wait+
272
+ private def stop_held(id, state_dir:, wait:)
273
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait.to_f
274
+ return false unless stop_session(id, state_dir: state_dir, wait: wait)
275
+
276
+ while StartingMarker.fresh?(id, state_dir: state_dir) || session_owner(id, state_dir: state_dir)
277
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
278
+
279
+ sleep(OwnerLock::RETRY_INTERVAL)
176
280
  end
177
- { id: id, removed: removed, stopped: !owner.nil? }
281
+ true
178
282
  end
179
283
 
180
284
  # Never a reason a delete fails: one warn, and the sweep ages them out.
181
- private def purge_session_runs(id, state_dir)
182
- SystemOne::Runs.purge_session(id, sessions_dir: state_dir)
285
+ private def purge_session_runs(ids, state_dir)
286
+ SystemOne::Runs.purge_sessions(ids, sessions_dir: state_dir)
183
287
  rescue StandardError => e
184
- Log.warn(:worker, "session_runs_purge_failed", sid: id, error: e.class.name, msg: e.message.to_s[0, 200])
288
+ Log.warn(:worker, "session_runs_purge_failed", sid: ids.first, error: e.class.name, msg: e.message.to_s[0, 200])
185
289
  end
186
290
  end
187
291
 
@@ -1,19 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../worker_link"
4
+ require_relative "../starting_marker"
4
5
 
5
6
  module Samagotchi
6
7
  class SessionManager
7
- # How long after a spawn a resume trusts that worker to take the
8
- # session, while it runs (and the session wasn't stopped since).
8
+ # How long a worker's starter waits for it to take the session
9
+ # (WorkerBoot.detach_from_spawner): the starting marker naming the
10
+ # starter holds that long at most.
9
11
  WORKER_START_GRACE = WorkerLink::START_LIMIT
10
12
 
11
- @spawns = {}
12
- @spawns_mutex = Mutex.new
13
+ # The starters this process spawned (their Process.detach waiters), for
14
+ # chi web's restart to adopt (#live_spawn_pids). Decides nothing: the
15
+ # starting marker is what holds a second spawn off.
16
+ @starters = []
17
+ @starters_mutex = Mutex.new
13
18
 
14
19
  # Spawn: deciding to start a session's worker (a new session, a resume, a
15
- # delegate's report waking its parent) and the spawns this process
16
- # remembers. Calls WorkerCommandPart's spawn_options and worker_command and
20
+ # delegate's report waking its parent) and the starters this process
21
+ # spawned. Calls WorkerCommandPart's spawn_options and worker_command and
17
22
  # the facade's ownership checks. Meant to be loaded through samagotchi/session_manager.
18
23
  module SpawnPart
19
24
  # Spawn a new background session that processes the given prompt (or,
@@ -22,9 +27,6 @@ module Samagotchi
22
27
  # Returns the session object with its ID. Every worker always starts its
23
28
  # per-session Bridge (the single live client transport), so external
24
29
  # clients can reach it once the sidecar is published.
25
- # @param memories [Array<String>] --memory: preloaded into the worker's prompt
26
- # @param muted_memories [Array<String>] --mute: hidden from the session
27
- # (both are session fields, so a respawn keeps them)
28
30
  # @param parent_id [String, nil] the session that delegates this one (the
29
31
  # `delegate` tool) or was forked from; a session field too
30
32
  # @param messages [Array<Hash>] a conversation to start from (a plugin's
@@ -35,9 +37,9 @@ module Samagotchi
35
37
  # @param title [String, nil] the session's own name (Session#title; a
36
38
  # continue's link inherits its previous link's), saved before the
37
39
  # worker starts: the worker owns the file from then on
38
- # @param setup [SessionSetup] the session's own settings (chi
39
- # --llm-context; a fork and a continue copy their session's; a delegate
40
- # child the toolkit its delegate call named); without a toolkit the
40
+ # @param setup [SessionSetup] the session's own settings (chi --memory,
41
+ # --mute, --llm-context; a fork and a continue copy their session's;
42
+ # a delegate child the memory and toolkit its delegate call named); without a toolkit the
41
43
  # session is stored on config's toolkit.default, a delegate child on
42
44
  # toolkit.child (Toolkit.configured)
43
45
  # @param continues [String, nil] the session this one continues (a
@@ -54,17 +56,20 @@ module Samagotchi
54
56
  # @param scratch [Boolean] a `chi scratch` session (its terminal's
55
57
  # AttachLauncher): its worker runs a scratch Engine and deletes it
56
58
  # once its last client is gone
57
- # @param client_id [String, nil] who sent +prompt+ when no person did
58
- # (a ClientId: a delegating parent, a schedule's chain link): its
59
- # first turn's origin (Session#first_turn_client_id)
59
+ # @param client_id [String, nil] who sent +prompt+ (a ClientId); required
60
+ # even when nil is intentional for a session with no initial prompt
61
+ # @param session_id [String, nil] an id reserved by a durable operation
60
62
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
61
63
  # and #model_warning: an id its host's saved list doesn't have
62
- def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
63
- memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
64
- preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
65
- note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false,
66
- client_id: nil)
64
+ def spawn_session(prompt:, client_id:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
65
+ parent_id: nil, messages: [], images_from: nil, preview: nil, title: nil, delegate: false,
66
+ setup: SessionSetup.new, continues: nil, note: nil, note_source: "chi", before_spawn: nil,
67
+ review: nil, verify_fresh: nil, scratch: false, session_id: nil)
67
68
  sd = state_dir || Session.default_state_dir
69
+ if session_id && (!Session.valid_id?(session_id) || Session.exist?(session_id, state_dir: sd))
70
+ raise ArgumentError, "invalid or existing session id #{session_id.inspect}"
71
+ end
72
+
68
73
  # The resolved ref is stored (a resumed session keeps its model when an
69
74
  # alias is retargeted), with the name as typed beside it. A wrong host
70
75
  # refuses here, before anything is saved or spawned; a model id the
@@ -79,8 +84,6 @@ module Samagotchi
79
84
  model_name: ref,
80
85
  model_typed: typed == ref ? nil : typed,
81
86
  working_directory: working_directory || Dir.pwd,
82
- preloaded_memory_names: memories,
83
- muted_memory_names: muted_memories,
84
87
  parent_id: parent_id,
85
88
  messages: messages,
86
89
  delegate: delegate,
@@ -89,6 +92,7 @@ module Samagotchi
89
92
  review: review,
90
93
  scratch: scratch
91
94
  )
95
+ session.id = session_id if session_id
92
96
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
93
97
  session.toolkit_origin = setup.toolkit_origin || "default"
94
98
  # Saved before the worker starts: its first turn is already the requester's.
@@ -104,93 +108,115 @@ module Samagotchi
104
108
  # carry the id.
105
109
  unless prompt.to_s.strip.empty?
106
110
  session.first_turn_id = SecureRandom.uuid
107
- session.first_turn_client_id = client_id
111
+ session.first_turn_client_id = client_id unless ClientId.human?(client_id)
108
112
  end
109
113
  # The worker takes last_prompt and clears it, and messages are saved at
110
114
  # the turn's end: until then this is the only preview a list has.
111
115
  session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
112
116
  session.title = title
113
117
  session_dir = Session.session_dir(session.id, state_dir: sd)
114
- unless session.messages.empty?
115
- session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
116
- session.seed_images_dropped = dropped
118
+ # Starting before its file exists: the file is never there unowned and
119
+ # unmarked (a sweep would take a scratch session from under its
120
+ # worker). spawn_worker_for_session hands it to the starter.
121
+ StartingMarker.mark(session.id, state_dir: sd)
122
+ begin
123
+ unless session.messages.empty?
124
+ session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
125
+ session.seed_images_dropped = dropped
126
+ end
127
+ setup_session_directory(session_dir, session, state_dir: sd)
128
+ SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
129
+ before_spawn&.call(session)
130
+ spawn_worker_for_session(session, state_dir: sd)
131
+ rescue StandardError
132
+ # Nothing is starting: a caller's cleanup (a continue's failed link) may delete it.
133
+ StartingMarker.clear(session.id, state_dir: sd)
134
+ raise
117
135
  end
118
- setup_session_directory(session_dir, session, state_dir: sd)
119
- SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
120
- before_spawn&.call(session)
121
- spawn_worker_for_session(session, state_dir: sd)
122
136
  session
123
137
  end
124
138
 
125
139
  # Ensure an existing session has a live worker process.
126
140
  # Returns the loaded session after state reconciliation.
127
141
  # @raise [OwnedByTUI] when the interactive TUI owns the session
142
+ # @yieldparam outcome [Symbol] what it did, when a block is given:
143
+ # :woken (it spawned the worker), :live (a worker holds the session,
144
+ # or took it since the first read) or :starting (another start is
145
+ # pending, or won the claim)
128
146
  def resume_session(session_id, state_dir: nil)
147
+ session, outcome = resume(session_id, state_dir: state_dir)
148
+ yield outcome if block_given?
149
+ session
150
+ end
151
+
152
+ # resume_session's work, with its outcome.
153
+ # @return [Array(Session, Symbol)]
154
+ private def resume(session_id, state_dir:)
129
155
  sd = state_dir || Session.default_state_dir
130
156
  session = Session.load(session_id, state_dir: sd)
131
157
  # Its terminal's worker came from spawn_session; nothing wakes another.
132
158
  raise ScratchSession, session.id if session.scratch
133
- return session if refuse_tui!(session.id, state_dir: sd)
134
159
 
135
- # status is turn state, not liveness: a new worker runs no turn yet,
136
- # and must not find the session stopped (it would exit): neither the
137
- # marker nor a status field from before it.
138
- # One this process just spawned may not hold the lock yet (a slow
139
- # start, deliver_turn's second resume): it would only lose it.
140
- return session if still_starting?(session.id, state_dir: sd)
141
- # A continue moving delegates to it spawns its worker once they are
142
- # moved; one spawned now (a moved child's ring) would race the move.
143
- return session if StartingMarker.fresh?(session.id, state_dir: sd)
160
+ presence = WorkerPresence.read(session.id, state_dir: sd, owner: session_owner(session.id, state_dir: sd))
161
+ raise OwnedByTUI, session.id if presence.tui_owned?
162
+ return [session, :live] if presence.owned?
144
163
 
145
- Session.clear_stopped(session.id, state_dir: sd)
146
- session.status = Session::STATUS_IDLE
147
- session.save(state_dir: sd)
148
- spawn_worker_for_session(session, state_dir: sd)
149
- session
150
- end
164
+ # A worker being started for it, by this process or another (chi web,
165
+ # a `chi send`, a child's ring; a continue moving delegates to its new
166
+ # link), may not hold the lock yet: a second one would only lose it,
167
+ # or race the move (#resume_held_off?).
168
+ return [session, :starting] if presence.resume_held_off?
151
169
 
152
- # A delegate child rang a parent that has no worker (ChildRing): start
153
- # one, which runs a turn for the report. Not for a parent that is gone,
154
- # stopped, archived, a scratch session, a one-turn run's, or a
155
- # continue's new link still taking its delegates (its own spawn comes):
156
- # the ring waits on disk for whoever runs it next (#resume_session
157
- # itself would clear the stop). A stop landing between the check and
158
- # the resume is a small window, left as is. An archived parent with a
159
- # later link in its chain wakes that link instead: a ring a moved child
160
- # wrote just as the continue moved it reaches the new link that way
161
- # (ChildReports reads its predecessor's rings).
162
- # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
163
- # :scratch, :owned, :starting)
164
- def wake_for_report(session_id, state_dir: nil, forward: true)
165
- wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: forward,
166
- event: :delegate_woke_parent)
170
+ claimed = claim_start(session.id, state_dir: sd, stopped: presence.stop_marked?)
171
+ return [session, claimed] unless claimed == :claimed
172
+
173
+ # The new worker must not find the session stopped (it would exit).
174
+ # Nothing else is written: the file is the worker's, which turns a
175
+ # status from before it idle under the lock (Worker#settle_stale_status).
176
+ begin
177
+ Session.clear_stopped(session.id, state_dir: sd)
178
+ spawn_worker_for_session(session, state_dir: sd)
179
+ rescue StandardError
180
+ # Its claim holds nothing now (a command or the options failed
181
+ # before Process.spawn): no 60 s hold-off.
182
+ StartingMarker.clear(session.id, state_dir: sd)
183
+ raise
184
+ end
185
+ [session, :woken]
167
186
  end
168
187
 
169
- # A pushed context source asked for a wake: start an idle-exited
170
- # session, but never clear a stop or forward an archived link.
188
+ # A ring waits in a parent's children/ (a failed continue's previous
189
+ # link got rings back): wake it as the ring's own delivery would
190
+ # (SessionInbox.wake :ring, its WakePolicy). Not for a parent that is
191
+ # gone, stopped (the stop marker; the file's status never decides),
192
+ # archived, a scratch session, a one-turn run's or owned, or a
193
+ # continue's new link still taking its delegates (its own spawn
194
+ # comes): the ring waits on disk for whoever runs it next. A stop
195
+ # landing between the check and the resume is a small window, left as
196
+ # is. An archived parent with a later link wakes that link (one hop:
197
+ # ChildReports reads its predecessor's rings, no further).
171
198
  # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
172
199
  # :scratch, :owned, :starting)
173
- def wake_for_context(session_id, state_dir: nil)
174
- wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: false,
175
- event: :context_woke_session)
200
+ def wake_for_report(session_id, state_dir: nil)
201
+ sd = state_dir || Session.default_state_dir
202
+ outcome = SessionInbox.wake(:ring, session_id, state_dir: sd, owner: ->(id) { session_owner(id, state_dir: sd) }).wake
203
+ outcome == :live ? :owned : outcome
176
204
  end
177
205
 
178
- private def wake_existing_session(session_id, state_dir:, forward:, event:)
179
- return :gone unless Session.exist?(session_id, state_dir: state_dir)
180
- return :owned if session_owner(session_id, state_dir: state_dir)
181
- return :stopped if Session.stopped_marker?(session_id, state_dir: state_dir)
182
-
183
- if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
184
- following = forward && SessionChain.next_of(session_id, state_dir: state_dir)
185
- return following ? wake_existing_session(following, state_dir: state_dir, forward: false, event: event) : :archived
186
- end
187
- return :starting if StartingMarker.fresh?(session_id, state_dir: state_dir)
206
+ # Start a worker for a delivery whose WakePolicy decided :start
207
+ # (SessionInbox.deliver): resume_session, which keeps its own checks
208
+ # (a claim on the starting marker: of starts racing, one spawns),
209
+ # logged as +event+.
210
+ # @param event [Symbol] :delegate_woke_parent (a ring) or :context_woke_session (a push)
211
+ # @return [Symbol] :woken (this start spawned it), :starting (another
212
+ # start won the claim, or is pending), :live (a worker took it since)
213
+ # or :owned (a one-turn chi run or its terminal's scratch session did)
214
+ def start_for_inbox(session_id, state_dir:, event:)
215
+ # A stand-in resume_session that yields nothing counts as a start.
216
+ outcome = :woken
217
+ resume_session(session_id, state_dir: state_dir) { |done| outcome = done }
218
+ return outcome unless outcome == :woken
188
219
 
189
- session = Session.load(session_id, state_dir: state_dir)
190
- return :stopped if session.status == Session::STATUS_STOPPED
191
- return :scratch if session.scratch
192
-
193
- resume_session(session_id, state_dir: state_dir)
194
220
  details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
195
221
  Log.info(:worker, event, **details)
196
222
  :woken
@@ -198,37 +224,46 @@ module Samagotchi
198
224
  :owned
199
225
  end
200
226
 
201
- # A worker this process spawned for the session within
202
- # WORKER_START_GRACE is still running, and no stop came since.
203
- private def still_starting?(session_id, state_dir:)
204
- return false if Session.stopped_marker?(session_id, state_dir: state_dir)
205
-
206
- at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
207
- return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
227
+ # Take the start for resume_session: the marker claimed (of resumes
228
+ # racing from any processes, one claims it), then the owner read
229
+ # again: a worker that took the lock since the first read cleared an
230
+ # earlier start's marker, and keeps the session. After a stop, a start
231
+ # marked before it is taken over (a stop cancels the hold-off), one a
232
+ # resume made after it holds (two sends after a stop start one worker).
233
+ # @return [Symbol] :claimed (this resume spawns), :starting (another
234
+ # start holds the claim) or :live (a worker took the lock since)
235
+ private def claim_start(session_id, state_dir:, stopped:)
236
+ over_before = stopped ? stopped_at(session_id, state_dir) : nil
237
+ return :starting unless StartingMarker.claim(session_id, state_dir: state_dir, over_before: over_before)
238
+ return :claimed unless session_owner(session_id, state_dir: state_dir)
208
239
 
209
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
240
+ StartingMarker.clear(session_id, state_dir: state_dir)
241
+ :live
210
242
  end
211
243
 
212
- private def note_spawn(session_id, state_dir:, waiter:)
213
- @spawns_mutex.synchronize do
214
- @spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
215
- @spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
244
+ # When the stop marker was written (Session.stopped_at); nil when it
245
+ # is gone (another resume cleared it: its start came after the stop,
246
+ # and holds).
247
+ private def stopped_at(session_id, state_dir) = Session.stopped_at(session_id, state_dir: state_dir)
248
+
249
+ private def note_starter(waiter)
250
+ @starters_mutex.synchronize do
251
+ @starters.select! { |w| w.respond_to?(:alive?) && w.alive? }
252
+ @starters << waiter
216
253
  end
217
254
  end
218
255
 
219
- # Forget the spawns #still_starting? remembers (specs).
256
+ # Forget the starters this process spawned (specs).
220
257
  def forget_spawns
221
- @spawns_mutex.synchronize { @spawns.clear }
258
+ @starters_mutex.synchronize { @starters.clear }
222
259
  end
223
260
 
224
261
  # Starter processes are children of chi web, unlike the detached workers
225
262
  # they launch; the web restart adopts these pids before replacing itself.
226
263
  def live_spawn_pids
227
- @spawns_mutex.synchronize do
228
- @spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
229
- @spawns.values.filter_map do |_, waiter|
230
- waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
231
- end.uniq.sort
264
+ @starters_mutex.synchronize do
265
+ @starters.select! { |waiter| waiter.respond_to?(:alive?) && waiter.alive? }
266
+ @starters.filter_map { |waiter| waiter.pid if waiter.respond_to?(:pid) }.uniq.sort
232
267
  end
233
268
  end
234
269
 
@@ -248,14 +283,39 @@ module Samagotchi
248
283
  opts = spawn_options(session)
249
284
  env = opts.delete(:env)
250
285
  command = worker_command(session.id, state_dir: state_dir)
251
- pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
252
- # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
286
+ # Starting from here (WorkerPresence#starting?), for as long as this
287
+ # process runs, until the spawn below names its starter.
288
+ StartingMarker.mark(session.id, state_dir: state_dir)
289
+ begin
290
+ pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
291
+ rescue StandardError
292
+ StartingMarker.clear(session.id, state_dir: state_dir)
293
+ raise
294
+ end
253
295
  # +pid+ is the worker's starter, which exits once the worker it forked
254
- # holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is the worker's.
255
- note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
296
+ # holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is
297
+ # the worker's, and the marker holds exactly while the starter runs
298
+ # (most spawners exit right after this). The worker removes it once it
299
+ # holds the lock (Worker#start).
300
+ if starter_running?(pid)
301
+ StartingMarker.mark(session.id, state_dir: state_dir, pid: pid)
302
+ # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
303
+ note_starter(Process.detach(pid))
304
+ else
305
+ # It failed to exec or boot: nothing is starting.
306
+ StartingMarker.clear(session.id, state_dir: state_dir)
307
+ end
256
308
  Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
257
309
  pid
258
310
  end
311
+
312
+ # Whether the starter just spawned still runs (reaped here if it
313
+ # already exited). Not a child of this process: nothing to wait for.
314
+ private def starter_running?(pid)
315
+ Process.waitpid(pid, Process::WNOHANG).nil?
316
+ rescue Errno::ECHILD
317
+ false
318
+ end
259
319
  end
260
320
 
261
321
  extend SpawnPart
@@ -59,6 +59,7 @@ require_relative "context_candidates_log"
59
59
  require_relative "session_chain"
60
60
  require_relative "child_move"
61
61
  require_relative "starting_marker"
62
+ require_relative "worker_presence"
62
63
  require_relative "tools/task_runtime"
63
64
 
64
65
  module Samagotchi