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
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require_relative "atomic_file"
4
7
  require_relative "session"
5
8
  require_relative "session_setup"
6
9
  require_relative "session_inbox"
@@ -9,6 +12,7 @@ require_relative "objectives"
9
12
  require_relative "child_move"
10
13
  require_relative "starting_marker"
11
14
  require_relative "context_move"
15
+ require_relative "note_move"
12
16
  require_relative "archive_store"
13
17
  require_relative "recap_store"
14
18
  require_relative "bridge_client"
@@ -56,6 +60,8 @@ module Samagotchi
56
60
  RECAP_WAIT = 30.0
57
61
  # In the previous link's folder: one continue of a session at a time.
58
62
  LOCK = "continue.lock"
63
+ # A durable record in the previous link's folder, written before archive.
64
+ CONTINUE_INTENT = "continue.json"
59
65
  # How often a last:<id> continue looks again when another continue
60
66
  # moved the chain's end while it waited for the lock.
61
67
  TRIES = 3
@@ -67,6 +73,29 @@ module Samagotchi
67
73
  # (ChildMove::Plan) and the new link's model (ModelChoice).
68
74
  Checked = Data.define(:plan, :choice)
69
75
 
76
+ # Repair an interrupted continue before showing sessions or starting
77
+ # another continue. An active continue holds LOCK, so its intent is left
78
+ # alone; after a crash, an unsaved successor means the previous link is
79
+ # unarchived.
80
+ def self.repair_interrupted(state_dir:)
81
+ return unless Dir.exist?(state_dir)
82
+
83
+ paths = Dir.glob(File.join(state_dir, "*", CONTINUE_INTENT))
84
+ return if paths.empty?
85
+
86
+ sessions = Session.list(state_dir: state_dir, include_archived: true)
87
+ by_id = sessions.to_h { |session| [session.id, session] }
88
+ paths.each do |path|
89
+ id = File.basename(File.dirname(path))
90
+ session = by_id[id]
91
+ if session
92
+ repair_intent(session, state_dir, path, sessions)
93
+ else
94
+ quarantine_intent(path, id, message: "session file could not be loaded")
95
+ end
96
+ end
97
+ end
98
+
70
99
  # Start the next link of a chain: a new session that continues
71
100
  # +id_or_ref+ (Session#continues) in its folder, on its model (the name
72
101
  # it was typed as, so an alias holds) and with its own setup (SessionSetup),
@@ -91,6 +120,9 @@ module Samagotchi
91
120
  # @param prompt [String, nil] its first message (nil: it starts idle)
92
121
  # @param preview [String, nil] what the lists show before the first turn;
93
122
  # by default the prompt, else the previous link's preview
123
+ # @param client_id [String, nil] who sends +prompt+ when no person does
124
+ # (a schedule's chain firing: ClientId.schedule), the first turn's
125
+ # origin (spawn_session's client_id:)
94
126
  # @return [Session] the new link (#model_warning as spawn_session's,
95
127
  # #moved_children: the delegates it took over, #moved_context: the
96
128
  # attached context it took (ContextMove), #opener:
@@ -99,8 +131,10 @@ module Samagotchi
99
131
  # @raise [Refused] continued already, delegates open that can't
100
132
  # move, or its folder is gone
101
133
  # @raise [ArchiveRefused, OwnedByTUI] as archive_session
102
- def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
134
+ def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5,
135
+ client_id: nil)
103
136
  sd = state_dir || Session.default_state_dir
137
+ repair_interrupted(state_dir: sd)
104
138
  TRIES.times do |try|
105
139
  id = SessionChain.resolve(id_or_ref, state_dir: sd)
106
140
  result = with_lock(id, sd) do
@@ -108,7 +142,7 @@ module Samagotchi
108
142
  next :moved if follow && SessionChain.next_of(id, state_dir: sd)
109
143
 
110
144
  run_locked(id, prompt: prompt, preview: preview, model: model, state_dir: sd, recap_wait: recap_wait,
111
- archive_wait: archive_wait)
145
+ archive_wait: archive_wait, client_id: client_id)
112
146
  end
