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
@@ -5,6 +5,13 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
+ # Everything the parts (session_manager/*.rb, required at the end) need loads
9
+ # here, before the class opens: a file declaring `autoload :SessionManager`
10
+ # (child_move, reply_wait, ...) loaded after that point, while
11
+ # SessionManager autoloads, drops the half-built class, and the parts then
12
+ # build another without the facade's methods (spec/require_cycle_spec.rb).
13
+ # Parts require only the leaves their class bodies read (none autoloads
14
+ # SessionManager: spec/require_cycle_spec.rb).
8
15
  require_relative "chi_home"
9
16
  require_relative "system_one/runs"
10
17
  require_relative "child_env"
@@ -19,7 +26,7 @@ require_relative "participant_messages"
19
26
  require_relative "session"
20
27
  require_relative "session_inbox"
21
28
  require_relative "session_metrics"
22
- require_relative "llm_context_strategy"
29
+ require_relative "effective_model"
23
30
  require_relative "session_setup"
24
31
  require_relative "host_registry"
25
32
  require_relative "utf8_default"
@@ -41,6 +48,7 @@ require_relative "model_profile"
41
48
  require_relative "installed_gem"
42
49
  require_relative "installed_versions"
43
50
  require_relative "recap_store"
51
+ require_relative "session_row"
44
52
  require_relative "reminder_carry"
45
53
  require_relative "archive_store"
46
54
  require_relative "continue_offer"
@@ -50,6 +58,7 @@ require_relative "context_sources"
50
58
  require_relative "context_candidates_log"
51
59
  require_relative "session_chain"
52
60
  require_relative "child_move"
61
+ require_relative "starting_marker"
53
62
  require_relative "tools/task_runtime"
54
63
 
55
64
  module Samagotchi
@@ -76,8 +85,6 @@ module Samagotchi
76
85
  # ├── output/ # agent writes responses here
77
86
  # │ └── <timestamp>.txt # one file per agent response
78
87
  # └── bridge.json # Bridge sidecar (how clients reach the worker)
79
- # Needed only at call time (run_session_loop); worker.rb requires this file.
80
- autoload :Worker, File.expand_path("worker", __dir__)
81
88
  # session_retention requires this file.
82
89
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
83
90
  # session_continue requires this file.
@@ -85,6 +92,16 @@ module Samagotchi
85
92
  # Needed only on an archive or a delete (it loads Grants).
86
93
  autoload :Participants, File.expand_path("participants", __dir__)
87
94
 
95
+ # Its methods live in parts, each a module this class extends (required
96
+ # at the end of this file; each loads alone, but is meant to be loaded
97
+ # through this file):
98
+ # session_manager/spawn.rb SpawnPart: start a session's worker, the spawns remembered
99
+ # session_manager/worker_command.rb WorkerCommandPart: the worker process (CROSS-VERSION CONTRACT)
100
+ # session_manager/listing.rb ListingPart: the session lists and summary rows, children, replies
101
+ # session_manager/delivery.rb DeliveryPart: a user message into a session (Bridge or input file)
102
+ # session_manager/lifecycle.rb LifecyclePart: stop and restart a worker
103
+ # session_manager/removal.rb RemovalPart: archive, unarchive, delete
104
+ # session_manager/emptiness.rb EmptinessPart: is a session empty, is a left one deleted
88
105
  class SessionManager
89
106
  # Raised when a terminal's own chi owns the session (OwnerLock kind
90
107
  # "tui": a -p --non-interactive one-shot, TerminalUI): it runs its own
@@ -108,1544 +125,6 @@ module Samagotchi
108
125
  def initialize(session_id) = super(session_id, Session::SCRATCH_REFUSED)
109
126
  end
110
127
 
