samagotchi 0.159.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -37,6 +37,17 @@ module Samagotchi
37
37
 
38
38
  def detail = ack["detail"]
39
39
 
40
+ # How a delivery that did not go in reads, as one line: the word for it
41
+ # (refused: a worker older than queueing would steer the running turn,
42
+ # or a remote refused it as chi send refuses it here; failed: it simply
43
+ # did not get in) and its reason. chi send prints it, and the delegate
44
+ # tool's follow-up says it after "Error: session <id8>".
45
+ # @return [String]
46
+ def refusal_text
47
+ refused = error == Delivery::QUEUE_UNAVAILABLE["error"] || DeliverResult::REMOTE_REFUSALS.include?(error)
48
+ "#{refused ? "refused" : "failed"}: #{detail || "could not queue it"}"
49
+ end
50
+
40
51
  # What the worker applied (a Bridge ack only): the delivery asked for,
41
52
  # or next_step for a --queue that found no turn running; nil from the
42
53
  # input file or a worker older than delivery.
@@ -49,6 +60,13 @@ module Samagotchi
49
60
  def cut_after = ack["cut_after"]
50
61
  end
51
62
 
63
+ # A remote's refusals of what this machine checks before sending (a
64
+ # parent's /continue yes, a loosening /parent-answers, images to a
65
+ # model that can't see them): its turn route's error codes. Named on
66
+ # DeliverResult, not inside its Data.define block, where a constant
67
+ # would land on SessionManager.
68
+ DeliverResult::REMOTE_REFUSALS = %w[parent_answers_refused parent_continue_refused no_vision].freeze
69
+
52
70
  # The unclaimed input files +author+ wrote.
53
71
  PARTICIPANT_QUEUE_LOCK = "participants.lock"
54
72
 
@@ -118,7 +136,10 @@ module Samagotchi
118
136
  link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
119
137
  link.live? ? link.client(session_id) : nil
120
138
  end
121
- manager.resume_session(session_id, state_dir: state_dir)
139
+ # Its refusals (no session, a one-turn run's, a scratch one) are
140
+ # resume_session's to raise, in their one wording.
141
+ start_for_send(manager, session_id, state_dir, before: true)
142
+ enqueued_id = SessionInbox.new_enqueued_id
122
143
  if (client = bridge.call)
123
144
  if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
124
145
  return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
@@ -130,7 +151,7 @@ module Samagotchi
130
151
  return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
131
152
 
132
153
  begin
133
- options = { prompt: prompt, client_id: client_id }
154
+ options = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id }
134
155
  options[:images] = images unless images.empty?
135
156
  # An old worker ignores the field: it cuts anyway (its own default).
136
157
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
@@ -164,12 +185,12 @@ module Samagotchi
164
185
  # still holding the session with its Bridge gone (exiting) may be an
165
186
  # older one, which would run it without the review boundary.
166
187
  # (A one-turn chi run holding it is refused as for anyone, below.)
167
- if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
188
+ if Steer.participant_author?(author) && (holder = delivery_presence(manager, session_id, state_dir)).owned? &&
189
+ !holder.tui_owned?
168
190
  return DeliverResult.new(status: :timeout, via: :file,
169
191
  ack: { "error" => "worker_timeout",
170
192
  "detail" => "the session's worker is exiting, so the message was not sent; send it again" })
171
193
  end
172
- enqueued_id = SecureRandom.uuid
173
194
  input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
174
195
  input[:images] = images unless images.empty?
175
196
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
@@ -189,21 +210,51 @@ module Samagotchi
189
210
  end
190
211
  return DeliverResult.new(status: :failed) unless path
191
212
 
192
- owner = delivery_owner(manager, session_id, state_dir)
193
213
  # A TUI that took the session between the resume and the write never
194
214
  # reads input files; a later worker would replay this one.
195
- if owner&.tui?
196
- FileUtils.rm_f(path) if path.is_a?(String)
197
- raise OwnedByTUI, session_id
198
- end
199
215
  # A worker that idle-exited since the resume never reads it either:
200
216
  # wake a new one. (The exiting worker also looks for input it left;
201
217
  # one the first resume spawned that is still starting is left be.)