113
147
  return result unless result == :moved
114
148
  end
@@ -123,7 +157,7 @@ module Samagotchi
123
157
  end
124
158
  end
125
159
 
126
- private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:)
160
+ private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:, client_id:)
127
161
  previous = Session.load(id, state_dir: state_dir)
128
162
  checked = check!(previous, state_dir, model: model)
129
163
  plan = checked.plan
@@ -136,24 +170,100 @@ module Samagotchi
136
170
  already = [id, *SessionManager.descendant_ids(id, state_dir, except: plan.move)].select do |sid|
137
171
  ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
138
172
  end
173
+ successor_id = SecureRandom.uuid
174
+ intent_path = continue_intent_path(id, state_dir)
175
+ intent_data = { successor_id: successor_id, previously_archived: already }
176
+ AtomicFile.write(intent_path, "#{JSON.generate(intent_data)}\n", perm: 0o600)
139
177
  archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
140
178
  archived = archive.archived - already
179
+ intent_data[:archived_ids] = archived
180
+ AtomicFile.write(intent_path, "#{JSON.generate(intent_data)}\n", perm: 0o600)
181
+ # The notes the new link took from this one (NoteMove.move).
182
+ notes = []
141
183
  begin
142
- spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
143
- move: plan.move)
144
- rescue StandardError
184
+ link = spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
185
+ move: plan.move, client_id: client_id, notes: notes, session_id: successor_id)
186
+ rescue StandardError, Interrupt
145
187
  # Nothing started: the delegates go back, what this archived goes
146
188
  # back to the lists, and a new link saved before its worker failed
147
189
  # to spawn goes (left, it would hold the chain: "continued already",
148
- # last: to it).
149
- undo_move(id, state_dir, moved: plan.move)
150
- begin
151
- discard_failed_link(id, state_dir)
152
- ensure
153
- archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
154
- wake_for_rings(id, state_dir) unless plan.move.empty?
190
+ # last: to it), its notes given back first.
191
+ undo_failed_continue(id, state_dir, moved: plan.move, archived: archived) do
192
+ discard_failed_link(id, state_dir, notes: notes)
155
193
  end
194
+ FileUtils.rm_f(intent_path)
156
195
  raise
196
+ else
197
+ FileUtils.rm_f(intent_path)
198
+ link
199
+ end
200
+ end
201
+
202
+ private_class_method def self.continue_intent_path(id, state_dir)
203
+ File.join(Session.session_dir(id, state_dir: state_dir), CONTINUE_INTENT)
204
+ end
205
+
206
+ private_class_method def self.repair_intent(session, state_dir, path, sessions)
207
+ lock_path = File.join(Session.session_dir(session.id, state_dir: state_dir), LOCK)
208
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
209
+ next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
210
+ next unless File.file?(path)
211
+
212
+ intent = JSON.parse(File.read(path))
213
+ intent = {} unless intent.is_a?(Hash)
214
+ successor_id = intent["successor_id"]
215
+ successor_path = Session.session_file(successor_id, state_dir: state_dir) if Session.valid_id?(successor_id)
216
+ successor = JSON.parse(File.read(successor_path)) if successor_path && File.file?(successor_path)
217
+ unless successor.is_a?(Hash) && successor["continues"] == session.id
218
+ previously_archived = Array(intent["previously_archived"]).select { |id| Session.valid_id?(id) }
219
+ archived = if intent["archived_ids"].is_a?(Array)
220
+ intent["archived_ids"].select { |id| Session.valid_id?(id) }
221
+ else
222
+ archived_descendants(session.id, sessions, state_dir) - previously_archived
223
+ end
224
+ undo_failed_continue(session.id, state_dir, moved: [], archived: archived)
225
+ end
226
+ FileUtils.rm_f(path)
227
+ end
228
+ rescue JSON::ParserError => e
229
+ quarantine_intent(path, session.id, error: e)
230
+ rescue Errno::ENOENT
231
+ nil
232
+ end
233
+
234
+ private_class_method def self.quarantine_intent(path, session_id, error: nil, message: nil)
235
+ File.rename(path, "#{path}.bad")
236
+ Log.warn(:worker, "continue_repair_failed", sid: session_id, path: path,
237
+ error: error&.class&.name || "SessionUnavailable",
238
+ msg: (message || error.message).to_s[0, 200])
239
+ rescue Errno::ENOENT
240
+ nil
241
+ end
242
+
243
+ private_class_method def self.archived_descendants(id, sessions, state_dir)
244
+ children = sessions.group_by(&:parent_id)
245
+ queue = [id]
246
+ descendants = []
247
+ until queue.empty?
248
+ children.fetch(queue.shift, []).each do |child|
249
+ next if descendants.include?(child.id)
250
+
251
+ descendants << child.id
252
+ queue << child.id
253
+ end
254
+ end
255
+ [id, *descendants].select do |session_id|
256
+ ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
257
+ end
258
+ end
259
+
260
+ private_class_method def self.undo_failed_continue(id, state_dir, moved:, archived:)
261
+ undo_move(id, state_dir, moved: moved)
262
+ begin
263
+ yield if block_given?
264
+ ensure
265
+ archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
266
+ wake_for_rings(id, state_dir) unless moved.empty?
157
267
  end
