samagotchi 0.155.0 → 0.157.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 (234) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +2 -1
  4. data/docs/architecture.md +22 -13
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/anthropic.md +1 -1
  11. data/docs/internals/attached-context.md +3 -2
  12. data/docs/internals/background-tasks.md +1 -1
  13. data/docs/internals/bridge.md +83 -6
  14. data/docs/internals/bundles.md +28 -18
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +641 -937
  17. data/docs/internals/llm-context-forget.md +8 -3
  18. data/docs/internals/objectives.md +2 -2
  19. data/docs/internals/participants.md +3 -2
  20. data/docs/internals/prompt-caching.md +4 -1
  21. data/docs/internals/remote-sessions.md +8 -3
  22. data/docs/internals/web-frontend.md +3 -2
  23. data/docs/internals/working-set.md +2 -2
  24. data/docs/memory.md +13 -2
  25. data/docs/plugins.md +35 -2
  26. data/docs/sessions.md +11 -11
  27. data/docs/sub-agent.md +17 -2
  28. data/docs/system-one.md +3 -2
  29. data/docs/testing.md +53 -2
  30. data/lib/samagotchi/answer_command.rb +2 -1
  31. data/lib/samagotchi/archive_store.rb +4 -3
  32. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  33. data/lib/samagotchi/bootstrap_command.rb +3 -2
  34. data/lib/samagotchi/bridge/card_places.rb +10 -4
  35. data/lib/samagotchi/bridge/card_store.rb +3 -0
  36. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  37. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  38. data/lib/samagotchi/bridge.rb +31 -6
  39. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  40. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  41. data/lib/samagotchi/broadcast_command.rb +3 -3
  42. data/lib/samagotchi/bundle_command/check.rb +18 -0
  43. data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
  44. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  45. data/lib/samagotchi/bundle_command.rb +5 -2
  46. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  47. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  48. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  49. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  50. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  51. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  52. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  53. data/lib/samagotchi/child_move.rb +13 -57
  54. data/lib/samagotchi/child_reports.rb +2 -2
  55. data/lib/samagotchi/children_status.rb +15 -21
  56. data/lib/samagotchi/cli/parent_wait.rb +9 -23
  57. data/lib/samagotchi/config.rb +20 -112
  58. data/lib/samagotchi/context_command.rb +1 -2
  59. data/lib/samagotchi/context_sources.rb +5 -4
  60. data/lib/samagotchi/context_window.rb +9 -12
  61. data/lib/samagotchi/desktop/macos.rb +1 -1
  62. data/lib/samagotchi/effective_model.rb +57 -22
  63. data/lib/samagotchi/engine/context_judge.rb +19 -11
  64. data/lib/samagotchi/engine/messages.rb +1 -1
  65. data/lib/samagotchi/engine/models.rb +5 -2
  66. data/lib/samagotchi/engine/objectives.rb +29 -11
  67. data/lib/samagotchi/engine/plugins.rb +1 -1
  68. data/lib/samagotchi/engine/recap.rb +8 -4
  69. data/lib/samagotchi/engine/side_requests.rb +21 -11
  70. data/lib/samagotchi/engine/toolkit.rb +10 -9
  71. data/lib/samagotchi/engine/turn_end.rb +1 -1
  72. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  73. data/lib/samagotchi/engine/verify.rb +1 -1
  74. data/lib/samagotchi/engine.rb +43 -12
  75. data/lib/samagotchi/events.rb +3 -2
  76. data/lib/samagotchi/extension_load.rb +6 -3
  77. data/lib/samagotchi/guardrails/targets.rb +24 -9
  78. data/lib/samagotchi/guardrails_command.rb +1 -1
  79. data/lib/samagotchi/host_address.rb +52 -0
  80. data/lib/samagotchi/host_config.rb +72 -1
  81. data/lib/samagotchi/host_model.rb +2 -6
  82. data/lib/samagotchi/host_registry.rb +58 -46
  83. data/lib/samagotchi/kernel_loop.rb +53 -124
  84. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  85. data/lib/samagotchi/llm/chat_loop.rb +40 -21
  86. data/lib/samagotchi/llm/native_backend.rb +1 -1
  87. data/lib/samagotchi/llm_context_apply.rb +54 -1
  88. data/lib/samagotchi/llm_context_notice.rb +1 -1
  89. data/lib/samagotchi/llm_context_strategy.rb +29 -67
  90. data/lib/samagotchi/llm_context_view.rb +9 -0
  91. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  92. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  93. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  94. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  95. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  96. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  97. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  98. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  99. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  100. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  101. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  102. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  103. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  104. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  105. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  106. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  107. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  108. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  109. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  110. data/lib/samagotchi/memory_usage.rb +1 -1
  111. data/lib/samagotchi/model_lookup.rb +75 -0
  112. data/lib/samagotchi/model_price.rb +4 -10
  113. data/lib/samagotchi/model_profile.rb +7 -6
  114. data/lib/samagotchi/model_settings.rb +37 -2
  115. data/lib/samagotchi/models_command.rb +1 -1
  116. data/lib/samagotchi/note_command.rb +2 -3
  117. data/lib/samagotchi/objectives_audit.rb +4 -3
  118. data/lib/samagotchi/participant_mode.rb +2 -1
  119. data/lib/samagotchi/participants.rb +2 -2
  120. data/lib/samagotchi/plugin/api.rb +41 -6
  121. data/lib/samagotchi/plugin/context.rb +1 -1
  122. data/lib/samagotchi/plugin/loader.rb +33 -10
  123. data/lib/samagotchi/plugin/sessions.rb +3 -6
  124. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  125. data/lib/samagotchi/project_env.rb +1 -1
  126. data/lib/samagotchi/reasoning_history.rb +14 -20
  127. data/lib/samagotchi/recap_place.rb +27 -0
  128. data/lib/samagotchi/reminder_carry.rb +1 -1
  129. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  130. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  131. data/lib/samagotchi/reply_wait/target.rb +113 -0
  132. data/lib/samagotchi/reply_wait.rb +22 -2
  133. data/lib/samagotchi/reprefill_meter.rb +45 -0
  134. data/lib/samagotchi/review_command.rb +1 -1
  135. data/lib/samagotchi/sampling_settings.rb +9 -16
  136. data/lib/samagotchi/self_report.rb +36 -37
  137. data/lib/samagotchi/send_command.rb +17 -23
  138. data/lib/samagotchi/session.rb +370 -445
  139. data/lib/samagotchi/session_access/local.rb +12 -11
  140. data/lib/samagotchi/session_access/remote.rb +22 -6
  141. data/lib/samagotchi/session_archive_command.rb +2 -2
  142. data/lib/samagotchi/session_chain.rb +7 -6
  143. data/lib/samagotchi/session_continue.rb +6 -5
  144. data/lib/samagotchi/session_delete_command.rb +1 -1
  145. data/lib/samagotchi/session_field.rb +133 -0
  146. data/lib/samagotchi/session_inbox.rb +49 -1
  147. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  148. data/lib/samagotchi/session_manager/emptiness.rb +139 -0
  149. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  150. data/lib/samagotchi/session_manager/listing.rb +118 -0
  151. data/lib/samagotchi/session_manager/removal.rb +190 -0
  152. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  153. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  154. data/lib/samagotchi/session_manager.rb +28 -1541
  155. data/lib/samagotchi/session_metrics.rb +8 -5
  156. data/lib/samagotchi/session_paths.rb +57 -0
  157. data/lib/samagotchi/session_retention.rb +2 -2
  158. data/lib/samagotchi/session_row.rb +200 -0
  159. data/lib/samagotchi/session_setup.rb +15 -12
  160. data/lib/samagotchi/sessions_command.rb +11 -129
  161. data/lib/samagotchi/share_command.rb +1 -1
  162. data/lib/samagotchi/starting_marker.rb +65 -0
  163. data/lib/samagotchi/steer_cut_after.rb +7 -14
  164. data/lib/samagotchi/system_one/recorder.rb +49 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  166. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  167. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  168. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  169. data/lib/samagotchi/terminal_ui.rb +5 -7
  170. data/lib/samagotchi/testing/install.rb +2 -1
  171. data/lib/samagotchi/testing/sessions.rb +1 -1
  172. data/lib/samagotchi/thinking.rb +16 -21
  173. data/lib/samagotchi/token_usage.rb +1 -1
  174. data/lib/samagotchi/tool_activity.rb +10 -4
  175. data/lib/samagotchi/tool_declarations.rb +2 -2
  176. data/lib/samagotchi/tool_output_cut.rb +17 -0
  177. data/lib/samagotchi/tool_response.rb +5 -3
  178. data/lib/samagotchi/tool_runner.rb +25 -5
  179. data/lib/samagotchi/toolkit_command.rb +1 -1
  180. data/lib/samagotchi/tools/delegate.rb +55 -45
  181. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  182. data/lib/samagotchi/tools/delegate_result.rb +5 -5
  183. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  184. data/lib/samagotchi/tools/index_hint.rb +14 -0
  185. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  186. data/lib/samagotchi/tools/registry.rb +28 -11
  187. data/lib/samagotchi/tools/send_note.rb +4 -6
  188. data/lib/samagotchi/tools/tool_index.rb +52 -51
  189. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  190. data/lib/samagotchi/turn_context.rb +2 -1
  191. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  192. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  193. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  194. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  195. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  196. data/lib/samagotchi/update_command.rb +11 -7
  197. data/lib/samagotchi/verify_fresh.rb +7 -11
  198. data/lib/samagotchi/version.rb +1 -1
  199. data/lib/samagotchi/vision_support.rb +11 -14
  200. data/lib/samagotchi/web/app.rb +45 -28
  201. data/lib/samagotchi/web/message_display.rb +57 -7
  202. data/lib/samagotchi/web/participant_access.rb +3 -3
  203. data/lib/samagotchi/web/public/activity.js +16 -7
  204. data/lib/samagotchi/web/public/app.js +29 -8
  205. data/lib/samagotchi/web/public/bubbles.js +8 -3
  206. data/lib/samagotchi/web/public/cards_place.js +15 -0
  207. data/lib/samagotchi/web/public/commands.js +5 -1
  208. data/lib/samagotchi/web/public/data.js +4 -2
  209. data/lib/samagotchi/web/public/history_shape.js +220 -0
  210. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  211. data/lib/samagotchi/web/public/reload_check.js +71 -0
  212. data/lib/samagotchi/web/public/stage_model.js +7 -5
  213. data/lib/samagotchi/web/public/stage_view.js +7 -3
  214. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  215. data/lib/samagotchi/web/public/timing.js +43 -2
  216. data/lib/samagotchi/web/public/turn_block.js +215 -30
  217. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  218. data/lib/samagotchi/web/public/turn_events.js +10 -1
  219. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  220. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  221. data/lib/samagotchi/web/server.rb +4 -2
  222. data/lib/samagotchi/web/session_access_routes.rb +6 -6
  223. data/lib/samagotchi/web/session_control.rb +4 -4
  224. data/lib/samagotchi/web/session_create.rb +3 -3
  225. data/lib/samagotchi/web/session_hub.rb +9 -17
  226. data/lib/samagotchi/web/session_show.rb +67 -5
  227. data/lib/samagotchi/web/session_summary.rb +39 -28
  228. data/lib/samagotchi/worker.rb +83 -32
  229. data/lib/samagotchi/worker_boot.rb +184 -0
  230. data/lib/samagotchi/worker_link.rb +3 -0
  231. data/lib/samagotchi/worker_signals.rb +1 -1
  232. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  233. metadata +32 -4
  234. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -47,7 +47,7 @@ module Samagotchi
