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
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionManager
5
+ # Listing: read-only queries over the saved sessions (the lists, their
6
+ # summary rows, a parent's delegates, a session's replies). Calls the
7
+ # facade's session_owner. Meant to be loaded through samagotchi/session_manager.
8
+ module ListingPart
9
+ # List all sessions, reading status from persisted session.json files.
10
+ # +project_root+: only that project's sessions (nil: every session).
11
+ # +include_archived+: archived sessions too (Session#archived says which).
12
+ def list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
13
+ include_archived: false)
14
+ Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
15
+ offset: offset, project_root: project_root, include_archived: include_archived)
16
+ end
17
+
18
+ # Short summaries of sessions, newest first: the picker behind
19
+ # `chi sessions list --live --format json|tsv` (chi note from a script)
20
+ # and the agent's list_sessions tool; both pass the current project as
21
+ # +project_root+ unless asked for every project.
22
+ # @param live [Boolean] only sessions a worker owns now (WorkerLink's
23
+ # worker_owned?: the owner lock, not the saved status, which a dead
24
+ # worker leaves at "running"); a session a one-shot (or an older
25
+ # chi's REPL) owns is left out: it can't take notes
26
+ # @param cwd [String, nil] only sessions in this folder or below it
27
+ # @param project_root [String, nil] only this project's sessions
28
+ # (Session#project_root)
29
+ # @param limit [Integer, nil] taken after the filters
30
+ # @param include_tests [Boolean] false leaves out test runs
31
+ # @param exclude [String, nil] a session id to leave out (the asker)
32
+ # @param include_archived [Boolean] archived sessions too
33
+ # @return [Array<SessionRow>] see SessionRow for the fields
34
+ def session_rows(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
35
+ project_root: nil, include_archived: false, sort: nil, order: nil)
36
+ sd = state_dir || Session.default_state_dir
37
+ root = cwd && folder_path(cwd)
38
+ roots = {}
39
+ # One registry per listing, built only when a row has a saved context
40
+ # to count: its session's llm_context budget is what ctx_pct counts
41
+ # against (SessionMetrics.saved_context_pct).
42
+ budgets = SessionRow.budgets
43
+ rows = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
44
+ include_archived: include_archived).lazy
45
+ .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
46
+ .select { |s| root.nil? || in_folder?(s.working_directory, root) }
47
+ .filter_map do |s|
48
+ dir = Session.session_dir(s.id, state_dir: sd)
49
+ owner = session_owner(s.id, state_dir: sd)
50
+ link = WorkerLink.resolve(dir, unlink: false, owner: owner)
51
+ next if live && !link.worker_owned?
52
+
53
+ SessionRow.build(s, session_dir: dir, owner: owner, link: link, roots: roots, budgets: budgets)
54
+ end
55
+ (limit ? rows.first(limit) : rows.to_a)
56
+ end
57
+
58
+ # .session_rows as the summaries route's hashes (SessionRow#to_summary_h,
59
+ # its 28 keys in order): chi web's GET /api/sessions/summaries.
60
+ # @return [Array<Hash>]
61
+ def session_summaries(**filters) = session_rows(**filters).map(&:to_summary_h)
62
+
63
+ # The sessions delegated by +parent_id+ (the `delegate` tool), newest
64
+ # first, as .session_rows. A running one (busy) counts against
65
+ # session.max_children. Archived ones too.
66
+ # @return [Array<SessionRow>]
67
+ def children_of(parent_id, state_dir: nil)
68
+ return [] if parent_id.to_s.empty?
69
+
70
+ session_rows(state_dir: state_dir, include_tests: true, include_archived: true)
71
+ .select { |s| s.parent_id == parent_id.to_s }
72
+ end
73
+
74
+ # Children, their children, …: a delegated session doesn't delegate
75
+ # further today, a plugin's fork may. The walk doesn't enter +except+.
76
+ def descendant_ids(id, state_dir, except: [])
77
+ seen = [id]
78
+ queue = [id]
79
+ until queue.empty?
80
+ children_of(queue.shift, state_dir: state_dir).each do |child|
81
+ next if seen.include?(child.id) || except.include?(child.id)
82
+
83
+ seen << child.id
84
+ queue << child.id
85
+ end
86
+ end
87
+ seen.drop(1)
88
+ end
89
+
90
+ private def folder_path(path)
91
+ File.realpath(path)
92
+ rescue SystemCallError
93
+ File.expand_path(path)
94
+ end
95
+
96
+ private def in_folder?(dir, root)
97
+ dir = dir.to_s.chomp("/")
98
+ root = root.chomp("/")
99
+ dir == root || dir.start_with?("#{root}/")
100
+ end
101
+
102
+ # Read any new output files from a session directory.
103
+ # Optionally filters to only files newer than `since_time`.
104
+ def read_responses(session_id, since_time: nil, state_dir: nil)
105
+ SessionInbox.read_outputs(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir),
106
+ since_time: since_time)
107
+ end
108
+
109
+ def stopped_on_disk?(session_id, state_dir:)
110
+ Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
111
+ rescue ArgumentError
112
+ false
113
+ end
114
+ end
115
+
116
+ extend ListingPart
117
+ end
118
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionManager
5
+ # A delete that would pull the session from under its live worker.
6
+ # reason: :worker_running (not asked to stop it) or :still_stopping
7
+ # (stopped, but the worker outlived the wait).
8
+ class DeleteRefused < StandardError
9
+ attr_reader :session_id, :reason
10
+
11
+ def initialize(session_id, reason)
12
+ @session_id = session_id
13
+ @reason = reason
14
+ super(reason == :still_stopping ? "session #{session_id}'s worker is still shutting down" : "session #{session_id}'s worker is running")
15
+ end
16
+ end
17
+
18
+ # An archive that can't happen now: a turn runs in the session or in one
19
+ # of its children (+busy_id+ names which), or it is a scratch session.
20
+ class ArchiveRefused < StandardError
21
+ attr_reader :session_id, :reason, :busy_id
22
+
23
+ def initialize(session_id, reason, busy_id: nil)
24
+ @session_id = session_id
25
+ @reason = reason
26
+ @busy_id = busy_id
27
+ super(archive_refused_message)
28
+ end
29
+
30
+ private
31
+
32
+ def archive_refused_message
33
+ case @reason
34
+ when :scratch then "a scratch session is deleted when you leave; nothing to archive"
35
+ when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
36
+ when :queued then "a prompt is queued, which a resume would run; let it run first"
37
+ when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
38
+ when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
39
+ when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
40
+ else "a turn is running; wait for it or cancel it first"
41
+ end
42
+ end
43
+ end
44
+
45
+ # What one archive did: id: the session it was asked for; archived: the
46
+ # ids (it and its delegates) now hidden for good; stopped: the live
47
+ # workers stopped first; discarded: empty sessions its stopping worker deleted.
48
+ ArchiveResult = Data.define(:id, :archived, :stopped, :discarded)
49
+
50
+ # Removal: archive, unarchive and delete a session (an archive takes its
51
+ # delegates too), and the retention sweep's entry. Calls LifecyclePart's
52
+ # stop_session, ListingPart's descendant_ids and the facade's ownership
53
+ # checks. Meant to be loaded through samagotchi/session_manager.
54
+ module RemovalPart
55
+ # Archive a session and its delegated children (ArchiveStore): hidden
56
+ # from every list, kept by the retention sweep. A live idle worker is
57
+ # stopped first; the marker is written even while it shuts down.
58
+ # @return [ArchiveResult]
59
+ # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
60
+ # @raise [OwnedByTUI] a one-turn chi run owns it or one of its children
61
+ # @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
62
+ # queued in one (a later resume would run it), a live one offers to
63
+ # continue past the step limit, or it is a scratch session
64
+ # @param except [Array<String>] descendants left alone with their own
65
+ # subtrees (delegates a continue moves to the chain's new link)
66
+ def archive_session(id_or_prefix, state_dir: nil, wait: 5, except: [])
67
+ sd = state_dir || Session.default_state_dir
68
+ id = archive_target(id_or_prefix, sd)
69
+ tree, owners = archivable!(id, sd, except: except)
70
+ stopped = tree.select { |sid| owners[sid] }
71
+ stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
72
+ archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
73
+ # A restart's reminders hand-off no successor took (the stop above
74
+ # removes a live one's): archived sessions keep no reminders.
75
+ archived.each { |sid| ReminderCarry.remove(Session.session_dir(sid, state_dir: sd)) }
76
+ # D8: an archive ends the review (unarchiving doesn't restore it).
77
+ Participants.end_sessions(archived, by: "archive", state_dir: sd)
78
+ ArchiveResult.new(id: id, archived: archived, stopped: stopped, discarded: discarded)
79
+ end
80
+
81
+ # The session and its descendants, with their owners, when they can be
82
+ # archived now (#archive_refusal for each), else the refusal. Public for
83
+ # SessionContinue, which checks before its recap wait.
84
+ # @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
85
+ # @raise [ArchiveRefused, OwnedByTUI]
86
+ def archivable!(id, state_dir, except: [])
87
+ raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
88
+
89
+ tree = [id, *descendant_ids(id, state_dir, except: except)]
90
+ owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
91
+ tree.each do |sid|
92
+ refusal = archive_refusal(sid, owners[sid], state_dir)
93
+ raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
94
+ end
95
+ [tree, owners]
96
+ end
97
+
98
+ # What keeps +sid+ (in an archive's tree) from being archived: :busy (a
99
+ # turn runs), :queued (input/ holds a prompt, which outlives the stop and
100
+ # runs on a resume, live worker or not), :offer (a live worker's continue
101
+ # offer, which the stop would drop), or nil.
102
+ # @raise [OwnedByTUI] a one-turn chi run owns it
103
+ private def archive_refusal(sid, owner, state_dir)
104
+ raise OwnedByTUI, sid if owner&.tui?
105
+
106
+ session = owner && Session.load(sid, state_dir: state_dir)
107
+ return :busy if session&.status == Session::STATUS_RUNNING
108
+ return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
109
+
110
+ :offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
111
+ end
112
+
113
+ # Unarchive a session and its delegated children.
114
+ # @return [Hash] {id:, unarchived: [ids that were archived]}
115
+ # @raise [ArgumentError] unknown id
116
+ def unarchive_session(id_or_prefix, state_dir: nil)
117
+ sd = state_dir || Session.default_state_dir
118
+ id = archive_target(id_or_prefix, sd)
119
+ unarchived = [id, *descendant_ids(id, sd)].select { |sid| ArchiveStore.unarchive(sid, state_dir: sd) }
120
+ { id: id, unarchived: unarchived }
121
+ end
122
+
123
+ private def archive_target(id_or_prefix, state_dir) = Session.resolve!(id_or_prefix.to_s, state_dir: state_dir)
124
+
125
+ # The retention sweep (SessionRetention.sweep_if_due), here for the web
126
+ # app and hub, which take this class as their manager.
127
+ def retention_sweep_if_due(state_dir: nil) = SessionRetention.sweep_if_due(state_dir: state_dir)
128
+
129
+ # Delete one session: its <id>.json, the whole <id>/ directory
130
+ # (history sidecars, input, output, notes, images) and its attached
131
+ # context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
132
+ # /exit --delete and the web all come here.
133
+ # @param id_or_prefix [String] a session id or a unique prefix of one
134
+ # @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
135
+ # @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
136
+ # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
137
+ # @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
138
+ # @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
139
+ # outlived the wait
140
+ # @param purge_runs [Boolean] also delete System 1's saved runs of it and
141
+ # its candidate-log lines; a session a worker left empty has none worth
142
+ # the read
143
+ def delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true)
144
+ sd = state_dir || Session.default_state_dir
145
+ given = id_or_prefix.to_s
146
+ # Only a plain id: anything else could name a path outside the state dir.
147
+ raise Session::NotFound, given unless Session.valid_id?(given)
148
+
149
+ id = Session.resolve_id(given, state_dir: sd)
150
+ path = Session.session_file(id, state_dir: sd)
151
+ dir = Session.session_dir(id, state_dir: sd)
152
+ # A folder with no file left is still deleted (not resolve!).
153
+ raise Session::NotFound, given unless File.exist?(path) || Dir.exist?(dir)
154
+
155
+ owner = refuse_tui!(id, state_dir: sd)
156
+ if owner
157
+ raise DeleteRefused.new(id, :worker_running) unless stop
158
+ raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
159
+ end
160
+
161
+ removed = []
162
+ if File.exist?(path)
163
+ FileUtils.rm_f(path)
164
+ removed << path
165
+ end
166
+ if Dir.exist?(dir)
167
+ FileUtils.rm_rf(dir)
168
+ removed << dir
169
+ end
170
+ PluginSessionState.remove(id)
171
+ removed.concat(ContextSources.remove_session(id, state_dir: sd))
172
+ Participants.end_sessions([id], by: "delete", state_dir: sd)
173
+ if purge_runs
174
+ purge_session_runs(id, sd)
175
+ ContextCandidatesLog.purge_session(id, state_dir: sd)
176
+ end
177
+ { id: id, removed: removed, stopped: !owner.nil? }
178
+ end
179
+
180
+ # Never a reason a delete fails: one warn, and the sweep ages them out.
181
+ private def purge_session_runs(id, state_dir)
182
+ SystemOne::Runs.purge_session(id, sessions_dir: state_dir)
183
+ rescue StandardError => e
184
+ Log.warn(:worker, "session_runs_purge_failed", sid: id, error: e.class.name, msg: e.message.to_s[0, 200])
185
+ end
186
+ end
187
+
188
+ extend RemovalPart
189
+ end
190
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../worker_link"
4
+
5
+ module Samagotchi
6
+ class SessionManager
7
+ # How long after a spawn a resume trusts that worker to take the
8
+ # session, while it runs (and the session wasn't stopped since).
9
+ WORKER_START_GRACE = WorkerLink::START_LIMIT
10
+
11
+ @spawns = {}
12
+ @spawns_mutex = Mutex.new
13
+
14
+ # Spawn: deciding to start a session's worker (a new session, a resume, a
15
+ # delegate's report waking its parent) and the spawns this process
16
+ # remembers. Calls WorkerCommandPart's spawn_options and worker_command and
17
+ # the facade's ownership checks. Meant to be loaded through samagotchi/session_manager.
18
+ module SpawnPart
19
+ # Spawn a new background session that processes the given prompt (or,
20
+ # with none, waits idle for input).
21
+ #
22
+ # Returns the session object with its ID. Every worker always starts its
23
+ # per-session Bridge (the single live client transport), so external
24
+ # clients can reach it once the sidecar is published.
25
+ # @param memories [Array<String>] --memory: preloaded into the worker's prompt
26
+ # @param muted_memories [Array<String>] --mute: hidden from the session
27
+ # (both are session fields, so a respawn keeps them)
28
+ # @param parent_id [String, nil] the session that delegates this one (the
29
+ # `delegate` tool) or was forked from; a session field too
30
+ # @param messages [Array<Hash>] a conversation to start from (a plugin's
31
+ # ctx.sessions.fork); its image refs are copied from +images_from+
32
+ # @param images_from [String, nil] the session dir the seed's images are in
33
+ # @param preview [String, nil] what the lists show before the first turn
34
+ # (the prompt's preview by default, else the seed's first user message)
35
+ # @param title [String, nil] the session's own name (Session#title; a
36
+ # continue's link inherits its previous link's), saved before the
37
+ # worker starts: the worker owns the file from then on
38
+ # @param setup [SessionSetup] the session's own settings (chi
39
+ # --llm-context; a fork and a continue copy their session's; a delegate
40
+ # child the toolkit its delegate call named); without a toolkit the
41
+ # session is stored on config's toolkit.default, a delegate child on
42
+ # toolkit.child (Toolkit.configured)
43
+ # @param continues [String, nil] the session this one continues (a
44
+ # session field; SessionContinue)
45
+ # @param note [String, nil] a context note the session starts with,
46
+ # from +note_source+: queued before its worker spawns, so its first
47
+ # turn sees it
48
+ # @param review [SessionReview, nil] a review session's (ReviewStart; a
49
+ # continue keeps its previous link's)
50
+ # @param verify_fresh [VerifyFreshOrigin, nil] a Verify fresh child's
51
+ # requester, rules and claim (VerifyFresh.start)
52
+ # @param before_spawn [#call, nil] (session) runs once the session and
53
+ # its note are saved, before its worker spawns
54
+ # @param scratch [Boolean] a `chi scratch` session (its terminal's
55
+ # AttachLauncher): its worker runs a scratch Engine and deletes it
56
+ # once its last client is gone
57
+ # @return [Session] with #seed_images_dropped: refs whose file was gone,
58
+ # and #model_warning: an id its host's saved list doesn't have
59
+ def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
60
+ memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
61
+ preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
62
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
63
+ sd = state_dir || Session.default_state_dir
64
+ # The resolved ref is stored (a resumed session keeps its model when an
65
+ # alias is retargeted), with the name as typed beside it. A wrong host
66
+ # refuses here, before anything is saved or spawned; a model id the
67
+ # host's saved list doesn't have (ModelListStore) only warns
68
+ # (#model_warning on the session), as some hosts serve ids they don't
69
+ # list.
70
+ typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
71
+ model_warning = Samagotchi::ModelProfile.model_warning(typed)
72
+ ref = HostConfig.model_ref(typed).ref
73
+ session = Session.new_session(
74
+ mode: mode,
75
+ model_name: ref,
76
+ model_typed: typed == ref ? nil : typed,
77
+ working_directory: working_directory || Dir.pwd,
78
+ preloaded_memory_names: memories,
79
+ muted_memory_names: muted_memories,
80
+ parent_id: parent_id,
81
+ messages: messages,
82
+ delegate: delegate,
83
+ setup: setup,
84
+ continues: continues,
85
+ review: review,
86
+ scratch: scratch
87
+ )
88
+ session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
89
+ session.toolkit_origin = setup.toolkit_origin || "default"
90
+ # Saved before the worker starts: its first turn is already the requester's.
91
+ session.verify_fresh = verify_fresh
92
+ session.model_warning = model_warning
93
+ # With no prompt there is no first turn to run (an attaching UI sends
94
+ # the prompts), so the session starts idle.
95
+ session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
96
+ session.last_prompt = prompt
97
+ # The first prompt's turn gets an id like a sent message's, so a wait
98
+ # for it can name it (chi send --new --wait, chi web's create): the
99
+ # worker runs it with that origin, and its reply file and last_turn
100
+ # carry the id.
101
+ session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
102
+ # The worker takes last_prompt and clears it, and messages are saved at
103
+ # the turn's end: until then this is the only preview a list has.
104
+ session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
105
+ session.title = title
106
+ session_dir = Session.session_dir(session.id, state_dir: sd)
107
+ unless session.messages.empty?
108
+ session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
109
+ session.seed_images_dropped = dropped
110
+ end
111
+ setup_session_directory(session_dir, session, state_dir: sd)
112
+ SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
113
+ before_spawn&.call(session)
114
+ spawn_worker_for_session(session, state_dir: sd)
115
+ session
116
+ end
117
+
118
+ # Ensure an existing session has a live worker process.
119
+ # Returns the loaded session after state reconciliation.
120
+ # @raise [OwnedByTUI] when the interactive TUI owns the session
121
+ def resume_session(session_id, state_dir: nil)
122
+ sd = state_dir || Session.default_state_dir
123
+ session = Session.load(session_id, state_dir: sd)
124
+ # Its terminal's worker came from spawn_session; nothing wakes another.
125
+ raise ScratchSession, session.id if session.scratch
126
+ return session if refuse_tui!(session.id, state_dir: sd)
127
+
128
+ # status is turn state, not liveness: a new worker runs no turn yet,
129
+ # and must not find the session stopped (it would exit): neither the
130
+ # marker nor a status field from before it.
131
+ # One this process just spawned may not hold the lock yet (a slow
132
+ # start, deliver_turn's second resume): it would only lose it.
133
+ return session if still_starting?(session.id, state_dir: sd)
134
+ # A continue moving delegates to it spawns its worker once they are
135
+ # moved; one spawned now (a moved child's ring) would race the move.
136
+ return session if StartingMarker.fresh?(session.id, state_dir: sd)
137
+
138
+ Session.clear_stopped(session.id, state_dir: sd)
139
+ session.status = Session::STATUS_IDLE
140
+ session.save(state_dir: sd)
141
+ spawn_worker_for_session(session, state_dir: sd)
142
+ session
143
+ end
144
+
145
+ # A delegate child rang a parent that has no worker (ChildRing): start
146
+ # one, which runs a turn for the report. Not for a parent that is gone,
147
+ # stopped, archived, a scratch session, a one-turn run's, or a
148
+ # continue's new link still taking its delegates (its own spawn comes):
149
+ # the ring waits on disk for whoever runs it next (#resume_session
150
+ # itself would clear the stop). A stop landing between the check and
151
+ # the resume is a small window, left as is. An archived parent with a
152
+ # later link in its chain wakes that link instead: a ring a moved child
153
+ # wrote just as the continue moved it reaches the new link that way
154
+ # (ChildReports reads its predecessor's rings).
155
+ # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
156
+ # :scratch, :owned, :starting)
157
+ def wake_for_report(session_id, state_dir: nil, forward: true)
158
+ sd = state_dir || Session.default_state_dir
159
+ return :gone unless Session.exist?(session_id, state_dir: sd)
160
+ return :owned if session_owner(session_id, state_dir: sd)
161
+ return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
162
+
163
+ if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
164
+ following = forward && SessionChain.next_of(session_id, state_dir: sd)
165
+ return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
166
+ end
167
+ return :starting if StartingMarker.fresh?(session_id, state_dir: sd)
168
+
169
+ session = Session.load(session_id, state_dir: sd)
170
+ return :stopped if session.status == Session::STATUS_STOPPED
171
+ return :scratch if session.scratch
172
+
173
+ resume_session(session_id, state_dir: sd)
174
+ Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
175
+ :woken
176
+ rescue OwnedByTUI
177
+ :owned
178
+ end
179
+
180
+ # A worker this process spawned for the session within
181
+ # WORKER_START_GRACE is still running, and no stop came since.
182
+ private def still_starting?(session_id, state_dir:)
183
+ return false if Session.stopped_marker?(session_id, state_dir: state_dir)
184
+
185
+ at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
186
+ return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
187
+
188
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
189
+ end
190
+
191
+ private def note_spawn(session_id, state_dir:, waiter:)
192
+ @spawns_mutex.synchronize do
193
+ @spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
194
+ @spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
195
+ end
196
+ end
197
+
198
+ # Forget the spawns #still_starting? remembers (specs).
199
+ def forget_spawns
200
+ @spawns_mutex.synchronize { @spawns.clear }
201
+ end
202
+
203
+ # Starter processes are children of chi web, unlike the detached workers
204
+ # they launch; the web restart adopts these pids before replacing itself.
205
+ def live_spawn_pids
206
+ @spawns_mutex.synchronize do
207
+ @spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
208
+ @spawns.values.filter_map do |_, waiter|
209
+ waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
210
+ end.uniq.sort
211
+ end
212
+ end
213
+
214
+ private def setup_session_directory(session_dir, session, state_dir:)
215
+ FileUtils.mkdir_p(session_dir)
216
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
217
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
218
+ session.save(state_dir: state_dir)
219
+ end
220
+
221
+ private def spawn_worker_for_session(session, state_dir:)
222
+ session_dir = Session.session_dir(session.id, state_dir: state_dir)
223
+ FileUtils.mkdir_p(session_dir)
224
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
225
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
226
+
227
+ opts = spawn_options(session)
228
+ env = opts.delete(:env)
229
+ command = worker_command(session.id, state_dir: state_dir)
230
+ pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
231
+ # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
232
+ # +pid+ is the worker's starter, which exits once the worker it forked
233
+ # holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is the worker's.
234
+ note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
235
+ Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
236
+ pid
237
+ end
238
+ end
239
+
240
+ extend SpawnPart
241
+ end
242
+ end