158
268
  end
159
269
 
@@ -171,10 +281,16 @@ module Samagotchi
171
281
  # The link a failed #run saved (no other continues +id+:
172
282
  # check! made sure under the lock): its worker stopped if one
173
283
  # started, then the rings a moved delegate wrote into it just before
174
- # the undo go back to +id+, then the link goes. A worker that outlives
175
- # the stop is raised (SessionManager::DeleteRefused): the link stays,
176
- # and the caller hears it.
177
- private_class_method def self.discard_failed_link(id, state_dir)
284
+ # the undo go back to +id+. Then its session file goes first, so the
285
+ # link is out of the chain (a note to +id+ stays there:
286
+ # SessionInbox::Head) and a writer that resolved it before finds it gone,
287
+ # then the notes it moved there (+notes+) go back with any sent to it
288
+ # meanwhile (NoteMove.give_back), then the link's folder goes, and one
289
+ # last look gives back a note an older chi wrote after the delete
290
+ # (its write_note recreates the folder). A worker that outlives the
291
+ # stop is raised (SessionManager::DeleteRefused): the link stays, and
292
+ # the caller hears it.
293
+ private_class_method def self.discard_failed_link(id, state_dir, notes:)
178
294
  orphan = SessionChain.next_of(id, state_dir: state_dir) or return
179
295
  if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
180
296
  !SessionManager.stop_session(orphan, state_dir: state_dir, wait: SessionManager::STOP_WAIT)
@@ -182,13 +298,26 @@ module Samagotchi
182
298
  end
183
299
 
184
300
  ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
185
- SessionManager.delete_session(orphan, state_dir: state_dir)
301
+ FileUtils.rm_f(Session.session_file(orphan, state_dir: state_dir))
302
+ NoteMove.give_back(from: orphan, to: id, moved: notes, state_dir: state_dir)
303
+ SessionManager.delete_session(orphan, state_dir: state_dir, allow_scheduled: true)
304
+ give_back_strays(orphan, id, state_dir)
186
305
  rescue SessionManager::DeleteRefused
187
306
  raise
188
307
  rescue StandardError => e
189
308
  Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
190
309
  end
191
310
 
311
+ # A failed link's folder an older chi's note brought back after its
312
+ # delete: the note goes back to +id+, the folder goes again.
313
+ private_class_method def self.give_back_strays(orphan, id, state_dir)
314
+ dir = Session.session_dir(orphan, state_dir: state_dir)
315
+ return unless Dir.exist?(dir) && !Session.exist?(orphan, state_dir: state_dir)
316
+
317
+ NoteMove.give_back(from: orphan, to: id, moved: [], state_dir: state_dir)
318
+ FileUtils.rm_rf(dir)
319
+ end
320
+
192
321
  # A failed continue's previous link, back in the lists, with rings that
193
322
  # came in meanwhile: wake it for them, as the ring's own wake would have.