47
47
 
48
48
  def handle_question_answer(req, id)
49
49
  session = @session_class.load(id, state_dir: default_state_dir) rescue nil
50
- return error_response(404, "not_found", "Session not found: #{id}") unless session
50
+ return session_not_found(id) unless session
51
51
 
52
52
  body = parse_json(request_body(req))
53
53
  return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
@@ -207,7 +207,7 @@ module Samagotchi
207
207
  when :accepted
208
208
  record_history(body, prompt)
209
209
  # via: bridge or file (the worker was gone: its input file), which
210
- # names the turn a remote chi send --wait follows (WaitTarget).
210
+ # names the turn a remote chi send --wait follows (ReplyWait::Target).
211
211
  json_response(202, result.ack.merge("via" => result.via.to_s))
212
212
  when :refused then json_response(result.code, result.ack)
213
213
  when :timeout then error_response(504, "worker_timeout", result.detail)
@@ -239,7 +239,7 @@ module Samagotchi
239
239
  return [] if raw.nil?
240
240
 
241
241
  session_dir = image_session_dir(id)
242
- return "unknown session" unless session_dir
242
+ return Session::NotFound.new(id).message unless session_dir
243
243
 
244
244
  ImageStore.check_refs(session_dir, raw)
245
245
  end
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  # (converted, downscaled); answers the ref the turn then names.
260
260
  def handle_image_upload(req, id)