111
- # A delete that would pull the session from under its live worker.
112
- # reason: :worker_running (not asked to stop it) or :still_stopping
113
- # (stopped, but the worker outlived the wait).
114
- class DeleteRefused < StandardError
115
- attr_reader :session_id, :reason
116
-
117
- def initialize(session_id, reason)
118
- @session_id = session_id
119
- @reason = reason
120
- super(reason == :still_stopping ? "session #{session_id}'s worker is still shutting down" : "session #{session_id}'s worker is running")
121
- end
122
- end
123
-
124
- # An archive that can't happen now: a turn runs in the session or in one
125
- # of its children (+busy_id+ names which), or it is a scratch session.
126
- class ArchiveRefused < StandardError
127
- attr_reader :session_id, :reason, :busy_id
128
-
129
- def initialize(session_id, reason, busy_id: nil)
130
- @session_id = session_id
131
- @reason = reason
132
- @busy_id = busy_id
133
- super(archive_refused_message)
134
- end
135
-
136
- private
137
-
138
- def archive_refused_message
139
- case @reason
140
- when :scratch then "a scratch session is deleted when you leave; nothing to archive"
141
- when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
142
- when :queued then "a prompt is queued, which a resume would run; let it run first"
143
- when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
144
- when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
145
- when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
146
- else "a turn is running; wait for it or cancel it first"
147
- end
148
- end
149
- end
150
-
151
- # Spawn a new background session that processes the given prompt (or,
152
- # with none, waits idle for input).
153
- #
154
- # Returns the session object with its ID. Every worker always starts its
155
- # per-session Bridge (the single live client transport), so external
156
- # clients can reach it once the sidecar is published.
157
- # @param memories [Array<String>] --memory: preloaded into the worker's prompt
158
- # @param muted_memories [Array<String>] --mute: hidden from the session
159
- # (both are session fields, so a respawn keeps them)
160
- # @param parent_id [String, nil] the session that delegates this one (the
161
- # `delegate` tool) or was forked from; a session field too
162
- # @param messages [Array<Hash>] a conversation to start from (a plugin's
163
- # ctx.sessions.fork); its image refs are copied from +images_from+
164
- # @param images_from [String, nil] the session dir the seed's images are in
165
- # @param preview [String, nil] what the lists show before the first turn
166
- # (the prompt's preview by default, else the seed's first user message)
167
- # @param title [String, nil] the session's own name (Session#title; a
168
- # continue's link inherits its previous link's), saved before the
169
- # worker starts: the worker owns the file from then on
170
- # @param setup [SessionSetup] the session's own settings (chi
171
- # --llm-context; a fork and a continue copy their session's; a delegate
172
- # child the toolkit its delegate call named); without a toolkit the
173
- # session is stored on config's toolkit.default, a delegate child on
174
- # toolkit.child (Toolkit.configured)
175
- # @param continues [String, nil] the session this one continues (a
176
- # session field; SessionContinue)
177
- # @param note [String, nil] a context note the session starts with,
178
- # from +note_source+: queued before its worker spawns, so its first
179
- # turn sees it
180
- # @param review [SessionReview, nil] a review session's (ReviewStart; a
181
- # continue keeps its previous link's)
182
- # @param verify_fresh [VerifyFreshOrigin, nil] a Verify fresh child's
183
- # requester, rules and claim (VerifyFresh.start)
184
- # @param before_spawn [#call, nil] (session) runs once the session and
185
- # its note are saved, before its worker spawns
186
- # @param scratch [Boolean] a `chi scratch` session (its terminal's
187
- # AttachLauncher): its worker runs a scratch Engine and deletes it
188
- # once its last client is gone
189
- # @return [Session] with #seed_images_dropped: refs whose file was gone,
190
- # and #model_warning: an id its host's saved list doesn't have
191
- def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
192
- memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
193
- preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
194
- note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
195
- sd = state_dir || Session.default_state_dir
196
- # The resolved ref is stored (a resumed session keeps its model when an
197
- # alias is retargeted), with the name as typed beside it. A wrong host
198
- # refuses here, before anything is saved or spawned; a model id the
199
- # host's saved list doesn't have (ModelListStore) only warns
200
- # (#model_warning on the session), as some hosts serve ids they don't
201
- # list.
202
- typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
203
- model_warning = Samagotchi::ModelProfile.model_warning(typed)
204
- ref = ConfigFile.model_ref(typed).ref
205
- session = Session.new_session(
206
- mode: mode,
207
- model_name: ref,
208
- model_typed: typed == ref ? nil : typed,
209
- working_directory: working_directory || Dir.pwd,
210
- preloaded_memory_names: memories,
211
- muted_memory_names: muted_memories,
212
- parent_id: parent_id,
213
- messages: messages,
214
- delegate: delegate,
215
- setup: setup,
216
- continues: continues,
217
- review: review,
218
- scratch: scratch
219
- )
220
- session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
221
- session.toolkit_origin = setup.toolkit_origin || "default"
222
- # Saved before the worker starts: its first turn is already the requester's.
223
- session.verify_fresh = verify_fresh
224
- session.model_warning = model_warning
225
- # With no prompt there is no first turn to run (an attaching UI sends
226
- # the prompts), so the session starts idle.
227
- session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
228
- session.last_prompt = prompt
229
- # The first prompt's turn gets an id like a sent message's, so a wait
230
- # for it can name it (chi send --new --wait, chi web's create): the
231
- # worker runs it with that origin, and its reply file and last_turn
232
- # carry the id.
233
- session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
234
- # The worker takes last_prompt and clears it, and messages are saved at
235
- # the turn's end: until then this is the only preview a list has.
236
- session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
237
- session.title = title
238
- session_dir = Session.session_dir(session.id, state_dir: sd)
239
- unless session.messages.empty?
240
- session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
241
- session.seed_images_dropped = dropped
242
- end
243
- setup_session_directory(session_dir, session, state_dir: sd)
244
- SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
245
- before_spawn&.call(session)
246
- spawn_worker_for_session(session, state_dir: sd)
247
- session
248
- end
249
-
250
- # Start the next link of a chain (SessionContinue.run, which has the
251
- # rules and raises SessionContinue::Refused).
252
- def self.continue_session(...) = SessionContinue.run(...)
253
-
254
- # Build the opts hash passed to Process.spawn for a forked worker. The
255
- # child inherits this process's ENV and reads config.yml itself (as it
256
- # is when the worker starts); opts[:env] adds to it (merged, not
257
- # replaced) what it can't read there: this `chi`'s CLI settings
258
- # (Config.cli_env) and the absolute log file. It unsets
259
- # what ChildEnv lists (SAMAGOTCHI_GUARDRAILS_*, the session markers).
260
- # XDG_CONFIG_HOME passes: whoever starts or
261
- # wakes the worker picks which config.yml it reads (docs/guardrails.md).
262
- # XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
263
- # (Paths): the worker starts in the session's directory.
264
- private_class_method def self.spawn_options(session)
265
- # Own process group: workers outlive `chi web`, and a Ctrl-C in its
266
- # terminal must not reach them (the worker then leaves the process
267
- # tree too: WORKER_DETACH_ENV).
268
- opts = { out: File::NULL, err: File::NULL, pgroup: true }
269
- # The worker's tools (and `!cmd`) run in the session's directory, not
270
- # in the cwd of whoever woke it (`chi web`, another terminal).
271
- dir = session.working_directory.to_s
272
- if !dir.empty? && File.directory?(dir)
273
- opts[:chdir] = dir
274
- else
275
- Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
276
- end
277
- # (A chi a session's tool runs took that session's project env out at
278
- # its start: bin/chi, ProjectEnv.take_orig!.)
279
- child_env = {}
280
- # A worker gets no CLI args: --model, --thinking, --log-level and the
281
- # other flags this chi was started with.
282
- child_env.merge!(Config.cli_env)
283
- child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
284
- # And the spawner's debug log, absolute: a relative log.file would
285
- # otherwise land in the worker's (the session's) directory.
286
- log_path = begin LogPath.resolve rescue nil end # degrade-ok: no log to say it in; the worker's is off (below)
287
- if log_path
288
- child_env["SAMAGOTCHI_LOG_FILE"] = log_path
289
- else
290
- child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
291
- end
292
- # And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
293
- # XDG_CONFIG_HOME would otherwise be read from the session's directory.
294
- child_env.merge!(Paths.absolute_xdg)
295
- # Unset (Process.spawn unsets a key mapped to nil) what no child chi
296
- # takes from its spawner (ChildEnv): the spawner's guardrails settings
297
- # (a parent agent starting or waking a session; config.yml no longer
298
- # reads them, this keeps an older or future env-exposed one out as
299
- # well); the session markers, a session's own (a worker started from
300
- # its commands, chi send, is another session: its execute and tasks
301
- # set its own; nothing else it runs has one); and a smoke's fake
302
- # "newest installed", for this process's notices only.
303
- child_env.merge!(ChildEnv.unset)
304
- # A worker process: run_session_loop traps signals, and leaves this
305
- # process's tree.
306
- child_env[WORKER_PROCESS_ENV] = "1"
307
- child_env[WORKER_DETACH_ENV] = "1"
308
- # A gem-mode worker activates the newest installed chi itself: under
309
- # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
310
- # hold it to the bundle's version, and its badge would never clear.
311
- child_env.merge!(ChildEnv.unbundled)
312
- opts[:env] = child_env unless child_env.empty?
313
- opts
314
- end
315
-
316
- # List all sessions, reading status from persisted session.json files.
317
- # +project_root+: only that project's sessions (nil: every session).
318
- # +include_archived+: archived sessions too (Session#archived says which).
319
- def self.list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
320
- include_archived: false)
321
- Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
322
- offset: offset, project_root: project_root, include_archived: include_archived)
323
- end
324
-
325
- SUMMARY_DESC_LIMIT = 60
326
-
327
- # Short summaries of sessions, newest first: the picker behind
328
- # `chi sessions list --live --format json|tsv` (chi note from a script)
329
- # and the agent's list_sessions tool; both pass the current project as
330
- # +project_root+ unless asked for every project.
331
- # @param live [Boolean] only sessions a worker owns now (the owner lock,
332
- # not the saved status, which a dead worker leaves at "running"); a
333
- # session a one-shot (or an older chi's REPL) owns is left out: it
334
- # can't take notes
335
- # @param cwd [String, nil] only sessions in this folder or below it
336
- # @param project_root [String, nil] only this project's sessions
337
- # (Session#project_root)
338
- # @param limit [Integer, nil] taken after the filters
339
- # @param include_tests [Boolean] false leaves out test runs
340
- # @param exclude [String, nil] a session id to leave out (the asker)
341
- # @param include_archived [Boolean] archived sessions too
342
- # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
343
- # updated_at:, status:, live:, busy:, owner:, recap:, auto_title:, parent_id:,
344
- # parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
345
- # waiting_id:, stopped_by:}; busy =
346
- # live with a turn running, delegate = started by the delegate tool
347
- # (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
348
- # previous link of its chain, Session#continues), recap = the saved recap's first sentence, auto_title =
349
- # the auto title its reply led with (RecapTitle) or nil, project =
350
- # Session#project_root, waiting = the kind of question it waits on
351
- # ("question", "approval", "hook"; Session#waiting_question) or nil,
352
- # waiting_id = that question's id or nil, relayed_to = the parent
353
- # whose card it waits in too (the approval relay), its short id, or nil,
354
- # stopped_by = the hook that stopped the last turn (Session#stopped_by)
355
- def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
356
- project_root: nil, include_archived: false, sort: nil, order: nil)
357
- sd = state_dir || Session.default_state_dir
358
- root = cwd && folder_path(cwd)
359
- roots = {}
360
- # One registry per listing, built only when a row has a saved context
361
- # to count: its session's llm_context budget is what ctx_pct counts
362
- # against (SessionMetrics.saved_context_pct).
363
- budgets = listing_budgets
364
- summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
365
- include_archived: include_archived).lazy
366
- .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
367
- .select { |s| root.nil? || in_folder?(s.working_directory, root) }
368
- .filter_map do |s|
369
- owner = session_owner(s.id, state_dir: sd)
370
- owned = !!owner&.worker?
371
- next if live && !owned
372
-
373
- { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
374
- project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
375
- owner: owner&.kind, **recap_fields(Session.session_dir(s.id, state_dir: sd)),
376
- ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
377
- budget_tokens: -> { budgets.call(s) })&.round(1),
378
- parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
379
- continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
380
- scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
381
- end
382
- (limit ? summaries.first(limit) : summaries.to_a)
383
- end
384
-
385
- # waiting: the question's kind, waiting_id: its id (what chi answer
386
- # --question takes); both nil with none.
387
- def self.waiting_fields(waiting)
388
- { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
389
- end
390
- private_class_method :waiting_fields
391
-
392
- # One HostRegistry for a whole listing, built on the first row that
393
- # needs it (a saved context to count) and kept for the rest: resolving a
394
- # session's llm_context budget reads config.yml (mtime-cached), so not
395
- # one registry per row.
396
- # @return [#call] (session) -> the session's budget in tokens or nil
397
- def self.listing_budgets
398
- registry = nil
399
- ->(session) { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
400
- end
401
- private_class_method :listing_budgets
402
-
403
- # The sessions delegated by +parent_id+ (the `delegate` tool), newest
404
- # first, as .session_summaries rows. A running one (busy) counts against
405
- # session.max_children. Archived ones too.
406
- def self.children_of(parent_id, state_dir: nil)
407
- return [] if parent_id.to_s.empty?
408
-
409
- session_summaries(state_dir: state_dir, include_tests: true, include_archived: true)
410
- .select { |s| s[:parent_id] == parent_id.to_s }
411
- end
412
-
413
- # What one archive did: id: the session it was asked for; archived: the
414
- # ids (it and its delegates) now hidden for good; stopped: the live
415
- # workers stopped first; discarded: empty sessions its stopping worker deleted.
416
- ArchiveResult = Data.define(:id, :archived, :stopped, :discarded)
417
-
418
- # Archive a session and its delegated children (ArchiveStore): hidden
419
- # from every list, kept by the retention sweep. A live idle worker is
420
- # stopped first; the marker is written even while it shuts down.
421
- # @return [ArchiveResult]
422
- # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
423
- # @raise [OwnedByTUI] a one-turn chi run owns it or one of its children
424
- # @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
425
- # queued in one (a later resume would run it), a live one offers to
426
- # continue past the step limit, or it is a scratch session
427
- # @param except [Array<String>] descendants left alone with their own
428
- # subtrees (delegates a continue moves to the chain's new link)
429
- def self.archive_session(id_or_prefix, state_dir: nil, wait: 5, except: [])
430
- sd = state_dir || Session.default_state_dir
431
- id = archive_target(id_or_prefix, sd)
432
- tree, owners = archivable!(id, sd, except: except)
433
- stopped = tree.select { |sid| owners[sid] }
434
- stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
435
- archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
436
- # A restart's reminders hand-off no successor took (the stop above
437
- # removes a live one's): archived sessions keep no reminders.
438
- archived.each { |sid| ReminderCarry.remove(Session.session_dir(sid, state_dir: sd)) }
439
- # D8: an archive ends the review (unarchiving doesn't restore it).
440
- Participants.end_sessions(archived, by: "archive", state_dir: sd)
441
- ArchiveResult.new(id: id, archived: archived, stopped: stopped, discarded: discarded)
442
- end
443
-
444
- # The session and its descendants, with their owners, when they can be
445
- # archived now (#archive_refusal for each), else the refusal. Public for
446
- # SessionContinue, which checks before its recap wait.
447
- # @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
448
- # @raise [ArchiveRefused, OwnedByTUI]
449
- def self.archivable!(id, state_dir, except: [])
450
- raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
451
-
452
- tree = [id, *descendant_ids(id, state_dir, except: except)]
453
- owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
454
- tree.each do |sid|
455
- refusal = archive_refusal(sid, owners[sid], state_dir)
456
- raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
457
- end
458
- [tree, owners]
459
- end
460
-
461
- # What keeps +sid+ (in an archive's tree) from being archived: :busy (a
462
- # turn runs), :queued (input/ holds a prompt, which outlives the stop and
463
- # runs on a resume, live worker or not), :offer (a live worker's continue
464
- # offer, which the stop would drop), or nil.
465
- # @raise [OwnedByTUI] a one-turn chi run owns it
466
- def self.archive_refusal(sid, owner, state_dir)
467
- raise OwnedByTUI, sid if owner&.tui?
468
-
469
- session = owner && Session.load(sid, state_dir: state_dir)
470
- return :busy if session&.status == Session::STATUS_RUNNING
471
- return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
472
-
473
- :offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
474
- end
475
- private_class_method :archive_refusal
476
-
477
- # Unarchive a session and its delegated children.
478
- # @return [Hash] {id:, unarchived: [ids that were archived]}
479
- # @raise [ArgumentError] unknown id
480
- def self.unarchive_session(id_or_prefix, state_dir: nil)
481
- sd = state_dir || Session.default_state_dir
482
- id = archive_target(id_or_prefix, sd)
483
- unarchived = [id, *descendant_ids(id, sd)].select { |sid| ArchiveStore.unarchive(sid, state_dir: sd) }
484
- { id: id, unarchived: unarchived }
485
- end
486
-
487
- private_class_method def self.archive_target(id_or_prefix, state_dir)
488
- given = id_or_prefix.to_s
489
- raise ArgumentError, "no session #{given}" unless Session.valid_id?(given)
490
-
491
- id = Session.resolve_id(given, state_dir: state_dir)
492
- raise ArgumentError, "no session #{given}" unless Session.exist?(id, state_dir: state_dir)
493
-
494
- id
495
- end
496
-
497
- # Children, their children, …: a delegated session doesn't delegate
498
- # further today, a plugin's fork may. The walk doesn't enter +except+.
499
- def self.descendant_ids(id, state_dir, except: [])
500
- seen = [id]
501
- queue = [id]
502
- until queue.empty?
503
- children_of(queue.shift, state_dir: state_dir).each do |child|
504
- next if seen.include?(child[:id]) || except.include?(child[:id])
505
-
506
- seen << child[:id]
507
- queue << child[:id]
508
- end
509
- end
510
- seen.drop(1)
511
- end
512
-
513
- SUMMARY_PREVIEW_LIMIT = 120
514
-
515
- # "<cwd basename> · <last prompt, or the first preview>", one line.
516
- private_class_method def self.summary_desc(session)
517
- desc = [File.basename(session.working_directory.to_s), summary_text(session)].reject(&:empty?).join(" · ")
518
- cut(desc, SUMMARY_DESC_LIMIT)
519
- end
520
-
521
- # A session row's recap: and auto_title: (RecapStore.card, one read).
522
- private_class_method def self.recap_fields(session_dir)
523
- card = RecapStore.card(session_dir)
524
- { recap: card.preview, auto_title: card.title }
525
- end
526
-
527
- private_class_method def self.summary_preview(session)
528
- cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
529
- end
530
-
531
- # A session as one line of text for the lists: its title (/title), else
532
- # its last prompt, else the first message it was started with (a session
533
- # command like `/model x`, or a first turn not saved yet).
534
- def self.summary_text(session)
535
- return one_line(session.title) unless session.title.to_s.strip.empty?
536
-
537
- one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
538
- end
539
-
540
- # A prompt as one line for a session list: whitespace collapsed, and a
541
- # quoted or annotated message (ContextQuote, the web's annotations)
542
- # without its "> " markers, so the words show.
543
- def self.one_line(text)
544
- text.to_s.gsub(/^[ \t]*(?:>[ \t]?)+/, "").gsub(/\s+/, " ").strip
545
- end
546
-
547
- private_class_method def self.cut(text, limit)
548
- text.length > limit ? "#{text[0, limit - 1]}…" : text
549
- end
550
-
551
- private_class_method def self.folder_path(path)
552
- File.realpath(path)
553
- rescue SystemCallError
554
- File.expand_path(path)
555
- end
556
-
557
- private_class_method def self.in_folder?(dir, root)
558
- dir = dir.to_s.chomp("/")
559
- root = root.chomp("/")
560
- dir == root || dir.start_with?("#{root}/")
561
- end
562
-
563
- # The retention sweep (SessionRetention.sweep_if_due), here for the web
564
- # app and hub, which take this class as their manager.
565
- def self.retention_sweep_if_due(state_dir: nil) = SessionRetention.sweep_if_due(state_dir: state_dir)
566
-
567
- # Ensure an existing session has a live worker process.
568
- # Returns the loaded session after state reconciliation.
569
- # @raise [OwnedByTUI] when the interactive TUI owns the session
570
- def self.resume_session(session_id, state_dir: nil)
571
- sd = state_dir || Session.default_state_dir
572
- session = Session.load(session_id, state_dir: sd)
573
- # Its terminal's worker came from spawn_session; nothing wakes another.
574
- raise ScratchSession, session.id if session.scratch
575
- return session if refuse_tui!(session.id, state_dir: sd)
576
-
577
- # status is turn state, not liveness: a new worker runs no turn yet,
578
- # and must not find the session stopped (it would exit): neither the
579
- # marker nor a status field from before it.
580
- # One this process just spawned may not hold the lock yet (a slow
581
- # start, deliver_turn's second resume): it would only lose it.
582
- return session if still_starting?(session.id, state_dir: sd)
583
- # A continue moving delegates to it spawns its worker once they are
584
- # moved; one spawned now (a moved child's ring) would race the move.
585
- return session if ChildMove.starting?(session.id, state_dir: sd)
586
-
587
- Session.clear_stopped(session.id, state_dir: sd)
588
- session.status = Session::STATUS_IDLE
589
- session.save(state_dir: sd)
590
- spawn_worker_for_session(session, state_dir: sd)
591
- session
592
- end
593
-
594
- # A delegate child rang a parent that has no worker (ChildRing): start
595
- # one, which runs a turn for the report. Not for a parent that is gone,
596
- # stopped, archived, a scratch session, a one-turn run's, or a
597
- # continue's new link still taking its delegates (its own spawn comes):
598
- # the ring waits on disk for whoever runs it next (#resume_session
599
- # itself would clear the stop). A stop landing between the check and
600
- # the resume is a small window, left as is. An archived parent with a
601
- # later link in its chain wakes that link instead: a ring a moved child
602
- # wrote just as the continue moved it reaches the new link that way
603
- # (ChildReports reads its predecessor's rings).
604
- # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
605
- # :scratch, :owned, :starting)
606
- def self.wake_for_report(session_id, state_dir: nil, forward: true)
607
- sd = state_dir || Session.default_state_dir
608
- return :gone unless Session.exist?(session_id, state_dir: sd)
609
- return :owned if session_owner(session_id, state_dir: sd)
610
- return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
611
-
612
- if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
613
- following = forward && SessionChain.next_of(session_id, state_dir: sd)
614
- return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
615
- end
616
- return :starting if ChildMove.starting?(session_id, state_dir: sd)
617
-
618
- session = Session.load(session_id, state_dir: sd)
619
- return :stopped if session.status == Session::STATUS_STOPPED
620
- return :scratch if session.scratch
621
-
622
- resume_session(session_id, state_dir: sd)
623
- Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
624
- :woken
625
- rescue OwnedByTUI
626
- :owned
627
- end
628
-
629
- # How long after a spawn a resume trusts that worker to take the
630
- # session, while it runs (and the session wasn't stopped since).
631
- WORKER_START_GRACE = WorkerLink::START_LIMIT
632
-
633
- @spawns = {}
634
- @spawns_mutex = Mutex.new
635
-
636
- # A worker this process spawned for the session within
637
- # WORKER_START_GRACE is still running, and no stop came since.
638
- private_class_method def self.still_starting?(session_id, state_dir:)
639
- return false if Session.stopped_marker?(session_id, state_dir: state_dir)
640
-
641
- at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
642
- return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
643
-
644
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
645
- end
646
-
647
- private_class_method def self.note_spawn(session_id, state_dir:, waiter:)
648
- @spawns_mutex.synchronize do
649
- @spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
650
- @spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
651
- end
652
- end
653
-
654
- # Forget the spawns #still_starting? remembers (specs).
655
- def self.forget_spawns
656
- @spawns_mutex.synchronize { @spawns.clear }
657
- end
658
-
659
- # Starter processes are children of chi web, unlike the detached workers
660
- # they launch; the web restart adopts these pids before replacing itself.
661
- def self.live_spawn_pids
662
- @spawns_mutex.synchronize do
663
- @spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
664
- @spawns.values.filter_map do |_, waiter|
665
- waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
666
- end.uniq.sort
667
- end
668
- end
669
-
670
- # Read any new output files from a session directory.
671
- # Optionally filters to only files newer than `since_time`.
672
- def self.read_responses(session_id, since_time: nil, state_dir: nil)
673
- SessionInbox.read_outputs(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir),
674
- since_time: since_time)
675
- end
676
-
677
- # A restart that can't happen now. reason: :not_running (no live worker:
678
- # the next prompt starts one on the newest chi anyway), :unsupported (a
679
- # worker from before restarts, running +version+: stop it instead), a
680
- # WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
681
- # :fallback_held (disk-visible work blocks an unresponsive-worker restart),
682
- # :stopped (its worker, +pid+, is SIGSTOPped: neither the ask nor a TERM
683
- # reaches it until it is continued), or :failed (the worker answered something else or didn't release).
684
- class RestartRefused < StandardError
685
- attr_reader :session_id, :reason, :version, :holds, :pid
686
-
687
- def initialize(session_id, reason, version: nil, holds: [], pid: nil, via: ChiInvocation::HERE)
688
- @session_id = session_id
689
- @reason = reason.to_sym
690
- @version = version
691
- @holds = Array(holds).map(&:to_sym).freeze
692
- @pid = pid
693
- super(RestartRefused.words(session_id, @reason, version, @holds, pid: pid, via: via))
694
- end
695
-
696
- HOLDS = {
697
- starting: "its worker is still starting",
698
- turn_running: "a turn is running",
699
- input_queued: "a prompt is queued",
700
- continue_offered: "the step-limit question waits for an answer",
701
- question_pending: "a question or approval waits for an answer",
702
- # Only a worker from before ReminderCarry still holds a restart for its reminders.
703
- reminders: "it has reminders, which its worker's older chi can't carry over a restart",
704
- command_running: "a /btw (or another command) is still running",
705
- relays_open: "a delegate's approval relayed here is open or just answered",
706
- background_tasks: "background tasks it started are still running",
707
- scratch: "a chi scratch session's worker ends with its terminal"
708
- }.merge(WorkerIdleExit::LEAVING_WORDS).freeze
709
-
710
- # What the CLI and the web say.
711
- def self.words(session_id, reason, version = nil, holds = [], pid: nil, via: ChiInvocation::HERE)
712
- stop = via.command("sessions stop #{session_id}")
713
- case reason
714
- when :stopped
715
- where = via.remote? ? " on #{via.remote}" : ""
716
- "session #{session_id}'s worker#{" (pid #{pid})" if pid} is stopped (SIGSTOP or Ctrl-Z) and can't take " \
717
- "a restart: continue it#{" with `kill -CONT #{pid}`" if pid}#{where}, then restart again"
718
- when :not_running
719
- "session #{session_id} has no running worker; the next prompt starts one on the newest chi"
720
- when :unsupported
721
- "session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
722
- "#{stop}, then any prompt starts it on the newest chi"
723
- when :fallback_held
724
- why = holds.map { |hold| HOLDS.fetch(hold, hold.to_s.tr("_", " ")) }.join("; ")
725
- "session #{session_id} has disk-visible work that a restart might lose#{": #{why}" unless why.empty?}; " \
726
- "run `#{stop}`, then any prompt starts it on the newest chi"
727
- when :failed then "session #{session_id}'s worker didn't take the restart"
728
- else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
729
- end
730
- end
731
- end
732
-
733
- # What a restart did: the worker's version before and after (nil when
734
- # the new worker hadn't published its Bridge within the wait).
735
- Restarted = Struct.new(:session_id, :from_version, :version, keyword_init: true)
736
-
737
- RESTART_WAIT = 10.0
738
- # A TERMed worker writes its recap on leave (an LLM call) while it still
739
- # holds the lock: STOP_WAIT plus the recap's own wait (SessionContinue::RECAP_WAIT).
740
- FALLBACK_STOP_WAIT = 40
741
-
742
- # Hand a session to a new worker on the newest chi installed: ask its
743
- # worker (POST /exit restart: true), which leaves only when nothing would
744
- # be lost and starts its successor. If the worker cannot be asked, stop
745
- # it without a stop marker and resume only when disk shows no work to lose.
746
- # @return [Restarted]
747
- # @raise [OwnedByTUI] a one-turn chi run owns the session
748
- # @raise [RestartRefused]
749
- # @raise [ArgumentError] no such session
750
- def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: ClientId::CLI_RESTART)
751
- sd = state_dir || Session.default_state_dir
752
- Session.load(session_id, state_dir: sd) # ArgumentError for none
753
- owner = refuse_tui!(session_id, state_dir: sd)
754
- # A stopped worker answers neither the ask (READ_TIMEOUT) nor the
755
- # fallback's TERM (FALLBACK_STOP_WAIT): say so now, not after ~70 s.
756
- raise RestartRefused.new(session_id, :stopped, pid: owner.pid) if owner&.worker? && ProcessGroup.stopped?(owner.pid)
757
-
758
- dir = Session.session_dir(session_id, state_dir: sd)
759
- link = WorkerLink.resolve(dir, unlink: false, owner: owner)
760
- if link.is_a?(WorkerLink::Skew)
761
- return restart_fallback(session_id, owner: owner, sidecar: link.sidecar, state_dir: sd, wait: wait, why: :skew)
762
- end
763
-
764
- if link.is_a?(WorkerLink::Starting)
765
- return restart_fallback(session_id, owner: owner, sidecar: nil, state_dir: sd, wait: wait, why: :starting_stuck) if link.stuck
766
-
767
- raise RestartRefused.new(session_id, :starting)
768
- end
769
- raise RestartRefused.new(session_id, :not_running) unless link.live?
770
-
771
- sidecar = link.sidecar
772
- unless sidecar.features.include?("restart")
773
- raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
774
- end
775
-
776
- begin
777
- reply = BridgeClient.for_sidecar(session_id, sidecar).request_exit(client_id: client_id, restart: true)
778
- rescue SystemCallError, IOError => e
779
- return restart_fallback(session_id, owner: owner, sidecar: sidecar, state_dir: sd, wait: wait, why: :unreachable,
780
- error: e)
781
- end
782
- raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
783
- if reply.secret_refused?
784
- return restart_fallback(session_id, owner: owner, sidecar: sidecar, state_dir: sd, wait: wait, why: :bridge_secret)
785
- end
786
- raise RestartRefused.new(session_id, :failed) unless reply.status == 200
787
-
788
- Log.info(:worker, "restart", sid: session_id, from: sidecar.version, by: client_id)
789
- fresh = WorkerLink.await(session_id, session_dir: dir, timeout: wait, unlink: false, interval: 0.1,
790
- until: ->(live) { live.epoch != sidecar.epoch || live.started_at != sidecar.started_at })
791
- # Timed out: the last outcome, the old worker's Live too, is no successor.
792
- changed = fresh.live? && (fresh.epoch != sidecar.epoch || fresh.started_at != sidecar.started_at)
793
- version = fresh.version if changed
794
- Restarted.new(session_id: session_id, from_version: sidecar.version, version: version)
795
- end
796
-
797
- def self.restart_fallback(session_id, owner:, sidecar:, state_dir:, wait:, why:, error: nil)
798
- Log.info(:worker, "restart_fallback", sid: session_id, why: why, error: error&.class&.name, msg: error&.message)
799
- dir = Session.session_dir(session_id, state_dir: state_dir)
800
- session = Session.load(session_id, state_dir: state_dir)
801
- holds = restart_fallback_holds(session, dir)
802
- raise RestartRefused.new(session_id, :fallback_held, holds: holds) unless holds.empty?
803
- raise RestartRefused.new(session_id, :failed, version: sidecar&.version) unless (owner&.pid || 0).to_i.positive?
804
-
805
- # The ask may have taken READ_TIMEOUT: TERM only the owner read at the
806
- # start; a successor that took the lock meanwhile is awaited, not killed.
807
- current = session_owner(session_id, state_dir: state_dir)
808
- if current.nil?
809
- resume_session(session_id, state_dir: state_dir)
810
- elsif current.pid == owner.pid && current.started_at == owner.started_at
811
- begin
812
- Process.kill("TERM", owner.pid)
813
- rescue Errno::ESRCH
814
- nil
815
- rescue SystemCallError => e
816
- Log.warn(:worker, "restart_fallback_failed", sid: session_id, why: why, error: e.class.name, msg: e.message)
817
- raise RestartRefused.new(session_id, :failed, version: sidecar&.version)
818
- end
819
- unless wait_for_owner_release(session_id, timeout: FALLBACK_STOP_WAIT, state_dir: state_dir)
820
- raise RestartRefused.new(session_id, :failed, version: sidecar&.version)
821
- end
822
-
823
- resume_session(session_id, state_dir: state_dir)
824
- end
825
- previous_epoch = sidecar&.epoch
826
- previous_started_at = sidecar&.started_at
827
- fresh = WorkerLink.await(session_id, session_dir: dir, timeout: wait, unlink: false, interval: 0.1,
828
- until: ->(live) { live.epoch != previous_epoch || live.started_at != previous_started_at })
829
- changed = fresh.live? && (fresh.epoch != previous_epoch || fresh.started_at != previous_started_at)
830
- Restarted.new(session_id: session_id, from_version: sidecar&.version, version: (fresh.version if changed))
831
- end
832
- private_class_method :restart_fallback
833
-
834
- def self.restart_fallback_holds(session, session_dir)
835
- holds = []
836
- holds << :turn_running if session.status == Session::STATUS_RUNNING
837
- holds << :question_pending if session.pending_question
838
- holds << :input_queued unless SessionInbox.find_new_input_files(session_dir).empty?
839
- holds << :background_tasks if running_created_task?(session)
840
- holds
841
- end
842
- private_class_method :restart_fallback_holds
843
-
844
- def self.running_created_task?(session)
845
- root = session.working_directory.to_s
846
- return false if root.empty?
847
-
848
- Tools::TaskRuntime.created_ids_in(session.messages).any? do |id|
849
- next false unless Tools::TaskRuntime::TASK_ID.match?(id)
850
-
851
- path = File.join(root, Tools::TaskRuntime::TASKS_DIR, id, Tools::TaskRuntime::METADATA_FILENAME)
852
- next false unless File.file?(path)
853
-
854
- Tools::TaskRecord.from_file(JSON.parse(File.read(path)))&.running?
855
- rescue JSON::ParserError, SystemCallError => e
856
- Log.warn(:worker, "restart_task_check_failed", sid: session.id, task_id: id,
857
- error: e.class.name, msg: e.message)
858
- true
859
- end
860
- end
861
- private_class_method :running_created_task?
862
-
863
- # Stop a session by sending TERM (then CONT, for a stopped one) to its process.
864
- # With +wait+ (seconds), also wait for the owner to let go of the session,
865
- # so a resume right after spawns a fresh worker instead of finding the
866
- # dying one.
867
- # A turn still running is canceled first (STOP_CANCEL_WAIT), as the
868
- # web's Cancel does, with reason STOP_CANCEL_REASON: its worker saves it. The TERM stops (and saves)
869
- # what still runs (WorkerSignals), with a short grace after the marker.
870
- # @return [Boolean, nil] with +wait+: whether the owner was gone in time
871
- # @raise [OwnedByTUI] when the interactive TUI owns the session
872
- def self.stop_session(session_id, state_dir: nil, wait: nil)
873
- sd = state_dir || Session.default_state_dir
874
- owner = refuse_tui!(session_id, state_dir: sd)
875
-
876
- cancel_running_turn(session_id, state_dir: sd)
877
- # Mark first: a worker that has not taken the lock yet sees it and exits.
878
- Session.mark_stopped(session_id, state_dir: sd)
879
- # A stop drops the reminders: a restart's hand-off no successor took
880
- # yet must not bring them back on the next resume.
881
- ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
882
- pid = owner&.pid.to_i
883
- begin
884
- Process.kill("TERM", pid) if pid && pid > 0
885
- rescue Errno::ESRCH
886
- nil # already exited
887
- end
888
- continue_stopped(pid)
889
- wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
890
- end
891
-
892
- # A stopped worker (SIGSTOP, Ctrl-Z) runs its TERM handler only once
893
- # continued: CONT it after the TERM (as ProcessGroup.stop does), so it
894
- # leaves now rather than outliving the stop's wait. Best effort: one the
895
- # TERM ended may be gone already.
896
- private_class_method def self.continue_stopped(pid)
897
- Process.kill("CONT", pid) if pid && pid > 0
898
- rescue Errno::ESRCH, Errno::EPERM
899
- nil
900
- end
901
-
902
- STOP_CANCEL_WAIT = 3.0
903
- # How long a stop from the CLI (chi sessions stop, delete, a continue's
904
- # orphan) waits for the worker to let go of the session, here or
905
- # through chi web (POST …/stop's wait, capped at it).
906
- STOP_WAIT = 10
907
- # The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
908
- # not the user's Stop, which only ends the turn ("user").
909
- STOP_CANCEL_REASON = "session_stopped"
910
-
911
- # Cancel the session's running turn over its Bridge and wait (up to
912
- # STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
913
- # live Bridge, or when it doesn't answer, the stop goes on as before.
914
- private_class_method def self.cancel_running_turn(session_id, state_dir:)
915
- return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
916
-
917
- client = WorkerLink.client(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
918
- return unless client && client.cancel(reason: STOP_CANCEL_REASON).status == 202
919
-
920
- BridgeClient.poll(STOP_CANCEL_WAIT) do
921
- Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
922
- end
923
- rescue StandardError => e
924
- Log.info(:worker, "stop_cancel_failed", sid: session_id, error: e.class.name, msg: e.message)
925
- nil
926
- end
927
-
928
- # What a session's directory holds before anything happened in it. Any
929
- # other entry (or a file in one of the EMPTY_DIRS) is something the
930
- # session keeps. "pid" is the pid file workers wrote until 2026-10-01:
931
- # nothing writes or reads it now, but a folder from before still has one
932
- # and is still empty.
933
- EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME].freeze
934
- EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
935
- EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
936
-
937
- # session.keep_empty off: sessions left with nothing in them are deleted
938
- # (the worker as it exits, the retention sweep).
939
- def self.discard_empty?
940
- Degrade.call(:config, "keep_empty_unreadable", fallback: false) { Samagotchi::Config.get("session.keep_empty") != true }
941
- end
942
-
943
- # Whether a session being left (or found left) is deleted: session.keep_empty
944
- # is off and nothing happened in it (#empty_session?). The worker as it
945
- # leaves and the retention sweep both ask this.
946
- # @param default_model [String, nil] what a new session starts on
947
- # @param used_memory_names [Array<String>] memory its owner's Engine used
948
- # before any save
949
- def self.discardable?(session_id, default_model:, state_dir: nil, used_memory_names: [])
950
- return false unless discard_empty? && Array(used_memory_names).empty?
951
-
952
- empty_session?(session_id, state_dir: state_dir || Session.default_state_dir, default_model: default_model)
953
- end
954
-
955
- # A session nothing happened in: no conversation, no turn tried (a failed
956
- # one leaves no messages but a last_prompt and analytics turns), nothing
957
- # queued or attached, and the model and mode a new session gets. A
958
- # session prepared for later (/model, --model, a note, an image, memory)
959
- # is not empty. Anything unreadable or unknown counts as not empty.
960
- # @param default_model [String, nil] what a new session starts on
961
- def self.empty_session?(session_id, state_dir: nil, default_model: nil)
962
- sd = state_dir || Session.default_state_dir
963
- session = Session.load(session_id, state_dir: sd)
964
- return false unless no_conversation?(session.messages) && session.pending_question.nil? && session.used_memory_names.empty?
965
- return false unless session.last_prompt.to_s.strip.empty? && session.first_preview.to_s.strip.empty?
966
- return false unless session.mode.to_s == "assist" && !default_model.nil? && same_model?(session.model_name, default_model)
967
-
968
- empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
969
- rescue ArgumentError, SystemCallError, JSON::ParserError
970
- false
971
- end
972
-
973
- # Whether two model names resolve to the same ref (ModelRef#ref): a
974
- # session stores the resolved ref, the default may be an alias.
975
- def self.same_model?(one, other)
976
- return true if one.to_s == other.to_s
977
- return false if one.nil? || other.nil?
978
-
979
- Degrade.call(:config, "model_ref_unresolved", fallback: false) do
980
- ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
981
- end
982
- end
983
-
984
- # Only the system prompt (an Engine seeds one, a saved session keeps
985
- # it); a context note is a system message too, but with its kind.
986
- # A turn note (the tail system message after a failed, cancelled or empty
987
- # turn) is not a conversation either: a session whose only turn failed
988
- # before any answer is still empty.
989
- def self.no_conversation?(messages)
990
- Array(messages).all? do |msg|
991
- (msg[:role] || msg["role"]).to_s == "system" &&
992
- ((msg[:kind] || msg["kind"]).nil? || TurnNote.note?(msg))
993
- end
994
- end
995
-
996
- # @return [Boolean] whether a session's directory holds only the skeleton
997
- def self.empty_session_dir?(dir)
998
- return true unless Dir.exist?(dir)
999
-
1000
- Dir.children(dir).all? do |name|
1001
- path = File.join(dir, name)
1002
- if EMPTY_SKELETON_DIRS.include?(name)
1003
- File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
1004
- elsif name == AnalyticsFile::NAME
1005
- analytics_empty?(path)
1006
- # Cards and notices from before any turn (Bridge::CardStore).
1007
- elsif name == "cards.json"
1008
- cards_empty?(path)
1009
- else
1010
- EMPTY_SKELETON_FILES.include?(name)
1011
- end
1012
- end
1013
- end
1014
-
1015
- # A folder's analytics.json is empty only when it is a JSON object with
1016
- # an empty turn_records array. Anything unreadable, not JSON, not an
1017
- # object, the wrong type, or turn_records the wrong shape reads as
1018
- # "not empty": a broken analytics keeps the folder instead of crashing
1019
- # the retention sweep (or of a wrong-shape one reading as empty).
1020
- def self.analytics_empty?(path)
1021
- data = read_json(path)
1022
- turns = data.is_a?(Hash) ? data["turn_records"] : nil
1023
- turns.is_a?(Array) && turns.empty?
1024
- end
1025
-
1026
- # A folder's cards.json is empty only when it is a JSON object with no
1027
- # turn in it: an empty turn_log, or an older file's (Bridge::CardStore
1028
- # version 1) turns an integer zero. Anything unreadable, not JSON, not an
1029
- # object, the wrong type, or neither of those reads as "not empty": a
1030
- # broken or wrong-shape cards file keeps the folder instead of raising
1031
- # (Array#/Hash#to_i) or reading as empty.
1032
- def self.cards_empty?(path)
1033
- data = read_json(path)
1034
- return false unless data.is_a?(Hash)
1035
-
1036
- turns = data["turns"]
1037
- data["turn_log"] == [] || (turns.is_a?(Integer) && turns.zero?)
1038
- end
1039
-
1040
- # A session-dir JSON file's contents, or nil when it is missing,
1041
- # unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
1042
- # broken file reads as none, so its folder is kept, not deleted.
1043
- def self.read_json(path)
1044
- JSON.parse(File.read(path))
1045
- rescue JSON::ParserError, SystemCallError
1046
- nil
1047
- end
1048
-
1049
- # Delete one session: its <id>.json, the whole <id>/ directory
1050
- # (history sidecars, input, output, notes, images) and its attached
1051
- # context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
1052
- # /exit --delete and the web all come here.
1053
- # @param id_or_prefix [String] a session id or a unique prefix of one
1054
- # @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
1055
- # @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
1056
- # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
1057
- # @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
1058
- # @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
1059
- # outlived the wait
1060
- # @param purge_runs [Boolean] also delete System 1's saved runs of it and
1061
- # its candidate-log lines; a session a worker left empty has none worth
1062
- # the read
1063
- def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true)
1064
- sd = state_dir || Session.default_state_dir
1065
- given = id_or_prefix.to_s
1066
- # Only a plain id: anything else could name a path outside the state dir.
1067
- raise ArgumentError, "no session #{given}" unless Session.valid_id?(given)
1068
-
1069
- id = Session.resolve_id(given, state_dir: sd)
1070
- path = Session.session_file(id, state_dir: sd)
1071
- dir = Session.session_dir(id, state_dir: sd)
1072
- raise ArgumentError, "no session #{given}" unless File.exist?(path) || Dir.exist?(dir)
1073
-
1074
- owner = refuse_tui!(id, state_dir: sd)
1075
- if owner
1076
- raise DeleteRefused.new(id, :worker_running) unless stop
1077
- raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
1078
- end
1079
-
1080
- removed = []
1081
- if File.exist?(path)
1082
- FileUtils.rm_f(path)
1083
- removed << path
1084
- end
1085
- if Dir.exist?(dir)
1086
- FileUtils.rm_rf(dir)
1087
- removed << dir
1088
- end
1089
- PluginSessionState.remove(id)
1090
- removed.concat(ContextSources.remove_session(id, state_dir: sd))
1091
- Participants.end_sessions([id], by: "delete", state_dir: sd)
1092
- if purge_runs
1093
- purge_session_runs(id, sd)
1094
- ContextCandidatesLog.purge_session(id, state_dir: sd)
1095
- end
1096
- { id: id, removed: removed, stopped: !owner.nil? }
1097
- end
1098
-
1099
- # Never a reason a delete fails: one warn, and the sweep ages them out.
1100
- private_class_method def self.purge_session_runs(id, state_dir)
1101
- SystemOne::Runs.purge_session(id, sessions_dir: state_dir)
1102
- rescue StandardError => e
1103
- Log.warn(:worker, "session_runs_purge_failed", sid: id, error: e.class.name, msg: e.message.to_s[0, 200])
1104
- end
1105
-
1106
- # @return [Boolean] whether the session had no owner within +timeout+ seconds
1107
- def self.wait_for_owner_release(session_id, timeout:, state_dir:)
1108
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
1109
- while session_owner(session_id, state_dir: state_dir)
1110
- return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1111
-
1112
- sleep(OwnerLock::RETRY_INTERVAL)
1113
- end
1114
- true
1115
- end
1116
-
1117
- # Run the session loop inside the forked process.
1118
- # This is the entry point called by Process.spawn.
1119
- #
1120
- # Every worker starts its per-session Bridge (the single live client
1121
- # transport) on a port bound to 127.0.0.1 before the loop and stops it on
1122
- # exit. If the bridge fails to start the worker degrades: turns still flow
1123
- # through the input-dir loop, but there is no live SSE or in-process
1124
- # cancel/answer.
1125
- #
1126
- # The worker first takes the session's OwnerLock; when another owner holds
1127
- # it (a racing resume spawned two workers, or the TUI has the session) it
1128
- # exits quietly.
1129
- #
1130
- # A worker nobody uses returns once session.idle_exit_minutes have passed
1131
- # (see WorkerIdleExit), and one a client asked to exit (Bridge POST /exit)
1132
- # returns as soon as nothing keeps it. The next send wakes a new one.
1133
- # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
1134
- # @param poll_interval [Numeric, nil] seconds between the loop's fallback
1135
- # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
1136
- # A stopped session's worker exits 0 (a signalled one too) and a
1137
- # crashed one 1, there.
1138
- # @return [Symbol] :idle_exit, :exit_requested, :restart or :scratch_left
1139
- def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
1140
- idle_exit_minutes: nil, poll_interval: nil)
1141
- # A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
1142
- Utf8Default.apply!
1143
- sd = state_dir || Session.default_state_dir
1144
- # The spawner passed the file and level through ENV; a worker's stderr
1145
- # is /dev/null, so warnings only reach the file.
1146
- Log.configure(stderr: false)
1147
- Log.session_id = session_id
1148
- session_dir = Session.session_dir(session_id, state_dir: sd)
1149
- # Read (and taken out of ENV, so nothing the worker runs inherits it)
1150
- # before anything else: set by spawn_options for a spawned worker.
1151
- spawned = ENV.delete(WORKER_PROCESS_ENV) == "1"
1152
- # Out of the spawner's process tree before the lock (it records this
1153
- # process's pid) and before any thread starts.
1154
- detach_from_spawner(session_dir) if ENV.delete(WORKER_DETACH_ENV) == "1"
1155
- # Kept in a class ivar so the lock's File lives as long as the worker.
1156
- @owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
1157
- exit(0) unless @owner_lock
1158
- # Set by the boot string when the newest installed chi didn't load.
1159
- fallback = ENV.delete(BOOT_FALLBACK_ENV)
1160
- Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
1161
- Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
1162
- # Tools follow the project (its `mise env`, computed once per worker;
1163
- # this process's ENV is left as it is); never in a loop run in-process.
1164
- apply_project_env(session_id, state_dir: sd) if spawned
1165
- # A spawned worker stops its turn on TERM/HUP/INT and saves it; a
1166
- # loop run in-process (specs) traps nothing.
1167
- signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
1168
- worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
1169
- idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals,
1170
- owner_lock: @owner_lock)
1171
- result = begin
1172
- worker.run
1173
- rescue StandardError, ScriptError => e
1174
- # The worker's stderr is /dev/null: the log is the only trace.
1175
- Log.exception(:worker, "crashed", e)
1176
- raise
1177
- ensure
1178
- @owner_lock.release
1179
- # A signal from here on ends the process as Ruby's default does.
1180
- signals.restore!
1181
- end
1182
- Log.info(:worker, "stop", reason: result)
1183
- # A scratch session goes with its worker however it leaves (its
1184
- # terminal deletes it too, whichever comes first); nothing resumes it.
1185
- if worker.scratch?
1186
- delete_left_scratch(session_id, state_dir: sd)
1187
- exit(result == :crashed ? 1 : 0) if %i[stopped signal crashed].include?(result)
1188
- return result
1189
- end
1190
- # A stopped session is left as it is: no resume for input that came
1191
- # in, no discard. One whose worker a signal stopped isn't discarded
1192
- # either; input that came in (queued behind the turn it stopped)
1193
- # wakes a new worker, as after an idle exit.
1194
- exit(0) if result == :stopped
1195
- if result == :signal
1196
- resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
1197
- exit(0)
1198
- end
1199
- exit(1) if result == :crashed
1200
- # Only after the release: a writer that still saw this worker as the
1201
- # owner may have queued input since the last check. Either it finds no
1202
- # owner after its write and wakes one, or this finds its input.
1203
- # A restart starts its successor here, on the newest chi installed
1204
- # (worker_command), once this one's lock is free.
1205
- # Delegate reports that rang while it left wake one too.
1206
- if result == :restart && Session.stopped_marker?(session_id, state_dir: sd)
1207
- # A stop landed as it left: no successor (resume_session would clear
1208
- # the stop), and the reminders go with it.
1209
- ReminderCarry.remove(session_dir)
1210
- Log.info(:worker, "restart_dropped", why: :stopped)
1211
- elsif result == :restart ||
1212
- (%i[idle_exit exit_requested].include?(result) &&
1213
- (!SessionInbox.find_new_input_files(session_dir).empty? || worker.wake_due?))
1214
- resume_session(session_id, state_dir: sd)
1215
- elsif worker.discard?
1216
- discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
1217
- end
1218
- result
1219
- end
1220
-
1221
- # A spawned worker leaves its spawner's process tree: a killer that
1222
- # walks the tree by parent pid (a terminal closing, a tool's teardown
1223
- # of a `chi send --wait` it ran in the background) no longer reaches it.
1224
- # This process (the spawner's child, which its Process.detach waits on)
1225
- # forks the worker, which starts a session of its own (setsid: no
1226
- # controlling terminal, no SIGHUP from one) and returns to run the loop;
1227
- # this one waits until the worker holds the session's OwnerLock, or has
1228
- # exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
1229
- # stays alive exactly while the worker starts (#still_starting?). The
1230
- # worker is reparented to init (launchd), which reaps it.
1231
- private_class_method def self.detach_from_spawner(session_dir)
1232
- worker = Process.fork
1233
- unless worker
1234
- Process.setsid
1235
- return
1236
- end
1237
-
1238
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_START_GRACE
1239
- until Process.waitpid(worker, Process::WNOHANG) || OwnerLock.owner(session_dir)&.pid == worker ||
1240
- Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1241
- sleep(OwnerLock::RETRY_INTERVAL)
1242
- end
1243
- Process.exit!(0)
1244
- end
1245
-
1246
- # Delete a scratch session its worker left (its terminal gone:
1247
- # Worker#leave_scratch, or any other way out), now the lock is free.
1248
- # Its terminal may have deleted it first.
1249
- private_class_method def self.delete_left_scratch(session_id, state_dir:)
1250
- delete_session(session_id, state_dir: state_dir)
1251
- Log.info(:worker, "scratch_deleted", sid: session_id)
1252
- rescue DeleteRefused, ArgumentError, SystemCallError => e
1253
- Log.info(:worker, "scratch_kept", sid: session_id, error: e.class.name, msg: e.message)
1254
- end
1255
-
1256
- # Delete a session its worker left empty. Checked again now the lock is
1257
- # free: a note or input may have come in since the worker looked, and a
1258
- # worker woken meanwhile owns it (delete_session refuses). A `chi send`
1259
- # landing between this check and the delete fails with "no session"
1260
- # (a window of an empty session's last moments, left as is).
1261
- private_class_method def self.discard_left_session(session_id, state_dir:, default_model:)
1262
- return unless discardable?(session_id, state_dir: state_dir, default_model: default_model)
1263
-
1264
- delete_session(session_id, state_dir: state_dir, purge_runs: false)
1265
- Log.info(:worker, "discarded_empty", sid: session_id)
1266
- rescue DeleteRefused, OwnedByTUI, ArgumentError, SystemCallError => e
1267
- Log.info(:worker, "kept_session", sid: session_id, error: e.class.name, msg: e.message)
1268
- end
1269
-
1270
- # The tools' ProjectEnv for the session's folder (its working_directory;
1271
- # spawn_options chdirs there unless it is gone), installed for this
1272
- # process's tool spawns. Never fatal: a worker without it still runs.
1273
- private_class_method def self.apply_project_env(session_id, state_dir:)
1274
- session = Session.summary_from_file(Session.session_file(session_id, state_dir: state_dir))
1275
- dir = session&.working_directory.to_s
1276
- overlay = ProjectEnv.install!(ProjectEnv.compute(dir))
1277
- Log.info(:worker, "project_env_applied", dir: dir, ruby: overlay.ruby) if overlay.applied
1278
- rescue StandardError => e
1279
- Log.warn(:worker, "project_env", reason: "error", error: e.class.name, msg: e.message)
1280
- end
1281
-
1282
- def self.config_idle_exit_minutes
1283
- Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
1284
- end
1285
-
1286
- private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
1287
- FileUtils.mkdir_p(session_dir)
1288
- FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
1289
- FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
1290
- session.save(state_dir: state_dir)
1291
- end
1292
-
1293
- # The worker's command line. Run from an installed gem, the worker
1294
- # activates the newest installed samagotchi (`>= 0.a`, prereleases
1295
- # count), whoever spawns it: an old chi web, chi send or --attach then
1296
- # starts new workers on the newest chi rather than on its own. Activating
1297
- # the gem also makes its dependencies resolve as its gemspec pins them
1298
- # (reline ~> 0.6.3). Should that fail (a broken newest install), the
1299
- # worker falls back to the spawner's own version and logs why.
1300
- #
1301
- # A source checkout (bin/chi, bundle exec; InstalledGem.spec is nil
1302
- # there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
1303
- #
1304
- # CROSS-VERSION CONTRACT: this boot string, run_session_loop(id,
1305
- # state_dir:), the env keys spawn_options sets and the input/ file
1306
- # format are read by a NEWER chi than the one writing them. Change them
1307
- # only additively (spec/worker_contract_spec.rb).
1308
- BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
1309
- # "1" in a worker spawn_options started: it traps signals (WorkerSignals).
1310
- # run_session_loop deletes it from ENV as it reads it.
1311
- WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
1312
- # "1" in a worker spawn_options started: run_session_loop leaves the
1313
- # spawner's process tree first (detach_from_spawner); deleted from ENV
1314
- # as it is read, so an rspec run by the worker's execute never forks.
1315
- WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
1316
-
1317
- # The boot string's rescue under a chi home: one line in the spawner's
1318
- # log file (SAMAGOTCHI_LOG_FILE, set by spawn_options), LogLine's shape,
1319
- # then exit. Single-quoted: the #{} run in the worker. SID is replaced.
1320
- # rubocop:disable-next Lint/InterpolationCheck -- the worker interpolates these, not this process
1321
- HOME_BOOT_FAILED = 'rescue ScriptError, StandardError => e; f = ENV["SAMAGOTCHI_LOG_FILE"].to_s; begin; ' \
1322
- 'File.open(f, "a") { |io| io.puts(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") + ' \
1323
- '" ERROR worker pid=#{Process.pid} sid=SID boot_failed ruby=#{RbConfig.ruby.inspect} error=" + ' \
1324
- "e.message.lines.first.to_s.strip.inspect) } unless f.empty?; rescue SystemCallError; end; exit 1"
1325
-
1326
- def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
1327
- home: ChiHome.current(gem_spec: gem_spec))
1328
- boot = "require 'samagotchi/session_manager'; " \
1329
- "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
1330
- return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
1331
- return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
1332
-
1333
- activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
1334
- "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
1335
- [RbConfig.ruby, "-e", "#{activate}; #{boot}"]
1336
- end
1337
-
1338
- # With a chi home (ChiHome.current, docs/architecture.md "How chi
1339
- # launches itself"): the home's ruby, `Gem.paths` set to its gems in the
1340
- # boot string (no GEM_* in the worker's env), the newest chi there, else
1341
- # the fallback pin (ChiHome.fallback_version). A home that still can't
1342
- # load chi (a broken install) logs HOME_BOOT_FAILED and exits: never a
1343
- # silent spawn timeout.
1344
- private_class_method def self.home_worker_command(session_id, state_dir:, gem_spec:, home:)
1345
- newest = home.newest_version
1346
- if newest && newest < Gem::Version.new(gem_spec.version.to_s)
1347
- ChiHome.warn_once(:older_than_spawner, home: newest.to_s, spawner: gem_spec.version.to_s)
1348
- end
1349
- fallback = ChiHome.fallback_version(home, gem_spec)
1350
- activate = if fallback
1351
- "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
1352
- "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{fallback}'; end"
1353
- else
1354
- "gem 'samagotchi', '>= 0.a'"
1355
- end
1356
- home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
1357
- "#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
1358
- "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
1359
- end
1360
-
1361
- private_class_method def self.spawn_worker_for_session(session, state_dir:)
1362
- session_dir = Session.session_dir(session.id, state_dir: state_dir)
1363
- FileUtils.mkdir_p(session_dir)
1364
- FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
1365
- FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
1366
-
1367
- opts = spawn_options(session)
1368
- env = opts.delete(:env)
1369
- command = worker_command(session.id, state_dir: state_dir)
1370
- pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
1371
- # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
1372
- # +pid+ is the worker's starter, which exits once the worker it forked
1373
- # holds the lock (detach_from_spawner): the lock's pid is the worker's.
1374
- note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
1375
- Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
1376
- pid
1377
- end
1378
-
1379
- # Start the in-process Bridge transport for this worker. The bridge is
1380
- # the single live client transport, so every worker starts it. Bridge
1381
- # creation happens *before* the loop so the capture observer is in place
1382
- # for the whole session; the caller stops the returned instance on exit
1383
- # (see Worker#run's ensure).
1384
- #
1385
- # @return [Samagotchi::Bridge, nil] nil when the transport failed to start
1386
- # (the worker degrades: turns still flow through the input-dir loop, but
1387
- # there is no live SSE or in-process cancel/answer).
1388
- # @param on_input [#call, nil] called after the Bridge queues a turn
1389
- def self.start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
1390
- exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
1391
- require_relative "bridge"
1392
- Samagotchi::Bridge.new(
1393
- engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
1394
- on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
1395
- exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
1396
- ).start
1397
- rescue StandardError => e
1398
- 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)
1399
- nil
1400
- end
1401
-
1402
- # Write a user turn into a session's input directory via the same file IPC
1403
- # the worker polls. Reused by the bridge's POST surface so a turn is
1404
- # fire-and-forget and never calls run_turn across the thread/process
1405
- # boundary. The file is JSON carrying the sender's ids.
1406
- # @param client_id [String, nil] the sending UI
1407
- # @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
1408
- # @param delivery [String, nil] the wire value: "next_step" (default),
1409
- # "cut" or "queue"
1410
- # @param images [Array<Hash>] image refs ({file:, name:}) in the
1411
- # session's images/
1412
- # @return [String, false] the input file's path, or false.
1413
- def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
1414
- images: [], delivery: nil, author: nil, anchors: nil)
1415
- session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1416
- SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1417
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
1418
- anchors: anchors)
1419
- end
1420
-
1421
- # How long a turn waits for the Bridge of a worker a resume just spawned.
1422
- TURN_BRIDGE_WAIT = 5.0
1423
-
1424
- # What #deliver_turn did with a message.
1425
- # +status+ :accepted; :refused (the Bridge refused it, or a queue would
1426
- # reach a worker that doesn't queue); :timeout (the Bridge took it and
1427
- # never answered); :failed (the input file couldn't be written).
1428
- # +via+ :bridge (the worker's Bridge answered) or :file (the input-file
1429
- # fallback); nil when nothing went in.
1430
- # +ack+ the Bridge's reply body, or the file's {"status", "enqueued_id",
1431
- # "session_id"}: string keys either way, {} for none.
1432
- # +code+ a refusal's HTTP status.
1433
- DeliverResult = Data.define(:status, :via, :ack, :code) do
1434
- def initialize(status:, via: nil, ack: {}, code: nil) = super(status:, via:, ack: ack || {}, code:)
1435
-
1436
- def accepted? = status == :accepted
1437
-
1438
- # Sent as an input file (no Bridge): its turn and outcome carry the
1439
- # file's enqueued_id, which a wait follows (chi send --wait).
1440
- def file? = via == :file
1441
-
1442
- # @return [String, nil] the id the message's turn will carry
1443
- def enqueued_id
1444
- id = ack["enqueued_id"].to_s
1445
- id.empty? ? nil : id
1446
- end
1447
-
1448
- # Whether the worker took the line as a session command (a POST /turn
1449
- # answered with its command_id).
1450
- def command? = !ack["command_id"].to_s.empty?
1451
-
1452
- def error = ack["error"]
1453
-
1454
- def detail = ack["detail"]
1455
-
1456
- # What the worker applied (a Bridge ack only): the delivery asked for,
1457
- # or next_step for a --queue that found no turn running; nil from the
1458
- # input file or a worker older than delivery.
1459
- def delivery = ack["delivery"]
1460
-
1461
- # A --cut's outcome ("now", "waits", "off"), nil when nothing was cut.
1462
- def cut = ack["cut"]
1463
-
1464
- # The steer cut-after seconds, with a cut that waits only.
1465
- def cut_after = ack["cut_after"]
1466
- end
1467
-
1468
- # Hand a user message to the session the way a UI does, for the web
1469
- # composer and `chi send` alike: wakes the worker when none runs, posts
1470
- # through its Bridge so every live UI sees :turn_enqueued, and falls back
1471
- # to the input file when the Bridge is gone (a worker closing it on idle
1472
- # exit). Race-safe against a TUI taking the session, or the worker
1473
- # exiting, between the resume and the write.
1474
- # @param images [Array<Hash>] refs ({file:, name:}) already in images/
1475
- # @param delivery [String, nil] what the message asks the running turn
1476
- # for: "next_step" (default), "cut" or "queue"
1477
- # @param no_interrupt [Boolean] the turn runs with the raised iteration
1478
- # limit (an attached TUI's --no-interrupt), through the Bridge or the
1479
- # file; sent only when true, so older callees see no new field
1480
- # @param author [String, nil] who wrote it when it isn't the session's
1481
- # owner (Web::Principal#author: chi web stamps it, never the body);
1482
- # to the Bridge or the file only when given, so an owner's turn is
1483
- # sent and saved as before
1484
- # @param pending_limit [Integer, nil] with +author+: how many of their
1485
- # turns may be running or waiting (the Bridge counts them; with no
1486
- # worker, their waiting files): :refused 429 too_many_turns past it
1487
- # @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
1488
- # turn's draft: each refused (409, its UNAVAILABLE) by a worker without
1489
- # its feature
1490
- # @param manager [#resume_session, #write_turn_input] this class, or a
1491
- # stand-in (the web's specs); #session_owner is optional
1492
- # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
1493
- # the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
1494
- # @return [DeliverResult] :accepted (via the Bridge or a file),
1495
- # :refused when the Bridge refused the request (any 4xx with an
1496
- # error: bad images, a delivery it doesn't run) or a queue would reach
1497
- # a worker that doesn't queue (no file then: it would steer), :timeout
1498
- # when it took the request but never answered (no file then), or
1499
- # :failed when the input file couldn't be written
1500
- # @raise [OwnedByTUI] a one-turn chi run owns the session
1501
- # @raise [ArgumentError] no such session
1502
- def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1503
- author: nil, pending_limit: nil, anchors: nil, state_dir: nil, manager: self, bridge: nil)
1504
- anchors = TurnAnchors.from(anchors)
1505
- session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1506
- bridge ||= lambda do
1507
- link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
1508
- link.live? ? link.client(session_id) : nil
1509
- end
1510
- manager.resume_session(session_id, state_dir: state_dir)
1511
- if (client = bridge.call)
1512
- if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
1513
- return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
1514
- end
1515
- # A worker from before queueing (no "delivery" feature) ignores the
1516
- # field or refuses it; either way a file would steer the running turn.
1517
- return queue_unavailable if Delivery.queue?(delivery) && !worker_has?(client, Delivery::QUEUE_FEATURE)
1518
- # A participant's turn only to a worker with the review boundary.
1519
- return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
1520
-
1521
- begin
1522
- options = { prompt: prompt, client_id: client_id }
1523
- options[:images] = images unless images.empty?
1524
- # An old worker ignores the field: it cuts anyway (its own default).
1525
- options[:delivery] = delivery unless Delivery.next_step?(delivery)
1526
- options[:no_interrupt] = true if no_interrupt
1527
- options[:author] = author if author
1528
- options[:pending_limit] = pending_limit if author && pending_limit
1529
- options[:anchors] = anchors unless anchors.empty?
1530
- reply = client.post_turn(**options)
1531
- ack = reply.json
1532
- return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
1533
- # Read after its deadline and dropped: a file would run it after all.
1534
- return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
1535
- # Any other 4xx with an error is a refusal (bad images, a delivery
1536
- # it doesn't run, a client bug): a file would bypass it, and a
1537
- # refused queue would steer. A 413 (a body over the Bridge's
1538
- # limit, a big piped chi send) and a 5xx keep the file fallback
1539
- # below, which carries the delivery (a queue too).
1540
- if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
1541
- return DeliverResult.new(status: :refused, via: :bridge, code: reply.status, ack: ack)
1542
- end
1543
- rescue Errno::ETIMEDOUT
1544
- # The Bridge drops a turn it reads after the request's deadline
1545
- # (BridgeClient#post_turn), so a worker that wakes later won't run
1546
- # it; a file would.
1547
- return turn_timeout
1548
- rescue SystemCallError, IOError
1549
- nil # the worker closed its Bridge on the way out: queue the file
1550
- end
1551
- end
1552
- # A participant's file only for a worker started after it: a worker
1553
- # still holding the session with its Bridge gone (exiting) may be an
1554
- # older one, which would run it without the review boundary.
1555
- # (A one-turn chi run holding it is refused as for anyone, below.)
1556
- if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
1557
- return DeliverResult.new(status: :timeout, via: :file,
1558
- ack: { "error" => "worker_timeout",
1559
- "detail" => "the session's worker is exiting, so the message was not sent; send it again" })
1560
- end
1561
- enqueued_id = SecureRandom.uuid
1562
- input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
1563
- input[:images] = images unless images.empty?
1564
- input[:delivery] = delivery unless Delivery.next_step?(delivery)
1565
- input[:no_interrupt] = true if no_interrupt
1566
- input[:author] = author if author
1567
- input[:anchors] = anchors unless anchors.empty?
1568
- path = participant_queue_lock(session_dir, author && pending_limit) do
1569
- # No worker: the participant's waiting files are all their turns,
1570
- # counted and written under one lock (the Bridge counts under its).
1571
- next :too_many if author && pending_limit && pending_files(session_dir, author) >= pending_limit
1572
-
1573
- manager.write_turn_input(session_id, **input)
1574
- end
1575
- if path == :too_many
1576
- return DeliverResult.new(status: :refused, code: 429,
1577
- ack: { "error" => "too_many_turns", "detail" => ParticipantMessages::TOO_MANY_TURNS })
1578
- end
1579
- return DeliverResult.new(status: :failed) unless path
1580
-
1581
- owner = delivery_owner(manager, session_id, state_dir)
1582
- # A TUI that took the session between the resume and the write never
1583
- # reads input files; a later worker would replay this one.
1584
- if owner&.tui?
1585
- FileUtils.rm_f(path) if path.is_a?(String)
1586
- raise OwnedByTUI, session_id
1587
- end
1588
- # A worker that idle-exited since the resume never reads it either:
1589
- # wake a new one. (The exiting worker also looks for input it left;
1590
- # one the first resume spawned that is still starting is left be.)
1591
- manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
1592
- DeliverResult.new(status: :accepted, via: :file,
1593
- ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
1594
- end
1595
-
1596
- # Whether the worker +client+ talks to names +feature+: the features of
1597
- # the sidecar the client was made from (the Live link WorkerLink.await
1598
- # found), not a second read of bridge.json.
1599
- private_class_method def self.worker_has?(client, feature) = client.features.include?(feature)
1600
-
1601
- # The unclaimed input files +author+ wrote.
1602
- PARTICIPANT_QUEUE_LOCK = "participants.lock"
1603
-
1604
- # The block under the session's participant queue lock (a file lock:
1605
- # chi web's threads, and another chi web), when +locked+; else as is.
1606
- private_class_method def self.participant_queue_lock(session_dir, locked)
1607
- return yield unless locked
1608
-
1609
- FileUtils.mkdir_p(session_dir)
1610
- File.open(File.join(session_dir, PARTICIPANT_QUEUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
1611
- lock.flock(File::LOCK_EX)
1612
- yield
1613
- end
1614
- end
1615
-
1616
- private_class_method def self.pending_files(session_dir, author)
1617
- SessionInbox.find_new_input_files(session_dir).count do |file|
1618
- data = JSON.parse(File.read(file))
1619
- data.is_a?(Hash) && data["author"] == author
1620
- rescue JSON::ParserError, SystemCallError
1621
- false
1622
- end
1623
- end
1624
-
1625
- private_class_method def self.queue_unavailable
1626
- DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
1627
- end
1628
-
1629
- private_class_method def self.review_unavailable
1630
- DeliverResult.new(status: :refused, code: 409, ack: Delivery::REVIEW_UNAVAILABLE)
1631
- end
1632
-
1633
- private_class_method def self.turn_timeout
1634
- DeliverResult.new(status: :timeout, via: :bridge,
1635
- ack: { "error" => "worker_timeout",
1636
- "detail" => "the session's worker did not answer, so the message was not sent" })
1637
- end
1638
-
1639
- private_class_method def self.delivery_owner(manager, session_id, state_dir)
1640
- Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
1641
- end
1642
-
1643
- def self.stopped_on_disk?(session_id, state_dir:)
1644
- Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
1645
- rescue ArgumentError
1646
- false
1647
- end
1648
-
1649
128
  # The session's live owner: the OwnerLock holder. The pid file workers
1650
129
  # once wrote is never read: a stale one whose pid the OS reused would
1651
130
  # make a session look owned.
@@ -1666,3 +145,11 @@ module Samagotchi
1666
145
  end
1667
146
  end
1668
147
  end
148
+
149
+ require_relative "session_manager/spawn"
150
+ require_relative "session_manager/worker_command"
151
+ require_relative "session_manager/listing"
152
+ require_relative "session_manager/delivery"
153
+ require_relative "session_manager/lifecycle"
154
+ require_relative "session_manager/removal"
155
+ require_relative "session_manager/emptiness"