194
323
  private_class_method def self.wake_for_rings(id, state_dir)
@@ -199,26 +328,35 @@ module Samagotchi
199
328
  Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
200
329
  end
201
330
 
202
- # The new link, holding off any worker but its own (StartingMarker.fresh?)
203
- # while +move+ goes to it.
204
- private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
331
+ # The new link, holding off any worker but its own while +move+ goes to
332
+ # it (spawn_session marks it starting before its file is saved:
333
+ # StartingMarker.fresh?).
334
+ # @param notes [Array<String>] gets the names of the notes moved to it
335
+ private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:, notes:,
336
+ session_id:, client_id: nil)
205
337
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
206
338
  start = prompt.to_s.strip.empty? ? nil : prompt
207
339
  preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
208
340
  moved_context = nil
209
341
  before_spawn = lambda do |link|
210
- StartingMarker.mark(link.id, state_dir: state_dir)
211
342
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
212
343
  # Its movable context too, with a note saying what moved and stayed.
213
344
  moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
214
- # Its objectives last: the list reads as the latest word.
345
+ # Its objectives: the list reads as the latest word.
215
346
  Objectives.carry(previous, to: link.id, state_dir: state_dir)
347
+ # Its waiting notes last: the carry above read a pending /objectives
348
+ # edit from them first. They keep their names, so they still sort
349
+ # ahead of the link's own notes. A note sent to the previous link
350
+ # from here on goes to this one (SessionInbox::Head), and a
351
+ # straggler is adopted by its worker (NoteMove.adoption).
352
+ notes.concat(NoteMove.move(from: previous.id, to: link.id, state_dir: state_dir))
216
353
  end
217
354
  link = SessionManager.spawn_session(
218
355
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
219
356
  model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
220
357
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
221
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
358
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id,
359
+ session_id: session_id
222
360
  )
223
361
  link.moved_children = move
224
362
  link.moved_context = moved_context
@@ -70,7 +70,9 @@ module Samagotchi
70
70
  @stdout.puts("#{short} refused: it is #{OwnerLock::TUI_BUSY}; let it finish first")
71
71
  false
72
72
  rescue SessionManager::DeleteRefused => e
73
- line = if e.reason == :still_stopping then "its worker is still shutting down; try again in a moment"
73
+ line = case e.reason
74
+ when :still_stopping then "its worker is still shutting down; try again in a moment"
75
+ when :scheduled then e.message
74
76
  else "its worker is running (--force stops it first)"
75
77
  end
76
78
  @stdout.puts("#{short} refused: #{line}")
@@ -44,6 +44,20 @@ module Samagotchi
44
44
  # NOTE_MAX_BYTES.
45
45
  class NoteRejected < ArgumentError; end
46
46
 
47
+ # The one delivery path (session_inbox_delivery.rb), loaded on first
48
+ # use: the bundle test kit's load budget stays as it was.
49
+ delivery = File.expand_path("session_inbox_delivery", __dir__)
50
+ %i[Dispatch Delivered Head Resolved WakePolicy].each { |name| autoload name, delivery }
51
+
52
+ # A delivery of +kind+ to session +target+ (Dispatch.deliver: resolve,
53
+ # refuse, the block's write, one presence read, the WakePolicy's wake).
54
+ # @return [Delivered]
55
+ def self.deliver(kind, target, **, &) = Dispatch.deliver(kind, target, **, &)
56
+
57
+ # The wake half of #deliver alone (Dispatch.wake).
58
+ # @return [Delivered]
59
+ def self.wake(kind, target, **) = Dispatch.wake(kind, target, **)
60
+
47
61
  # Queue a turn's input file: JSON with the prompt and the sender's ids.
48
62
  # +delivery+ is only written when it is "queue": a step-boundary
49
63
  # message is the default an older worker assumes, and a cut is done by
@@ -89,11 +103,17 @@ module Samagotchi
89
103
  # @param now [Time] the time the file is named and stamped by (a spec's clock)
90
104
  # @return [String] the note file's path
91
105
  # @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