202
- manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
218
+ start_for_send(manager, session_id, state_dir, before: false) do
219
+ FileUtils.rm_f(path) if path.is_a?(String)
220
+ raise OwnedByTUI, session_id
221
+ end
203
222
  DeliverResult.new(status: :accepted, via: :file,
204
223
  ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
205
224
  end
206
225
 
226
+ # deliver_turn's two liveness checks, on the session inbox's send row
227
+ # (SessionInbox::WakePolicy: send un-stops and starts an archived
228
+ # link; it is pinned to the id it names): resume_session unless a
229
+ # worker holds the session or one is being started. Its refusals (no
230
+ # session, a one-turn run's, a scratch one) are resume_session's to
231
+ # raise, in their one wording: +before+ the write, a session it would
232
+ # not resume is checked for them (WakePolicy#refusal: the file read
233
+ # only then, resume_session reads it otherwise); after it, a one-turn
234
+ # run that took the session since runs the block, and a worker that
235
+ # is leaving (OwnerLock::Owner#leaving?: its Bridge closed, the lock
236
+ # held for its last work) has its input served by its own respawn
237
+ # (WorkerBoot's leave): a stop that landed before this send is
238
+ # cleared, as resume_session clears it (a send un-stops); one that
239
+ # lands after it still wins.
240
+ private def start_for_send(manager, session_id, state_dir, before:)
241
+ presence = delivery_presence(manager, session_id, state_dir)
242
+ policy = SessionInbox::WakePolicy.for(:send)
243
+ outcome = policy.decide(presence)
244
+ yield if outcome == :owned && block_given?
245
+ if !before && outcome == :live && presence.stop_marked? && presence.owner&.leaving?
246
+ Session.clear_stopped(session_id, state_dir: state_dir || Session.default_state_dir)
247
+ Log.info(:worker, "send_unstopped_leaving", sid: session_id[0, 8])
248
+ end
249
+ if %i[live starting].include?(outcome)
250
+ return unless before
251
+
252
+ load = -> { Session.load(session_id, state_dir: state_dir || Session.default_state_dir) }
253
+ return unless policy.refusal(presence, load: load)
254
+ end
255
+ manager.resume_session(session_id, state_dir: state_dir)
256
+ end
257
+
207
258
  # Whether the worker +client+ talks to names +feature+: the features of
208
259
  # the sidecar the client was made from (the Live link WorkerLink.await
209
260
  # found), not a second read of bridge.json.
@@ -244,8 +295,11 @@ module Samagotchi
244
295
  "detail" => "the session's worker did not answer, so the message was not sent" })
245
296
  end
246
297
 
247
- private def delivery_owner(manager, session_id, state_dir)
248
- Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
298
+ # The session's WorkerPresence, its owner read through +manager+ (a
299
+ # stand-in's #session_owner is optional: then none).
300
+ private def delivery_presence(manager, session_id, state_dir)
301
+ owner = Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
302
+ WorkerPresence.read(session_id, state_dir: state_dir, owner: owner)
249
303
  end
250
304
  end
251
305
 
@@ -3,6 +3,7 @@
3
3
  require_relative "../analytics_file"
4
4
  require_relative "../session_inbox"
5
5
  require_relative "../worker_link"
6
+ require_relative "../starting_marker"
6
7
 
7
8
  module Samagotchi
8
9
  class SessionManager
@@ -12,7 +13,9 @@ module Samagotchi
12
13
  # nothing writes or reads it now, but a folder from before still has one
13
14
  # and is still empty. A turn's drawing (Bridge::TurnDrawings::FILE)
14
15
  # exists only after a turn that kept messages: never a skeleton file.
15
- EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME].freeze
16
+ # The starting marker is one too: a start left one behind; a fresh one
17
+ # keeps the session from the deleters itself.
18
+ EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME, StartingMarker::FILE].freeze
16
19
  EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
17
20
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
18
21
 
@@ -94,7 +97,9 @@ module Samagotchi
94
97
  elsif name == "cards.json"
95
98
  cards_empty?(path)
96
99
  else
97
- EMPTY_SKELETON_FILES.include?(name)
100
+ # A starting claim's temp files a kill left (StartingMarker.claim:
101
+ # starting.<pid>.<hex>, starting.old.<pid>.<hex>) are the marker's.
102
+ EMPTY_SKELETON_FILES.include?(name) || name.start_with?("#{StartingMarker::FILE}.")
98
103
  end
99
104
  end
100
105
  end
@@ -220,7 +220,8 @@ module Samagotchi
220
220
  # A stop drops the reminders: a restart's hand-off no successor took
221
221
  # yet must not bring them back on the next resume.
222
222
  ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
223
- pid = owner&.pid.to_i
223
+ # A deleter holding the lock (chi web, the CLI) is no worker to stop.
224
+ pid = owner&.deleting? ? 0 : owner&.pid.to_i
224
225
  begin
225
226
  Process.kill("TERM", pid) if pid && pid > 0
226
227
  rescue Errno::ESRCH
@@ -11,7 +11,9 @@ module Samagotchi
11
11
  # +include_archived+: archived sessions too (Session#archived says which).
12
12
  def list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
13
13
  include_archived: false)
14
- Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
14
+ sd = state_dir || Session.default_state_dir
15
+ SessionContinue.repair_interrupted(state_dir: sd)
16
+ Session.list(state_dir: sd, sort: sort, order: order, limit: limit,
15
17
  offset: offset, project_root: project_root, include_archived: include_archived)
16
18
  end
17
19
 
@@ -34,6 +36,7 @@ module Samagotchi
34
36
  def session_rows(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
35
37
  project_root: nil, include_archived: false, sort: nil, order: nil)
36
38
  sd = state_dir || Session.default_state_dir
39
+ SessionContinue.repair_interrupted(state_dir: sd)
37
40
  root = cwd && folder_path(cwd)
38
41
  roots = {}
39
42
  # One registry per listing, built only when a row has a saved context
@@ -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