261
261
  session_dir = image_session_dir(id)
262
- return error_response(404, "not_found", "unknown session") unless session_dir
262
+ return session_not_found(id) unless session_dir
263
263
 
264
264
  length = req.content_length.to_i
265
265
  if length > MAX_IMAGE_UPLOAD_BYTES
@@ -140,7 +140,7 @@ module Samagotchi
140
140
  end
141
141
 
142
142
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
143
- # link of that session's chain (SessionManager.continue_session), in its
143
+ # link of that session's chain (SessionContinue.run), in its
144
144
  # folder and with its own setup (SessionSetup: llm_context, thinking
145
145
  # and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
146
146
  # refused beside it (a create takes no reasoning_history). It runs on
@@ -328,11 +328,11 @@ module Samagotchi
328
328
  [level, nil]
329
329
  end
330
330
 
331
- # SessionManager.continue_session, its refusals as responses: [session,
331
+ # SessionContinue.run, its refusals as responses: [session,
332
332
  # nil] or [nil, the response]. Only its own errors: what fails after
333
333
  # the new link started is not a refusal of it.
334
334
  def continue_from(given, prompt:, preview:, model:)
335
- [@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
335
+ [SessionContinue.run(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
336
336
  rescue SessionContinue::Refused => e
337
337
  extra = e.reason == :continued ? { next_id: e.ids.first } : {}
338
338
  [nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
@@ -343,8 +343,8 @@ module Samagotchi
343
343
  return if dir_stat.mtime == @dir_mtime
344
344
 
345
345
  @dir_mtime = dir_stat.mtime
346
- on_disk = Dir.glob(File.join(@state_dir, "*#{Session::FILE_EXT}")).to_h do |path|
347
- [File.basename(path, Session::FILE_EXT), path]
346
+ on_disk = Dir.glob(File.join(@state_dir, "*#{SessionPaths::FILE_EXT}")).to_h do |path|
347
+ [File.basename(path, SessionPaths::FILE_EXT), path]
348
348
  end.select { |id, _| Session.valid_id?(id) }
349
349
  (@sessions.keys - on_disk.keys).each { |id| drop(id) }
350
350
  on_disk.each_key { |id| @sessions[id] ||= Entry.new }
@@ -404,21 +404,13 @@ module Samagotchi
404
404
  emit("session", session: summary)
405
405
  end
406
406
 
407
- # One HostRegistry kept across scans: every session's ctx_pct may
408
- # need its llm_context budget resolved, and the registry's config read
409
- # is what would otherwise repeat per scan and per session. Built on
410
- # the first summary that needs it, and built again when config.yml's
411
- # hosts: changed (compared as read, like App#host_registry: the YAML
412
- # read is mtime-cached), so a host added or a window changed while
413
- # chi web runs is picked up on the next refresh of a card.
414
- def hub_registry
415
- hosts = ConfigFile.hosts_config
416
- if @hub_registry.nil? || hosts != @hub_registry_hosts
417
- @hub_registry = HostRegistry.new(hosts_config: hosts)
418
- @hub_registry_hosts = hosts
419
- end
420
- @hub_registry
421
- end
407
+ # The registry kept across scans (HostRegistry.current, built again
408
+ # when config.yml's hosts: changed): every session's ctx_pct may need
409
+ # its llm_context budget resolved, and the registry's config read is
410
+ # what would otherwise repeat per scan and per session; a host added
411
+ # or a window changed while chi web runs is picked up on the next
412
+ # refresh of a card.
413
+ def hub_registry = HostRegistry.current
422
414
 
423
415
  def drop(id)
424
416
  entry = @sessions.delete(id)
@@ -4,6 +4,8 @@ require "time" # Time.iso8601
4
4
  require "uri"
5
5
 
6
6
  require_relative "../analytics_file"
7
+ require_relative "../bridge/turn_accumulator"
8
+ require_relative "../bridge/turn_drawings"
7
9
  require_relative "../answer_refs"
8
10
  require_relative "../answer_tail"
9
11
  require_relative "../objectives"
@@ -67,13 +69,27 @@ module Samagotchi
67
69
  session_json = shown_session_json(session, snapshot, owner, persisted)
68
70
  raw_messages = live_or_saved(turn_snapshot, "messages") { session.messages }
69
71
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
72
+ parts = req.params["parts"] == "1"
73
+ # A page that draws finished turns from their drawings (drawings=1,
74
+ # with parts=1): the valid lines stamp their items, which lose their
75
+ # parts. An older page (no drawings=1) reads what it always did.
76
+ # A live worker from before turn drawings lists no of_turn on its
77
+ # cards: its turns all draw as before.
78
+ drawings = parts && req.params["drawings"] == "1" && (!live || worker_draws?(id))
79
+ drawn = drawings ? drawn_lines(id, raw_messages) : []
80
+ messages = messages_for_display(raw_messages, parts: parts, cwd: session.working_directory,
81
+ drawings: drawn, strip_drawn: true)
70
82
  json_response(200, {
71
83
  session: session_json,
72
84
  history: read_history(id),
73
- messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
74
- cwd: session.working_directory),
85
+ messages: messages,
86
+ **turn_drawings_field(drawn, messages),
75
87
  failed_turn: failed_turn_for(session, raw_messages),
76
88
  current_turn: current_turn,
89
+ # The page's join: the turn in progress and the queue behind it as
90
+ # the live events that draw them (TurnAccumulator.join_events, one
91
+ # unfold, here: any worker's snapshot works).
92
+ replay: replay_events_for(turn_snapshot, timing),
77
93
  queued: snapshot_list(turn_snapshot, "queued"),
78
94
  # Commands waiting for the running turn's end (their bubbles).
79
95
  queued_commands: snapshot_list(turn_snapshot, "queued_commands"),
@@ -110,6 +126,52 @@ module Samagotchi
110
126
  view ? { objectives: view } : {}
111
127
  end
112
128
 
129
+ # The live events a joining page replays (`replay`): the worker's
130
+ # +turn_snapshot+'s turn, queued prompts and commands, the turn's start
131
+ # from +timing+'s active_turn; [] without a worker or with nothing.
132
+ def replay_events_for(turn_snapshot, timing)
133
+ return [] unless turn_snapshot
134
+
135
+ wire = Bridge::TurnAccumulator.from_wire(turn_snapshot.slice("current_turn", "queued", "queued_commands"))
136
+ active = timing[:active_turn]
137
+ Bridge::TurnAccumulator.join_events(current_turn: wire[:current_turn], queued: wire[:queued],
138
+ queued_commands: wire[:queued_commands],
139
+ started_at: active.is_a?(Hash) ? active["started_at"] : nil)
140
+ end
141
+
142
+ # Whether the session's live worker names turn drawings in its
143
+ # sidecar's features (Bridge::TurnDrawings::FEATURE); false when its
144
+ # link can't be read as live.
145
+ def worker_draws?(id)
146
+ link = worker_link(id)
147
+ link.live? && Array(link.sidecar.features).include?(Bridge::TurnDrawings::FEATURE)
148
+ end
149
+
150
+ # The session's turn drawings that hold against +messages+ (the live
151
+ # snapshot's, else the file's): Bridge::TurnDrawings, cached by the
152
+ # file's size and mtime; the newest SHOWN_MAX of them.
153
+ # A drawing that can't be read draws its turn as before.
154
+ def drawn_lines(id, messages)
155
+ Degrade.call(:web, "turn_drawings_unreadable", fallback: []) do
156
+ dir = @session_class.session_dir(id, state_dir: default_state_dir)
157
+ Bridge::TurnDrawings.valid(Bridge::TurnDrawings.read(dir), messages).last(Bridge::TurnDrawings::SHOWN_MAX)
158
+ end
159
+ end
160
+
161
+ # {turn_drawings: {turn_id => {events:, end:}}} for the stamped turns
162
+ # (chi web's one unfold of a drawing, its cards too); nothing without
163
+ # one.
164
+ def turn_drawings_field(lines, messages)
165
+ stamped = messages.filter_map { |item| item[:drawn] }.uniq
166
+ shown = lines.select { |line| stamped.include?(line.turn_id) }
167
+ return {} if shown.empty?
168
+
169
+ { turn_drawings: shown.to_h do |line|
170
+ turn = Bridge::TurnAccumulator.from_wire(line.drawing)
171
+ [line.turn_id, { events: Bridge::TurnAccumulator.replay_events(turn, cards: true), end: line.end }]
172
+ end }
173
+ end
174
+
113
175
  # A list in the worker's turn +snapshot+ (queued prompts, commands,
114
176
  # init tasks); [] without a worker.
115
177
  def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
@@ -184,7 +246,7 @@ module Samagotchi
184
246
  # cards from its light GET tail, else the saved ones; the session file
185
247
  # is only stat'ed (the 404), never parsed.
186
248
  def handle_cards_read(id)
187
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
249
+ return session_not_found(id) unless session_exists?(id)
188
250
 
189
251
  tail = live_tail(id)
190
252
  json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
@@ -201,7 +263,7 @@ module Samagotchi
201
263
  # are the newest turn's (#recent_timing), which the page merges by id
202
264
  # (the page still sends ?recent=1, which is accepted and not needed).
203
265
  def handle_tail_read(req, id)
204
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
266
+ return session_not_found(id) unless session_exists?(id)
205
267
 
206
268
  turn_id = req.params["turn_id"].to_s.strip
207
269
  turn_id = nil if turn_id.empty?
@@ -259,7 +321,7 @@ module Samagotchi
259
321
  # the live ones) and turn_count, when the page's merge comes up short.
260
322
  # The worker's GET state, never its messages.
261
323
  def handle_timing_read(id)
262
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
324
+ return session_not_found(id) unless session_exists?(id)
263
325
 
264
326
  state = bridge_get_json(id, "state")
265
327
  json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
@@ -7,10 +7,11 @@ require_relative "../verify_fresh"
7
7
  require_relative "../recap_store"
8
8
  require_relative "../archive_store"
9
9
  require_relative "../session_metrics"
10
- require_relative "../llm_context_strategy"
10
+ require_relative "../effective_model"
11
11
  require_relative "../host_registry"
12
12
  require_relative "../bridge_client"
13
13
  require_relative "../worker_link"
14
+ require_relative "../session_row"
14
15
  require_relative "../bridge/pending_card"
15
16
 
16
17
  module Samagotchi
@@ -20,6 +21,14 @@ module Samagotchi
20
21
  # session is answered (GET /api/sessions, the session view, the session
21
22
  # hub's events), so every path agrees on status, owner and bridge_up.
22
23
  module SessionSummary
24
+ # The page's keys, in the order it has always had them (a hub event
25
+ # compares with ==, a 0.156 page reads them by name).
26
+ WEB_KEYS = %i[id status mode model_name working_directory created_at updated_at last_prompt short_id test_run
27
+ used_memory_names preloaded_memory_names muted_memory_names prompt_notes parent_id delegate
28
+ continues review first_preview title owner recap auto_title ctx_pct tokens memory_index side
29
+ archived project_root bridge_up worker_version worker_features pending_question pending_card
30
+ last_turn verdicts verify_fresh last_verdict].freeze
31
+
23
32
  module_function
24
33
 
25
34
  # @param session [Session] a saved session (messages may be empty)
@@ -39,64 +48,66 @@ module Samagotchi
39
48
  # @return [Hash] symbol keys
40
49
  def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil,
41
50
  registry: nil)
42
- used = Array(session.used_memory_names)
43
51
  link = WorkerLink.resolve(session_dir, unlink: false, owner: owner)
44
52
  up = link.live?
45
53
  sidecar = up ? link.sidecar : nil
46
54
  saved = SessionMetrics.saved_summary(session_dir, budget_tokens: budget_resolver(session, registry))
47
- recap_card = RecapStore.card(session_dir)
48
- {
49
- id: session.id,
55
+ # The facts every list shares (SessionRow), from this one link,
56
+ # analytics and recap read.
57
+ row = SessionRow.build(session, session_dir: session_dir, owner: owner, link: link, roots: root_cache || {},
58
+ saved: saved, card: RecapStore.card(session_dir))
59
+ values = {
60
+ id: row.id,
50
61
  status: status,
51
62
  mode: session.mode,
52
63
  model_name: session.model_name,
53
- working_directory: session.working_directory,
64
+ working_directory: row.cwd,
54
65
  created_at: session.created_at,
55
- updated_at: session.updated_at,
66
+ updated_at: row.updated_at,
56
67
  last_prompt: session.last_prompt,
57
- short_id: session.id.to_s[0, 8],
58
- test_run: !!session.test_run,
59
- used_memory_names: used,
68
+ short_id: row.short_id,
69
+ test_run: !!row.test_run,
70
+ used_memory_names: Array(session.used_memory_names),
60
71
  # The session's --memory and --mute lists (info-bar tooltip).
61
72
  preloaded_memory_names: Array(session.preloaded_memory_names),
62
73
  muted_memory_names: Array(session.muted_memory_names),
63
74
  # The model notes its prompt carried (the info bar's notes chip).
64
75
  prompt_notes: Array(session.prompt_notes).map(&:to_h),
65
76
  # The session that delegated this one (the `delegate` tool), else nil.
66
- parent_id: session.parent_id,
77
+ parent_id: row.parent_id,
67
78
  # Started by the delegate tool (Session#delegate?); a fork has a
68
79
  # parent_id too but isn't one. The parent's children chip counts
69
80
  # delegates only, as the TUI's segment and /children do.
70
- delegate: session.delegate?,
81
+ delegate: row.delegate,
71
82
  # The session this one continues (the previous link of its
72
83
  # chain), else nil: the lists draw chains from it.
73
- continues: session.continues,
84
+ continues: row.continues,
74
85
  # A review session's {subjects, from} (SessionReview), else nil:
75
86
  # the session bar offers Start review on the others.
76
87
  review: session.review&.to_file,
77
88
  first_preview: first_preview_for(session),
78
89
  # The session's own name (/title), else nil: the pages show it in
79
90
  # place of the first message.
80
- title: session.title,
81
- owner: owner&.kind,
91
+ title: row.title,
92
+ owner: row.owner,
82
93
  # The saved recap's first sentence, for the session card, and the
83
94
  # auto title its reply led with (RecapTitle), else nil: an untitled
84
95
  # session's name on the pages and its pushes (never in a model
85
96
  # request).
86
- recap: recap_card.preview,
87
- auto_title: recap_card.title,
97
+ recap: row.recap,
98
+ auto_title: row.auto_title,
88
99
  # How full the context was after the last turn (%), or nil, and
89
100
  # the session's token sums and cost (the card's ctx tooltip), from
90
101
  # one read of analytics.json.
91
- ctx_pct: saved&.ctx_pct&.round(1),
102
+ ctx_pct: row.ctx_pct,
92
103
  tokens: saved&.tokens,
93
104
  # The memory indexes its prompt held (the card's ctx tooltip).
94
105
  memory_index: saved&.memory_index,
95
106
  # Its side requests, apart from the tokens (the tooltip's own line).
96
107
  side: saved&.side,
97
108
  # Hidden from the strip and the list unless the all view's "archived" toggle is on.
98
- archived: ArchiveStore.archived?(session_dir),
99
- project_root: session.project_root(cache: root_cache),
109
+ archived: row.archived,
110
+ project_root: row.project,
100
111
  # A worker is reachable: WorkerLink answers Live (the lock held by
101
112
  # a worker whose sidecar names its pid). A sidecar a dead worker
102
113
  # left is not up.
@@ -108,8 +119,8 @@ module Samagotchi
108
119
  # For the tab's notifications (notify.js): an open question, the
109
120
  # running turn's open card with actions, and how the last turn
110
121
  # ended. A question saved by a worker that died is not open, nor a
111
- # one-turn chi run's (only a worker shares it).
112
- pending_question: session.waiting_question(live: !!owner&.worker?),
122
+ # one-turn chi run's (only a worker shares it): the row's live.
123
+ pending_question: session.waiting_question(live: row.live),
113
124
  pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
114
125
  last_turn: session.last_turn&.to_file,
115
126
  # Its Verify verdicts, the fresh ones its worker hasn't taken yet
@@ -117,9 +128,10 @@ module Samagotchi
117
128
  # ({of, child_id}: the card's "verifying fresh" and its link).
118
129
  verdicts: VerifyFresh.verdicts_of(session, session_dir),
119
130
  verify_fresh: VerifyFresh.running(session.id, state_dir: File.dirname(session_dir))
120
- .map { |row| { of: row[:of], child_id: row[:child_id] } },
131
+ .map { |entry| { of: entry[:of], child_id: entry[:child_id] } },
121
132
  last_verdict: last_verdict(session, session_dir)
122
133
  }
134
+ WEB_KEYS.to_h { |key| [key, values.fetch(key)] }
123
135
  end
124
136
 
125
137
  def last_verdict(session, session_dir)
@@ -141,18 +153,17 @@ module Samagotchi
141
153
  # no network) is resolved only for a session that has a saved context
142
154
  # to count. One registry per caller (the hub) or one built here.
143
155
  def budget_resolver(session, registry)
144
- -> { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
156
+ -> { EffectiveModel.session_budget(session, registry: registry ||= HostRegistry.current) }
145
157
  end
146
158
  private_class_method :budget_resolver
147
159
 
148
160
  # status is turn state (idle/running). The live worker's snapshot is the
149
- # truth; on disk, a "running" with no live owner was left by a worker
150
- # that died mid-turn.
161
+ # truth; on disk, Session#displayed_status (a "running" with no live
162
+ # owner was left by a worker that died mid-turn).
151
163
  def displayed_status(session, snapshot = nil, owner:)
152
164
  return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
153
- return session.status unless session.status == Session::STATUS_RUNNING
154
165
 
155
- owner ? session.status : Session::STATUS_IDLE
166
+ session.displayed_status(owner: owner)
156
167
  end
157
168
 
158
169
  def first_preview_for(session)
@@ -37,6 +37,7 @@ require_relative "model_profile"
37
37
  require_relative "child_reports"
38
38
  require_relative "verify_fresh"
39
39
  require_relative "child_move"
40
+ require_relative "starting_marker"
40
41
  require_relative "context_absorber"
41
42
  require_relative "context_candidates"
42
43
  require_relative "context_poller"
@@ -47,7 +48,7 @@ require_relative "participant_name"
47
48
 
48
49
  module Samagotchi
49
50
  # The loop of a background session worker, once it owns the session (see
50
- # SessionManager.run_session_loop, which takes the OwnerLock around #run).
51
+ # WorkerBoot.run_session_loop, which takes the OwnerLock around #run).
51
52
  #
52
53
  # It runs the session's Engine and Bridge, takes queued prompts from the
53
54
  # input dir one turn at a time, and returns when nobody has used it for the
@@ -57,8 +58,8 @@ module Samagotchi
57
58
  # exits with 1.
58
59
  #
59
60
  # The file IPC stays behind SessionInbox's functions (find_new_input_files,
60
- # claim_input_file, ...) and the Bridge behind SessionManager.start_bridge,
61
- # which specs stub as seams.
61
+ # claim_input_file, ...) and the Bridge behind #start_bridge, which specs
62
+ # stub as seams.
62
63
  #
63
64
  # The loop sleeps on a Waker, which the Bridge wakes when it queues a turn
64
65
  # or a command, and the reminder callback when it queues one. A fallback
@@ -132,11 +133,11 @@ module Samagotchi
132
133
  # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
133
134
  # @param poll_interval [Numeric, nil] seconds between fallback ticks
134
135
  # @param signals [WorkerSignals, WorkerSignals::None] the process's
135
- # trapped signals (SessionManager.run_session_loop); none by default
136
+ # trapped signals (WorkerBoot.run_session_loop); none by default
136
137
  # @param signal_grace [Array(Numeric, Numeric, Numeric)] the grace, the
137
138
  # short grace and the hard exit's delay (specs shorten them)
138
139
  # @param owner_lock [OwnerLock, nil] the session's, which this process
139
- # holds (SessionManager.run_session_loop): marked leaving as the Bridge
140
+ # holds (WorkerBoot.run_session_loop): marked leaving as the Bridge
140
141
  # stops, so an attach waits for the recap on leave (OwnerLock.await_release)
141
142
  def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil,
142
143
  signals: WorkerSignals::NONE, signal_grace: [SIGNAL_GRACE, SIGNAL_SHORT_GRACE, SIGNAL_HARD_EXIT],
@@ -185,16 +186,16 @@ module Samagotchi
185
186
  end
186
187
 
187
188
  # Whether the session was empty as the worker left it, so the caller
188
- # deletes it once the lock is free (SessionManager.run_session_loop
189
+ # deletes it once the lock is free (WorkerBoot.run_session_loop
189
190
  # checks again then).
190
191
  def discard? = @discard
191
192
 
192
193
  # Whether this worker runs a `chi scratch` session: deleted as it leaves
193
- # (SessionManager.run_session_loop).
194
+ # (WorkerBoot.run_session_loop).
194
195
  def scratch? = !!@session&.scratch
195
196
 
196
197
  # Whether delegate reports wait that an idle parent runs a turn for
197
- # (#wake_state): SessionManager.run_session_loop starts a new worker
198
+ # (#wake_state): WorkerBoot.run_session_loop starts a new worker
198
199
  # for them after this one leaves. Reports the budget or a failure held
199
200
  # back wait for the next human input instead.
200
201
  def wake_due? = wake_state == :due
@@ -208,7 +209,7 @@ module Samagotchi
208
209
  # (chi stop), :signal when a signal stopped it (WorkerSignals),
209
210
  # :crashed when the loop raised (the session is marked errored),
210
211
  # :scratch_left when a scratch session's terminal is gone (the caller
211
- # deletes it); SessionManager.run_session_loop turns it into the exit
212
+ # deletes it); WorkerBoot.run_session_loop turns it into the exit
212
213
  def run
213
214
  # The thread a signal's expired grace raises in (the main thread in a
214
215
  # worker process).
@@ -261,7 +262,7 @@ module Samagotchi
261
262
  # This worker holds the session now (a continue's starting marker held
262
263
  # off any other until it did), and a continue that died moving
263
264
  # delegates here left its intent: finish it.
264
- ChildMove.clear_starting(@session_id, state_dir: @state_dir)
265
+ StartingMarker.clear(@session_id, state_dir: @state_dir)
265
266
  ChildMove.adopt(@session_id, state_dir: @state_dir)
266
267
  @session = Session.load(@session_id, state_dir: @state_dir)
267
268
  # A newer chi saved it: this worker keeps the fields it doesn't know
@@ -362,25 +363,25 @@ module Samagotchi
362
363
  # The Bridge (UIs and clients reach the worker from here on), then the
363
364
  # idle-exit policy.
364
365
  def serve
365
- @bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
366
- on_input: -> { @waker.wake },
367
- on_command: method(:on_command),
368
- on_exit_request: method(:exit_request),
369
- # Decided again as it leaves (a note may still come in).
370
- exit_discards: method(:empty_session?),
371
- # What a stop-all /exit stopped, for its 200.
372
- exit_stopped: method(:stopped_holds),
373
- # chi web's Stop asks about these before TERMed-ing
374
- # the worker (its own holds routes).
375
- on_holds: -> { @idle_exit&.stoppable_holds },
376
- on_holds_stop: -> { @hold_stop&.stop_all })
366
+ @bridge = start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
367
+ on_input: -> { @waker.wake },
368
+ on_command: method(:on_command),
369
+ on_exit_request: method(:exit_request),
370
+ # Decided again as it leaves (a note may still come in).
371
+ exit_discards: method(:empty_session?),
372
+ # What a stop-all /exit stopped, for its 200.
373
+ exit_stopped: method(:stopped_holds),
374
+ # chi web's Stop asks about these before TERMed-ing
375
+ # the worker (its own holds routes).
376
+ on_holds: -> { @idle_exit&.stoppable_holds },
377
+ on_holds_stop: -> { @hold_stop&.stop_all })
377
378
  # What failed to load and what plugins showed as they loaded, now that
378
379
  # a UI can be there (a late one gets them in the snapshot).
379
380
  @engine.announce_load_events!
380
381
  announce_session_warnings
381
382
  @idle_exit = WorkerIdleExit.new(
382
383
  engine: @engine, bridge: @bridge,
383
- timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
384
+ timeout_minutes: @idle_exit_minutes || config_idle_exit_minutes,
384
385
  input_pending: method(:input_pending?),
385
386
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
386
387
  running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) },
@@ -398,6 +399,33 @@ module Samagotchi
398
399
  state_dir: @state_dir)
399
400
  end
400
401
 
402
+ # Start the in-process Bridge transport for this worker. The bridge is
403
+ # the single live client transport, so every worker starts it. Bridge
404
+ # creation happens *before* the loop so the capture observer is in place
405
+ # for the whole session; the caller stops the returned instance on exit
406
+ # (see #run's ensure).
407
+ #
408
+ # @return [Samagotchi::Bridge, nil] nil when the transport failed to start
409
+ # (the worker degrades: turns still flow through the input-dir loop, but
410
+ # there is no live SSE or in-process cancel/answer).
411
+ # @param on_input [#call, nil] called after the Bridge queues a turn
412
+ def start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
413
+ exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
414
+ require_relative "bridge"
415
+ Samagotchi::Bridge.new(
416
+ engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
417
+ on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
418
+ exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
419
+ ).start
420
+ rescue StandardError => e
421
+ Log.error(:bridge, "start_failed", echo: "Bridge: failed to start for session #{session_id}: #{e.class}: #{e.message}", sid: session_id, error: e.class.name)
422
+ nil
423
+ end
424
+
425
+ def config_idle_exit_minutes
426
+ Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
427
+ end
428
+
401
429
  # The delegate children of this session that are running a turn or have
402
430
  # a live worker. ChildrenStatus.of scans the session files; this runs on
403
431
  # an exit request, not every pass.
@@ -648,13 +676,14 @@ module Samagotchi
648
676
  save_or_log(:interrupted_turn) { save_session }
649
677
  end
650
678
 
651
- # The per-input tombstones (removed, settled) older than
679
+ # The per-input tombstones (removed, settled, merged) older than
652
680
  # SessionInbox::TOMBSTONE_MAX_AGE, once per worker start: they only go
653
681
  # with the session dir otherwise.
654
682
  def prune_tombstones
655
683
  removed = SessionInbox.prune_removed(@session_dir)
656
684
  settled = TurnCheckpoint.prune_settled(@session_dir)
657
- Log.info(:worker, "tombstones_pruned", removed: removed, settled: settled) if (removed + settled).positive?
685
+ merged = SessionInbox.prune_merged(@session_dir)
686
+ Log.info(:worker, "tombstones_pruned", removed: removed, settled: settled, merged: merged) if (removed + settled + merged).positive?
658
687
  end
659
688
 
660
689
  # A saved parent_answers that isn't a level reads as hard on every
@@ -882,14 +911,31 @@ module Samagotchi
882
911
  # A failure has no prompt to hand back (the Engine announced
883
912
  # :turn_failed). The offer went before the turn
884
913
  # (ContinueOffer#drop); either way the rollback window closes.
885
- run_engine_turn(nil, continue: true, origin: REMINDER_ORIGIN,
914
+ run_engine_turn(nil, continue: true, origin: wake_origin(REMINDER_ORIGIN),
886
915
  max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
887
916
  true
888
917
  end
889
918
 
890
- # A reminder turn's origin: chi's own (system:reminder).
919
+ # A reminder turn's origin: chi's own (system:reminder). Its run gets
920
+ # an id of its own (#wake_origin); this constant has none.
891
921
  REMINDER_ORIGIN = TurnOrigin.new(client_id: ClientId::REMINDER)
892
922
 
923
+ # A turn nobody typed (a reminder, a wake) with an enqueued_id of its
924
+ # own, minted here as it runs, so its checkpoint mark, last_turn, reply
925
+ # file and settle name it (a wait with no message binds it:
926
+ # ReplyWait.own_turn). Never on the origin constants nor on
927
+ # ChildReports::Report#origin: a report merged into a turn keeps an
928
+ # id-less origin, which the web page reads as a report (turn_events.js
929
+ # promptOps), not as an unmatched steer.
930
+ # @return [TurnOrigin] +origin+ with an id ("wake-<16 hex>")
931
+ def wake_origin(origin)
932
+ origin.enqueued_id ? origin : origin.with(enqueued_id: "#{WAKE_ID_PREFIX}#{SecureRandom.hex(8)}")
933
+ end
934
+
935
+ # A wake turn's minted enqueued_id starts with this
936
+ # (SessionInbox::ENQUEUED_ID_NAME).
937
+ WAKE_ID_PREFIX = "wake-"
938
+
893
939
  # :due when an idle parent should run a turn for its delegate
894
940
  # children's reports now (session.delegate_reports: wake): rings wait,
895
941
  # no continue offer does (they merge into its turn), wakes aren't paused
@@ -948,7 +994,7 @@ module Samagotchi
948
994
  def run_wake(origin:, failed:, kept: nil, mark: nil)
949
995
  @turn_flow.before_prompt_turn
950
996
  restores = @turn_flow.restores
951
- run_engine_turn(nil, continue: true, origin: origin, id: mark&.turn_id,
997
+ run_engine_turn(nil, continue: true, origin: wake_origin(origin), id: mark&.turn_id,
952
998
  max_iterations: IterationLimit.for) do |result, error|
953
999
  if error
954
1000
  steps_kept = @turn_flow.prompt_turn_failed(error: error, wake: failed, wake_kept: kept) == :kept
@@ -1303,6 +1349,8 @@ module Samagotchi
1303
1349
  origin = fresh_origin(turn_args[:origin])
1304
1350
  # Settled here: the checkpoint's prompt carries it from the start.
1305
1351
  turn_args = turn_args.merge(origin: origin, id: turn_args[:id] || SecureRandom.uuid)
1352
+ # The id its drain's merge records name (SessionInbox.mark_merged).
1353
+ @turn_enqueued_id = origin.enqueued_id
1306
1354
  verdicts_before = @session.verdicts.size
1307
1355
  @reporting_turn = reports_to_parent?(origin)
1308
1356
  # A participant's review turn merges no input (#pending_input_drain).
@@ -1512,6 +1560,9 @@ module Samagotchi
1512
1560
  begin
1513
1561
  input = SessionInbox.read_input(claimed_file)
1514
1562
  prompt = input.prompt.to_s.strip
1563
+ # Which turn took it, on disk before its file goes (a wait for
1564
+ # it reads this, not an inference: ReplyWait::Poll#follow_merge).
1565
+ SessionInbox.mark_merged(@session_dir, input.origin.enqueued_id, into: @turn_enqueued_id) unless prompt.empty?
1515
1566
  prompt.empty? ? nil : [prompt, input.origin]
1516
1567
  ensure
1517
1568
  FileUtils.rm_f(claimed_file)
@@ -1634,7 +1685,7 @@ module Samagotchi
1634
1685
 
1635
1686
  # #leave_idle for a scratch worker its terminal left: checked again with
1636
1687
  # the event log held, then the intake closes. The session is deleted
1637
- # once the lock is free (SessionManager.run_session_loop: :scratch_left).
1688
+ # once the lock is free (WorkerBoot.run_session_loop: :scratch_left).
1638
1689
  # @return [Boolean] false when a client came back since
1639
1690
  def leave_scratch
1640
1691
  stop_bridge_after do
@@ -1842,8 +1893,8 @@ module Samagotchi
1842
1893
  # A restart hands the reminders to the next worker (ReminderCarry), while
1843
1894
  # the event log is held: no turn can register or fire one from here. A
1844
1895
  # write that fails keeps this worker up with them rather than losing them.
1845
- # A session stopped meanwhile drops them (run_session_loop then starts no
1846
- # successor): nothing is written.
1896
+ # A session stopped meanwhile drops them (WorkerBoot.run_session_loop
1897
+ # then starts no successor): nothing is written.
1847
1898
  # @return [Boolean] false when the write failed
1848
1899
  def carry_reminders
1849
1900
  if stopped_on_disk?
@@ -1881,7 +1932,7 @@ module Samagotchi
1881
1932
  # @return [Symbol] +result+, with #discard? decided and, for a session
1882
1933
  # kept, its recap written: the Bridge is closed and the idle jobs are
1883
1934
  # stopped by now, and nothing waits on this (the TUI has detached). A
1884
- # `chi send` meanwhile is picked up after the exit (run_session_loop);
1935
+ # `chi send` meanwhile is picked up after the exit (WorkerBoot.run_session_loop);
1885
1936
  # a `chi --attach` finds no Bridge until then.
1886
1937
  # A restart keeps even an empty session (a new worker takes it over at
1887
1938
  # once) and leaves the recap to the conversation's next real pause.