106
+ # @raise [Session::NotFound] for a session that is gone: no file and no
107
+ # folder (deleted since the writer resolved it), which a note would
108
+ # otherwise bring back as a stray folder (as Session#save skips one)
92
109
  def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil,
93
110
  objectives: nil, now: Time.now)
94
111
  body = checked_text(text)
95
112
  sd = state_dir || Session.default_state_dir
96
- notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
113
+ session_dir = Session.session_dir(session_id, state_dir: sd)
114
+ raise Session::NotFound, session_id unless Dir.exist?(session_dir) || Session.exist?(session_id, state_dir: sd)
115
+
116
+ notes_dir = File.join(session_dir, NOTES_DIR)
97
117
  FileUtils.mkdir_p(notes_dir)
98
118
 
99
119
  # The random part keeps two writers in one nanosecond apart; the
@@ -203,6 +223,11 @@ module Samagotchi
203
223
  # An id that is safe in a file name (a Bridge's or deliver_turn's
204
224
  # uuid); anything else leaves the name bare.
205
225
  ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
226
+ ENQUEUED_ID_UUID = /\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z/i
227
+
228
+ def self.new_enqueued_id = SecureRandom.uuid
229
+
230
+ def self.valid_enqueued_id?(id) = id.is_a?(String) && ENQUEUED_ID_UUID.match?(id)
206
231
 
207
232
  def self.output_name(timestamp, enqueued_id)
208
233
  id = enqueued_id.to_s
@@ -221,6 +246,21 @@ module Samagotchi
221
246
  Dir.children(dir).select { |f| f.end_with?(suffix) }.max
222
247
  end
223
248
 
249
+ # Evidence that a prior worker took this input: a saved or interrupted
250
+ # turn, its reply, or a mid-turn merge record. A restarted worker's
251
+ # in-memory id ledger cannot cover these deliveries.
252
+ def self.input_taken?(session_dir, session, enqueued_id)
253
+ id = enqueued_id.to_s
254
+ return false if id.empty?
255
+ return true if [session.last_turn&.enqueued_id, session.interrupted_turn&.enqueued_id].include?(id)
256
+ return true if reply_for(session_dir, id)
257
+
258
+ require_relative "turn_checkpoint"
259
+ return true if TurnCheckpoint.settled_turn(session_dir, id)
260
+
261
+ !merged_into(session_dir, id).nil?
262
+ end
263
+
224
264
  # The output files' text, oldest first; with +since_time+ only files
225
265
  # modified after it.
