samagotchi 0.155.0 → 0.156.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +10 -6
  5. data/docs/internals/anthropic.md +1 -1
  6. data/docs/internals/attached-context.md +3 -2
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/engine.md +625 -935
  9. data/docs/internals/llm-context-forget.md +8 -3
  10. data/docs/internals/objectives.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -1
  12. data/docs/internals/remote-sessions.md +3 -1
  13. data/docs/internals/working-set.md +1 -1
  14. data/docs/sessions.md +2 -2
  15. data/docs/system-one.md +3 -2
  16. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  17. data/lib/samagotchi/broadcast_command.rb +3 -3
  18. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +7 -51
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  24. data/lib/samagotchi/config.rb +17 -112
  25. data/lib/samagotchi/context_command.rb +1 -2
  26. data/lib/samagotchi/context_window.rb +3 -3
  27. data/lib/samagotchi/desktop/macos.rb +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -11
  29. data/lib/samagotchi/engine/messages.rb +1 -1
  30. data/lib/samagotchi/engine/models.rb +5 -2
  31. data/lib/samagotchi/engine/objectives.rb +29 -11
  32. data/lib/samagotchi/engine/side_requests.rb +21 -11
  33. data/lib/samagotchi/engine/toolkit.rb +10 -9
  34. data/lib/samagotchi/engine/turn_end.rb +1 -1
  35. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  36. data/lib/samagotchi/engine/verify.rb +1 -1
  37. data/lib/samagotchi/engine.rb +14 -9
  38. data/lib/samagotchi/guardrails/targets.rb +24 -9
  39. data/lib/samagotchi/host_config.rb +72 -1
  40. data/lib/samagotchi/host_model.rb +2 -6
  41. data/lib/samagotchi/host_registry.rb +1 -1
  42. data/lib/samagotchi/kernel_loop.rb +46 -121
  43. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +34 -18
  45. data/lib/samagotchi/llm/native_backend.rb +1 -1
  46. data/lib/samagotchi/llm_context_apply.rb +54 -1
  47. data/lib/samagotchi/llm_context_notice.rb +1 -1
  48. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  49. data/lib/samagotchi/llm_context_view.rb +9 -0
  50. data/lib/samagotchi/model_price.rb +4 -10
  51. data/lib/samagotchi/model_profile.rb +6 -6
  52. data/lib/samagotchi/model_settings.rb +37 -2
  53. data/lib/samagotchi/note_command.rb +1 -2
  54. data/lib/samagotchi/objectives_audit.rb +4 -3
  55. data/lib/samagotchi/participants.rb +2 -2
  56. data/lib/samagotchi/plugin/context.rb +1 -1
  57. data/lib/samagotchi/plugin/sessions.rb +2 -5
  58. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  59. data/lib/samagotchi/project_env.rb +1 -1
  60. data/lib/samagotchi/reasoning_history.rb +3 -3
  61. data/lib/samagotchi/reminder_carry.rb +1 -1
  62. data/lib/samagotchi/reprefill_meter.rb +45 -0
  63. data/lib/samagotchi/review_command.rb +1 -1
  64. data/lib/samagotchi/sampling_settings.rb +3 -3
  65. data/lib/samagotchi/self_report.rb +7 -7
  66. data/lib/samagotchi/session.rb +50 -7
  67. data/lib/samagotchi/session_access/local.rb +7 -5
  68. data/lib/samagotchi/session_access/remote.rb +17 -3
  69. data/lib/samagotchi/session_archive_command.rb +2 -2
  70. data/lib/samagotchi/session_chain.rb +7 -6
  71. data/lib/samagotchi/session_continue.rb +6 -5
  72. data/lib/samagotchi/session_delete_command.rb +1 -1
  73. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  74. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  75. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  76. data/lib/samagotchi/session_manager/listing.rb +189 -0
  77. data/lib/samagotchi/session_manager/removal.rb +190 -0
  78. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  79. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  80. data/lib/samagotchi/session_manager.rb +26 -1540
  81. data/lib/samagotchi/session_metrics.rb +1 -1
  82. data/lib/samagotchi/sessions_command.rb +1 -1
  83. data/lib/samagotchi/share_command.rb +1 -1
  84. data/lib/samagotchi/starting_marker.rb +65 -0
  85. data/lib/samagotchi/steer_cut_after.rb +3 -3
  86. data/lib/samagotchi/system_one/recorder.rb +49 -0
  87. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  88. data/lib/samagotchi/terminal_ui.rb +2 -6
  89. data/lib/samagotchi/testing/sessions.rb +1 -1
  90. data/lib/samagotchi/thinking.rb +3 -3
  91. data/lib/samagotchi/token_usage.rb +1 -1
  92. data/lib/samagotchi/tool_activity.rb +10 -4
  93. data/lib/samagotchi/tool_declarations.rb +2 -2
  94. data/lib/samagotchi/tool_output_cut.rb +17 -0
  95. data/lib/samagotchi/tool_runner.rb +7 -4
  96. data/lib/samagotchi/toolkit_command.rb +1 -1
  97. data/lib/samagotchi/tools/delegate.rb +10 -5
  98. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  99. data/lib/samagotchi/tools/index_hint.rb +14 -0
  100. data/lib/samagotchi/tools/registry.rb +28 -11
  101. data/lib/samagotchi/tools/send_note.rb +4 -6
  102. data/lib/samagotchi/tools/tool_index.rb +52 -51
  103. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  104. data/lib/samagotchi/turn_context.rb +2 -1
  105. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  106. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  107. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  108. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  109. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  110. data/lib/samagotchi/version.rb +1 -1
  111. data/lib/samagotchi/vision_support.rb +3 -3
  112. data/lib/samagotchi/web/app.rb +15 -8
  113. data/lib/samagotchi/web/participant_access.rb +2 -2
  114. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  115. data/lib/samagotchi/web/session_control.rb +3 -3
  116. data/lib/samagotchi/web/session_create.rb +3 -3
  117. data/lib/samagotchi/web/session_hub.rb +1 -1
  118. data/lib/samagotchi/web/session_show.rb +3 -3
  119. data/lib/samagotchi/web/session_summary.rb +3 -4
  120. data/lib/samagotchi/worker.rb +55 -27
  121. data/lib/samagotchi/worker_boot.rb +184 -0
  122. data/lib/samagotchi/worker_signals.rb +1 -1
  123. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  124. metadata +13 -1
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../worker_idle_exit"
4
+
5
+ module Samagotchi
6
+ class SessionManager
7
+ # A restart that can't happen now. reason: :not_running (no live worker:
8
+ # the next prompt starts one on the newest chi anyway), :unsupported (a
9
+ # worker from before restarts, running +version+: stop it instead), a
10
+ # WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
11
+ # :fallback_held (disk-visible work blocks an unresponsive-worker restart),
12
+ # :stopped (its worker, +pid+, is SIGSTOPped: neither the ask nor a TERM
13
+ # reaches it until it is continued), or :failed (the worker answered something else or didn't release).
14
+ class RestartRefused < StandardError
15
+ attr_reader :session_id, :reason, :version, :holds, :pid
16
+
17
+ def initialize(session_id, reason, version: nil, holds: [], pid: nil, via: ChiInvocation::HERE)
18
+ @session_id = session_id
19
+ @reason = reason.to_sym
20
+ @version = version
21
+ @holds = Array(holds).map(&:to_sym).freeze
22
+ @pid = pid
23
+ super(RestartRefused.words(session_id, @reason, version, @holds, pid: pid, via: via))
24
+ end
25
+
26
+ HOLDS = {
27
+ starting: "its worker is still starting",
28
+ turn_running: "a turn is running",
29
+ input_queued: "a prompt is queued",
30
+ continue_offered: "the step-limit question waits for an answer",
31
+ question_pending: "a question or approval waits for an answer",
32
+ # Only a worker from before ReminderCarry still holds a restart for its reminders.
33
+ reminders: "it has reminders, which its worker's older chi can't carry over a restart",
34
+ command_running: "a /btw (or another command) is still running",
35
+ relays_open: "a delegate's approval relayed here is open or just answered",
36
+ background_tasks: "background tasks it started are still running",
37
+ scratch: "a chi scratch session's worker ends with its terminal"
38
+ }.merge(WorkerIdleExit::LEAVING_WORDS).freeze
39
+
40
+ # What the CLI and the web say.
41
+ def self.words(session_id, reason, version = nil, holds = [], pid: nil, via: ChiInvocation::HERE)
42
+ stop = via.command("sessions stop #{session_id}")
43
+ case reason
44
+ when :stopped
45
+ where = via.remote? ? " on #{via.remote}" : ""
46
+ "session #{session_id}'s worker#{" (pid #{pid})" if pid} is stopped (SIGSTOP or Ctrl-Z) and can't take " \
47
+ "a restart: continue it#{" with `kill -CONT #{pid}`" if pid}#{where}, then restart again"
48
+ when :not_running
49
+ "session #{session_id} has no running worker; the next prompt starts one on the newest chi"
50
+ when :unsupported
51
+ "session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
52
+ "#{stop}, then any prompt starts it on the newest chi"
53
+ when :fallback_held
54
+ why = holds.map { |hold| HOLDS.fetch(hold, hold.to_s.tr("_", " ")) }.join("; ")
55
+ "session #{session_id} has disk-visible work that a restart might lose#{": #{why}" unless why.empty?}; " \
56
+ "run `#{stop}`, then any prompt starts it on the newest chi"
57
+ when :failed then "session #{session_id}'s worker didn't take the restart"
58
+ else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
59
+ end
60
+ end
61
+ end
62
+
63
+ # What a restart did: the worker's version before and after (nil when
64
+ # the new worker hadn't published its Bridge within the wait).
65
+ Restarted = Struct.new(:session_id, :from_version, :version, keyword_init: true)
66
+
67
+ RESTART_WAIT = 10.0
68
+ # A TERMed worker writes its recap on leave (an LLM call) while it still
69
+ # holds the lock: STOP_WAIT plus the recap's own wait (SessionContinue::RECAP_WAIT).
70
+ FALLBACK_STOP_WAIT = 40
71
+
72
+ STOP_CANCEL_WAIT = 3.0
73
+ # How long a stop from the CLI (chi sessions stop, delete, a continue's
74
+ # orphan) waits for the worker to let go of the session, here or
75
+ # through chi web (POST …/stop's wait, capped at it).
76
+ STOP_WAIT = 10
77
+ # The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
78
+ # not the user's Stop, which only ends the turn ("user").
79
+ STOP_CANCEL_REASON = "session_stopped"
80
+
81
+ # Lifecycle: stop a session's worker (TERM, then CONT) and restart it on
82
+ # the newest chi (its Bridge's POST /exit, else the fallback). Calls
83
+ # SpawnPart's resume_session and the facade's ownership checks. Meant to
84
+ # be loaded through samagotchi/session_manager.
85
+ module LifecyclePart
86
+ # Hand a session to a new worker on the newest chi installed: ask its
87
+ # worker (POST /exit restart: true), which leaves only when nothing would
88
+ # be lost and starts its successor. If the worker cannot be asked, stop
89
+ # it without a stop marker and resume only when disk shows no work to lose.
90
+ # @return [Restarted]
91
+ # @raise [OwnedByTUI] a one-turn chi run owns the session
92
+ # @raise [RestartRefused]
93
+ # @raise [ArgumentError] no such session
94
+ def restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: ClientId::CLI_RESTART)
95
+ sd = state_dir || Session.default_state_dir
96
+ Session.load(session_id, state_dir: sd) # ArgumentError for none
97
+ owner = refuse_tui!(session_id, state_dir: sd)
98
+ # A stopped worker answers neither the ask (READ_TIMEOUT) nor the
99
+ # fallback's TERM (FALLBACK_STOP_WAIT): say so now, not after ~70 s.
100
+ raise RestartRefused.new(session_id, :stopped, pid: owner.pid) if owner&.worker? && ProcessGroup.stopped?(owner.pid)
101
+
102
+ dir = Session.session_dir(session_id, state_dir: sd)
103
+ link = WorkerLink.resolve(dir, unlink: false, owner: owner)
104
+ if link.is_a?(WorkerLink::Skew)
105
+ return restart_fallback(session_id, owner: owner, sidecar: link.sidecar, state_dir: sd, wait: wait, why: :skew)
106
+ end
107
+
108
+ if link.is_a?(WorkerLink::Starting)
109
+ return restart_fallback(session_id, owner: owner, sidecar: nil, state_dir: sd, wait: wait, why: :starting_stuck) if link.stuck
110
+
111
+ raise RestartRefused.new(session_id, :starting)
112
+ end
113
+ raise RestartRefused.new(session_id, :not_running) unless link.live?
114
+
115
+ sidecar = link.sidecar
116
+ unless sidecar.features.include?("restart")
117
+ raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
118
+ end
119
+
120
+ begin
121
+ reply = BridgeClient.for_sidecar(session_id, sidecar).request_exit(client_id: client_id, restart: true)
122
+ rescue SystemCallError, IOError => e
123
+ return restart_fallback(session_id, owner: owner, sidecar: sidecar, state_dir: sd, wait: wait, why: :unreachable,
124
+ error: e)
125
+ end
126
+ raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
127
+ if reply.secret_refused?
128
+ return restart_fallback(session_id, owner: owner, sidecar: sidecar, state_dir: sd, wait: wait, why: :bridge_secret)
129
+ end
130
+ raise RestartRefused.new(session_id, :failed) unless reply.status == 200
131
+
132
+ Log.info(:worker, "restart", sid: session_id, from: sidecar.version, by: client_id)
133
+ fresh = WorkerLink.await(session_id, session_dir: dir, timeout: wait, unlink: false, interval: 0.1,
134
+ until: ->(live) { live.epoch != sidecar.epoch || live.started_at != sidecar.started_at })
135
+ # Timed out: the last outcome, the old worker's Live too, is no successor.
136
+ changed = fresh.live? && (fresh.epoch != sidecar.epoch || fresh.started_at != sidecar.started_at)
137
+ version = fresh.version if changed
138
+ Restarted.new(session_id: session_id, from_version: sidecar.version, version: version)
139
+ end
140
+
141
+ private def restart_fallback(session_id, owner:, sidecar:, state_dir:, wait:, why:, error: nil)
142
+ Log.info(:worker, "restart_fallback", sid: session_id, why: why, error: error&.class&.name, msg: error&.message)
143
+ dir = Session.session_dir(session_id, state_dir: state_dir)
144
+ session = Session.load(session_id, state_dir: state_dir)
145
+ holds = restart_fallback_holds(session, dir)
146
+ raise RestartRefused.new(session_id, :fallback_held, holds: holds) unless holds.empty?
147
+ raise RestartRefused.new(session_id, :failed, version: sidecar&.version) unless (owner&.pid || 0).to_i.positive?
148
+
149
+ # The ask may have taken READ_TIMEOUT: TERM only the owner read at the
150
+ # start; a successor that took the lock meanwhile is awaited, not killed.
151
+ current = session_owner(session_id, state_dir: state_dir)
152
+ if current.nil?
153
+ resume_session(session_id, state_dir: state_dir)
154
+ elsif current.pid == owner.pid && current.started_at == owner.started_at
155
+ begin
156
+ Process.kill("TERM", owner.pid)
157
+ rescue Errno::ESRCH
158
+ nil
159
+ rescue SystemCallError => e
160
+ Log.warn(:worker, "restart_fallback_failed", sid: session_id, why: why, error: e.class.name, msg: e.message)
161
+ raise RestartRefused.new(session_id, :failed, version: sidecar&.version)
162
+ end
163
+ unless wait_for_owner_release(session_id, timeout: FALLBACK_STOP_WAIT, state_dir: state_dir)
164
+ raise RestartRefused.new(session_id, :failed, version: sidecar&.version)
165
+ end
166
+
167
+ resume_session(session_id, state_dir: state_dir)
168
+ end
169
+ previous_epoch = sidecar&.epoch
170
+ previous_started_at = sidecar&.started_at
171
+ fresh = WorkerLink.await(session_id, session_dir: dir, timeout: wait, unlink: false, interval: 0.1,
172
+ until: ->(live) { live.epoch != previous_epoch || live.started_at != previous_started_at })
173
+ changed = fresh.live? && (fresh.epoch != previous_epoch || fresh.started_at != previous_started_at)
174
+ Restarted.new(session_id: session_id, from_version: sidecar&.version, version: (fresh.version if changed))
175
+ end
176
+
177
+ private def restart_fallback_holds(session, session_dir)
178
+ holds = []
179
+ holds << :turn_running if session.status == Session::STATUS_RUNNING
180
+ holds << :question_pending if session.pending_question
181
+ holds << :input_queued unless SessionInbox.find_new_input_files(session_dir).empty?
182
+ holds << :background_tasks if running_created_task?(session)
183
+ holds
184
+ end
185
+
186
+ private def running_created_task?(session)
187
+ root = session.working_directory.to_s
188
+ return false if root.empty?
189
+
190
+ Tools::TaskRuntime.created_ids_in(session.messages).any? do |id|
191
+ next false unless Tools::TaskRuntime::TASK_ID.match?(id)
192
+
193
+ path = File.join(root, Tools::TaskRuntime::TASKS_DIR, id, Tools::TaskRuntime::METADATA_FILENAME)
194
+ next false unless File.file?(path)
195
+
196
+ Tools::TaskRecord.from_file(JSON.parse(File.read(path)))&.running?
197
+ rescue JSON::ParserError, SystemCallError => e
198
+ Log.warn(:worker, "restart_task_check_failed", sid: session.id, task_id: id,
199
+ error: e.class.name, msg: e.message)
200
+ true
201
+ end
202
+ end
203
+
204
+ # Stop a session by sending TERM (then CONT, for a stopped one) to its process.
205
+ # With +wait+ (seconds), also wait for the owner to let go of the session,
206
+ # so a resume right after spawns a fresh worker instead of finding the
207
+ # dying one.
208
+ # A turn still running is canceled first (STOP_CANCEL_WAIT), as the
209
+ # web's Cancel does, with reason STOP_CANCEL_REASON: its worker saves it. The TERM stops (and saves)
210
+ # what still runs (WorkerSignals), with a short grace after the marker.
211
+ # @return [Boolean, nil] with +wait+: whether the owner was gone in time
212
+ # @raise [OwnedByTUI] when the interactive TUI owns the session
213
+ def stop_session(session_id, state_dir: nil, wait: nil)
214
+ sd = state_dir || Session.default_state_dir
215
+ owner = refuse_tui!(session_id, state_dir: sd)
216
+
217
+ cancel_running_turn(session_id, state_dir: sd)
218
+ # Mark first: a worker that has not taken the lock yet sees it and exits.
219
+ Session.mark_stopped(session_id, state_dir: sd)
220
+ # A stop drops the reminders: a restart's hand-off no successor took
221
+ # yet must not bring them back on the next resume.
222
+ ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
223
+ pid = owner&.pid.to_i
224
+ begin
225
+ Process.kill("TERM", pid) if pid && pid > 0
226
+ rescue Errno::ESRCH
227
+ nil # already exited
228
+ end
229
+ continue_stopped(pid)
230
+ wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
231
+ end
232
+
233
+ # A stopped worker (SIGSTOP, Ctrl-Z) runs its TERM handler only once
234
+ # continued: CONT it after the TERM (as ProcessGroup.stop does), so it
235
+ # leaves now rather than outliving the stop's wait. Best effort: one the
236
+ # TERM ended may be gone already.
237
+ private def continue_stopped(pid)
238
+ Process.kill("CONT", pid) if pid && pid > 0
239
+ rescue Errno::ESRCH, Errno::EPERM
240
+ nil
241
+ end
242
+
243
+ # Cancel the session's running turn over its Bridge and wait (up to
244
+ # STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
245
+ # live Bridge, or when it doesn't answer, the stop goes on as before.
246
+ private def cancel_running_turn(session_id, state_dir:)
247
+ return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
248
+
249
+ client = WorkerLink.client(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
250
+ return unless client && client.cancel(reason: STOP_CANCEL_REASON).status == 202
251
+
252
+ BridgeClient.poll(STOP_CANCEL_WAIT) do
253
+ Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
254
+ end
255
+ rescue StandardError => e
256
+ Log.info(:worker, "stop_cancel_failed", sid: session_id, error: e.class.name, msg: e.message)
257
+ nil
258
+ end
259
+
260
+ # @return [Boolean] whether the session had no owner within +timeout+ seconds
261
+ def wait_for_owner_release(session_id, timeout:, state_dir:)
262
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
263
+ while session_owner(session_id, state_dir: state_dir)
264
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
265
+
266
+ sleep(OwnerLock::RETRY_INTERVAL)
267
+ end
268
+ true
269
+ end
270
+ end
271
+
272
+ extend LifecyclePart
273
+ end
274
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionManager
5
+ SUMMARY_DESC_LIMIT = 60
6
+
7
+ SUMMARY_PREVIEW_LIMIT = 120
8
+
9
+ # Listing: read-only queries over the saved sessions (the lists, their
10
+ # summary rows, a parent's delegates, a session's replies). Calls the
11
+ # facade's session_owner. Meant to be loaded through samagotchi/session_manager.
12
+ module ListingPart
13
+ # List all sessions, reading status from persisted session.json files.
14
+ # +project_root+: only that project's sessions (nil: every session).
15
+ # +include_archived+: archived sessions too (Session#archived says which).
16
+ def list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
17
+ include_archived: false)
18
+ Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
19
+ offset: offset, project_root: project_root, include_archived: include_archived)
20
+ end
21
+
22
+ # Short summaries of sessions, newest first: the picker behind
23
+ # `chi sessions list --live --format json|tsv` (chi note from a script)
24
+ # and the agent's list_sessions tool; both pass the current project as
25
+ # +project_root+ unless asked for every project.
26
+ # @param live [Boolean] only sessions a worker owns now (the owner lock,
27
+ # not the saved status, which a dead worker leaves at "running"); a
28
+ # session a one-shot (or an older chi's REPL) owns is left out: it
29
+ # can't take notes
30
+ # @param cwd [String, nil] only sessions in this folder or below it
31
+ # @param project_root [String, nil] only this project's sessions
32
+ # (Session#project_root)
33
+ # @param limit [Integer, nil] taken after the filters
34
+ # @param include_tests [Boolean] false leaves out test runs
35
+ # @param exclude [String, nil] a session id to leave out (the asker)
36
+ # @param include_archived [Boolean] archived sessions too
37
+ # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
38
+ # updated_at:, status:, saved_status:, live:, busy:, owner:, recap:, auto_title:, parent_id:,
39
+ # parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
40
+ # waiting_id:, stopped_by:}; status = Session#displayed_status (a dead
41
+ # worker's "running" is idle, as chi web shows it), saved_status = the
42
+ # file's (a "running" with no owner yet is also a worker on its way:
43
+ # Tools::Delegate.running?), busy =
44
+ # live with a turn running, delegate = started by the delegate tool
45
+ # (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
46
+ # previous link of its chain, Session#continues), recap = the saved recap's first sentence, auto_title =
47
+ # the auto title its reply led with (RecapTitle) or nil, project =
48
+ # Session#project_root, waiting = the kind of question it waits on
49
+ # ("question", "approval", "hook"; Session#waiting_question) or nil,
50
+ # waiting_id = that question's id or nil, relayed_to = the parent
51
+ # whose card it waits in too (the approval relay), its short id, or nil,
52
+ # stopped_by = the hook that stopped the last turn (Session#stopped_by)
53
+ def session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
54
+ project_root: nil, include_archived: false, sort: nil, order: nil)
55
+ sd = state_dir || Session.default_state_dir
56
+ root = cwd && folder_path(cwd)
57
+ roots = {}
58
+ # One registry per listing, built only when a row has a saved context
59
+ # to count: its session's llm_context budget is what ctx_pct counts
60
+ # against (SessionMetrics.saved_context_pct).
61
+ budgets = listing_budgets
62
+ summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
63
+ include_archived: include_archived).lazy
64
+ .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
65
+ .select { |s| root.nil? || in_folder?(s.working_directory, root) }
66
+ .filter_map do |s|
67
+ owner = session_owner(s.id, state_dir: sd)
68
+ owned = !!owner&.worker?
69
+ next if live && !owned
70
+
71
+ { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
72
+ project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.displayed_status(owner: owner), saved_status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
73
+ owner: owner&.kind, **recap_fields(Session.session_dir(s.id, state_dir: sd)),
74
+ ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
75
+ budget_tokens: -> { budgets.call(s) })&.round(1),
76
+ parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
77
+ continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
78
+ scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
79
+ end
80
+ (limit ? summaries.first(limit) : summaries.to_a)
81
+ end
82
+
83
+ # waiting: the question's kind, waiting_id: its id (what chi answer
84
+ # --question takes); both nil with none.
85
+ private def waiting_fields(waiting)
86
+ { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
87
+ end
88
+
89
+ # One HostRegistry for a whole listing, built on the first row that
90
+ # needs it (a saved context to count) and kept for the rest: resolving a
91
+ # session's llm_context budget reads config.yml (mtime-cached), so not
92
+ # one registry per row.
93
+ # @return [#call] (session) -> the session's budget in tokens or nil
94
+ private def listing_budgets
95
+ registry = nil
96
+ ->(session) { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
97
+ end
98
+
99
+ # The sessions delegated by +parent_id+ (the `delegate` tool), newest
100
+ # first, as .session_summaries rows. A running one (busy) counts against
101
+ # session.max_children. Archived ones too.
102
+ def children_of(parent_id, state_dir: nil)
103
+ return [] if parent_id.to_s.empty?
104
+
105
+ session_summaries(state_dir: state_dir, include_tests: true, include_archived: true)
106
+ .select { |s| s[:parent_id] == parent_id.to_s }
107
+ end
108
+
109
+ # Children, their children, …: a delegated session doesn't delegate
110
+ # further today, a plugin's fork may. The walk doesn't enter +except+.
111
+ def descendant_ids(id, state_dir, except: [])
112
+ seen = [id]
113
+ queue = [id]
114
+ until queue.empty?
115
+ children_of(queue.shift, state_dir: state_dir).each do |child|
116
+ next if seen.include?(child[:id]) || except.include?(child[:id])
117
+
118
+ seen << child[:id]
119
+ queue << child[:id]
120
+ end
121
+ end
122
+ seen.drop(1)
123
+ end
124
+
125
+ # "<cwd basename> · <last prompt, or the first preview>", one line.
126
+ private def summary_desc(session)
127
+ desc = [File.basename(session.working_directory.to_s), summary_text(session)].reject(&:empty?).join(" · ")
128
+ cut(desc, SUMMARY_DESC_LIMIT)
129
+ end
130
+
131
+ # A session row's recap: and auto_title: (RecapStore.card, one read).
132
+ private def recap_fields(session_dir)
133
+ card = RecapStore.card(session_dir)
134
+ { recap: card.preview, auto_title: card.title }
135
+ end
136
+
137
+ private def summary_preview(session)
138
+ cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
139
+ end
140
+
141
+ # A session as one line of text for the lists: its title (/title), else
142
+ # its last prompt, else the first message it was started with (a session
143
+ # command like `/model x`, or a first turn not saved yet).
144
+ def summary_text(session)
145
+ return one_line(session.title) unless session.title.to_s.strip.empty?
146
+
147
+ one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
148
+ end
149
+
150
+ # A prompt as one line for a session list: whitespace collapsed, and a
151
+ # quoted or annotated message (ContextQuote, the web's annotations)
152
+ # without its "> " markers, so the words show.
153
+ def one_line(text)
154
+ text.to_s.gsub(/^[ \t]*(?:>[ \t]?)+/, "").gsub(/\s+/, " ").strip
155
+ end
156
+
157
+ private def cut(text, limit)
158
+ text.length > limit ? "#{text[0, limit - 1]}…" : text
159
+ end
160
+
161
+ private def folder_path(path)
162
+ File.realpath(path)
163
+ rescue SystemCallError
164
+ File.expand_path(path)
165
+ end
166
+
167
+ private def in_folder?(dir, root)
168
+ dir = dir.to_s.chomp("/")
169
+ root = root.chomp("/")
170
+ dir == root || dir.start_with?("#{root}/")
171
+ end
172
+
173
+ # Read any new output files from a session directory.
174
+ # Optionally filters to only files newer than `since_time`.
175
+ def read_responses(session_id, since_time: nil, state_dir: nil)
176
+ SessionInbox.read_outputs(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir),
177
+ since_time: since_time)
178
+ end
179
+
180
+ def stopped_on_disk?(session_id, state_dir:)
181
+ Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
182
+ rescue ArgumentError
183
+ false
184
+ end
185
+ end
186
+
187
+ extend ListingPart
188
+ end
189
+ 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