226
266
  def self.read_outputs(session_dir, since_time: nil)
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_inbox"
5
+ require_relative "archive_store"
6
+ require_relative "worker_presence"
7
+
8
+ module Samagotchi
9
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
10
+ # files unloaded until a delivery starts a worker.
11
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
12
+
13
+ # The session inbox's one delivery path (SessionInbox.deliver): which link
14
+ # a delivery goes to (Head), whether it is refused, its write (the kind's
15
+ # own writer, unchanged), one read of the worker's presence after it, and
16
+ # what that means for a worker (WakePolicy). Loaded on first use, from
17
+ # session_inbox.rb's autoloads: the bundle test kit's load budget
18
+ # (spec/samagotchi/testing/sandbox_spec.rb) stays as it was.
19
+ module SessionInbox
20
+ # Where a delivery for +asked+ goes: +id+, reached +via+ :pinned (the
21
+ # kind never follows a chain), :same (followed, nothing to follow) or
22
+ # :head (a later link of +asked+'s chain).
23
+ Resolved = Data.define(:id, :asked, :via)
24
+
25
+ # A delivery's outcome.
26
+ # @!attribute kind [Symbol] the WakePolicy row
27
+ # @!attribute id [String] the session whose worker the outcome is about
28
+ # (a ring's file stays with +asked+: WakePolicy#resolves :reader)
29
+ # @!attribute asked [String] the id the caller named
30
+ # @!attribute via [Symbol] Resolved#via
31
+ # @!attribute status [Symbol] :refused (nothing written), :queued (a
32
+ # worker sees it: one runs, is starting or was started) or :waits (no
33
+ # worker comes: it waits for the next start)
34
+ # @!attribute wake [Symbol] WakePolicy#decide's outcome, :woken for a
35
+ # start made, or the refusal (:gone, :owned: a one-turn chi run's,
36
+ # :scratch)
37
+ # @!attribute queued [Integer, nil] notes waiting (a note kind's), nil else
38
+ Delivered = Data.define(:kind, :id, :asked, :via, :status, :wake, :queued) do
39
+ def initialize(kind:, id:, asked:, via:, status:, wake:, queued: nil) = super
40
+
41
+ def refused? = status == :refused
42
+ end
43
+
44
+ # What a delivery of one kind may do to a session's worker: one frozen
45
+ # row per kind (ROWS), decided on WorkerPresence's predicates alone, in
46
+ # one order for every kind (#decide).
47
+ # @!attribute kind [Symbol] :send, :note, :push, :ring, :fresh_verdict or
48
+ # :leave (WorkerBoot's respawn, not a delivery)
49
+ # @!attribute resolves [Symbol] which link it goes to (Head.resolve):
50
+ # :pinned the id named; :head the chain's newest link from it; :reader
51
+ # the link that reads the named one's file (a ring: the file stays with
52
+ # the parent parent.json names, ChildReports reads its predecessor's
53
+ # rings, so the wake goes one hop at most)
54
+ # @!attribute starts [Boolean] it may start a worker
55
+ # @!attribute unstops [Boolean] it starts a stopped session (a resume
56
+ # clears the stop): send only
57
+ # @!attribute starts_archived [Boolean] it starts an archived link
58
+ # (input from a person un-archives it): send only
59
+ # @!attribute refuse_tui [Boolean] a one-turn chi run's session refuses it
60
+ # @!attribute refuse_scratch [Boolean] a chi scratch session refuses it
61
+ WakePolicy = Data.define(:kind, :resolves, :starts, :unstops, :starts_archived, :refuse_tui, :refuse_scratch) do
62
+ # @return [WakePolicy]
63
+ # @raise [ArgumentError] no such kind
64
+ def self.for(kind) = WakePolicy::ROWS.fetch(kind) { raise ArgumentError, "no inbox kind #{kind.inspect}" }
65
+
66
+ # Why the delivery is refused before anything is written, else nil:
67
+ # :gone (no session), :owned (a one-turn chi run's), :scratch.
68
+ # @param load [#call, nil] the session, read only when the scratch
69
+ # flag decides (nil: not a scratch session)
70
+ # @return [Symbol, nil]
71
+ def refusal(presence, load: nil)
72
+ return :gone unless presence.exists?
73
+ return :owned if refuse_tui && presence.tui_owned?
74
+
75
+ :scratch if refuse_scratch && scratch?(load)
76
+ end
77
+
78
+ # What the delivery means for the session's worker, in this order:
79
+ # :gone, :owned (a one-turn chi run's), :live (a worker holds it: it
80
+ # sees the delivery), :stopped (the stop marker: the only "stopped",
81
+ # the file's status never decides), :archived, :starting (a worker is
82
+ # being started: it reads the store on its first pass), :none (the
83
+ # kind never starts one), :off (+wake+ false: none was asked),
84
+ # :scratch (its terminal's alone; the file is read last, only here),
85
+ # :start (start one).
86
+ # @param wake [Boolean] the delivery asks for a worker (a ring in wake
87
+ # mode, a push with wake)
88
+ # @return [Symbol]
89
+ def decide(presence, wake: true, load: nil)
90
+ return :gone unless presence.exists?
91
+ return :owned if presence.tui_owned?
92
+ return :live if presence.owned?
93
+ return :stopped if presence.stop_marked? && !unstops
94
+ return :archived if presence.archived? && !starts_archived
95
+ # A stop cancels a start's hold-off for a kind that clears it
96
+ # (WorkerPresence#start_pending?).
97
+ return :starting if unstops ? presence.start_pending? : presence.starting?
98
+ return :none unless starts
99
+ return :off unless wake
100
+ return :scratch if scratch?(load)
101
+
102
+ :start
103
+ end
104
+
105
+ private def scratch?(load) = load ? load.call.scratch == true : false
106
+ end
107
+
108
+ WakePolicy::ROWS = {
109
+ send: WakePolicy.new(kind: :send, resolves: :pinned, starts: true, unstops: true, starts_archived: true,
110
+ refuse_tui: true, refuse_scratch: true),
111
+ note: WakePolicy.new(kind: :note, resolves: :head, starts: false, unstops: false, starts_archived: false,
112
+ refuse_tui: true, refuse_scratch: true),
113
+ push: WakePolicy.new(kind: :push, resolves: :pinned, starts: true, unstops: false, starts_archived: false,
114
+ refuse_tui: false, refuse_scratch: false),
115
+ ring: WakePolicy.new(kind: :ring, resolves: :reader, starts: true, unstops: false, starts_archived: false,
116
+ refuse_tui: false, refuse_scratch: false),
117
+ fresh_verdict: WakePolicy.new(kind: :fresh_verdict, resolves: :pinned, starts: false, unstops: false,
118
+ starts_archived: false, refuse_tui: false, refuse_scratch: false),
119
+ # Not a delivery: a worker's own leave starting its successor for
120
+ # what waits (input, rings) or a restart (WorkerBoot, after the
121
+ # lock's release). A stop wins; an archived session's input still
122
+ # starts it, as a send's would have.
123
+ leave: WakePolicy.new(kind: :leave, resolves: :pinned, starts: true, unstops: false, starts_archived: true,
124
+ refuse_tui: false, refuse_scratch: false)
125
+ }.freeze
126
+
127
+ # Which link of a chain a delivery goes to.
128
+ module Head
129
+ module_function
130
+
131
+ # +id+ for a pinned kind, or for an id not archived (the cheap check
132
+ # first: only an archived id is walked, with one Session.list).
133
+ # Else the walk forward over archived links that have a successor:
134
+ # to the first link not archived, or the last (a loop a hand-edited
135
+ # file could make ends where it started); a :reader kind goes one hop
136
+ # at most.
137
+ # @return [Resolved]
138
+ def resolve(kind, id, state_dir:)
139
+ how = WakePolicy.for(kind).resolves
140
+ return Resolved.new(id: id, asked: id, via: :pinned) if how == :pinned
141
+
142
+ same = Resolved.new(id: id, asked: id, via: :same)
143
+ return same unless archived?(id, state_dir)
144
+
145
+ nexts = successors(state_dir)
146
+ seen = [id]
147
+ while (following = nexts[seen.last]) && !seen.include?(following)
148
+ seen << following
149
+ break if how == :reader || !archived?(following, state_dir)
150
+ end
151
+ seen.size == 1 ? same : Resolved.new(id: seen.last, asked: id, via: :head)
152
+ end
153
+
154
+ def archived?(id, state_dir)
155
+ Session.valid_id?(id) && ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
156
+ end
157
+
158
+ # id → the session that continues it (SessionChain.next_of's: the
159
+ # earliest one, archived ones too).
160
+ def successors(state_dir)
161
+ Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
162
+ .each_with_object({}) { |s, nexts| nexts[s.continues] ||= s.id if s.continues }
163
+ end
164
+ end
165
+
166
+ # SessionInbox.deliver and SessionInbox.wake.
167
+ module Dispatch
168
+ # What a start each kind makes is logged as (SessionManager.start_for_inbox).
169
+ START_EVENTS = { ring: :delegate_woke_parent, push: :context_woke_session }.freeze
170
+
171
+ module_function
172
+
173
+ # Deliver one +kind+ of thing to session +target+, in this order:
174
+ # resolve the link (Head.resolve), refuse (WakePolicy#refusal: before
175
+ # any write), write (the block: the kind's own writer, given the id
176
+ # it writes under), read the presence once, then decide and apply
177
+ # (#wake).
178
+ # @param wake [Boolean] the delivery may ask for a worker; the block's
179
+ # value says whether it does (truthy: asks)
180
+ # @param pin [Boolean] write under +target+ itself, whatever the kind's
181
+ # row resolves (a writer whose text is +target+'s own: an
182
+ # /objectives list built from its conversation)
183
+ # @param resolved [Resolved, nil] the link, as the caller resolved it
184
+ # already (Head.resolve for this kind): not walked again
185
+ # @param owner [#call, nil] (id) the owner lock's holder (a stand-in
186
+ # manager's session_owner); nil: SessionManager.session_owner
187
+ # @param start [#call, nil] (id) starts a worker, returns :woken (or
188
+ # why not); nil: SessionManager.start_for_inbox
189
+ # @yieldparam id [String] the session it is written under
190
+ # @yieldreturn [Boolean] whether it asks for a worker
191
+ # @return [Delivered]
192
+ def deliver(kind, target, state_dir: nil, wake: true, pin: false, resolved: nil, owner: nil, start: nil)
193
+ sd = state_dir || Session.default_state_dir
194
+ policy = WakePolicy.for(kind)
195
+ resolved = Resolved.new(id: target, asked: target, via: :pinned) if pin
196
+ resolved ||= Head.resolve(kind, target, state_dir: sd)
197
+ at = policy.resolves == :reader ? resolved.asked : resolved.id
198
+ if (why = refusal(policy, at, sd, owner))
199
+ return Delivered.new(kind: kind, id: resolved.id, asked: resolved.asked, via: resolved.via, status: :refused,
200
+ wake: why)
201
+ end
202
+
203
+ asks = yield(at)
204
+ apply(policy, resolved, sd, wake: wake && asks ? true : false, owner: owner, start: start)
205
+ end
206
+
207
+ # The wake half of #deliver alone, for a delivery already on disk (a
208
+ # failed continue's previous link with rings that came meanwhile).
209
+ # @return [Delivered]
210
+ def wake(kind, target, state_dir: nil, owner: nil, start: nil)
211
+ sd = state_dir || Session.default_state_dir
212
+ apply(WakePolicy.for(kind), Head.resolve(kind, target, state_dir: sd), sd, wake: true, owner: owner,
213
+ start: start)
214
+ end
215
+
216
+ def refusal(policy, id, state_dir, owner)
217
+ return :gone unless Session.exist?(id, state_dir: state_dir)
218
+ return nil unless policy.refuse_tui || policy.refuse_scratch
219
+
220
+ policy.refusal(presence(id, state_dir, owner), load: -> { Session.load(id, state_dir: state_dir) })
221
+ end
222
+
223
+ def apply(policy, resolved, state_dir, wake:, owner:, start:)
224
+ id = resolved.id
225
+ outcome = policy.decide(presence(id, state_dir, owner), wake: wake, load: -> { Session.load(id, state_dir: state_dir) })
226
+ if outcome == :start
227
+ outcome = if start
228
+ start.call(id)
229
+ else
230
+ SessionManager.start_for_inbox(id, state_dir: state_dir,
231
+ event: START_EVENTS.fetch(policy.kind))
232
+ end
233
+ end
234
+ status = %i[live woken starting].include?(outcome) ? :queued : :waits
235
+ Delivered.new(kind: policy.kind, id: id, asked: resolved.asked, via: resolved.via, status: status,
236
+ wake: outcome, queued: notes_waiting(policy, status, id, state_dir))
237
+ end
238
+
239
+ # A note's count of the notes waiting for a start (0 when a worker
240
+ # sees them); nil for the other kinds.
241
+ def notes_waiting(policy, status, id, state_dir)
242
+ return nil unless policy.kind == :note
243
+ return 0 unless status == :waits
244
+
245
+ SessionInbox.find_new_note_files(Session.session_dir(id, state_dir: state_dir)).size
246
+ end
247
+
248
+ def presence(id, state_dir, owner)
249
+ reader = owner ? -> { owner.call(id) } : -> { SessionManager.session_owner(id, state_dir: state_dir) }
250
+ WorkerPresence.read(id, state_dir: state_dir, owner: reader)
251
+ end
252
+ end
253
+ end
254
+ end