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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -1
- data/bin/chi +1 -1
- data/docs/architecture.md +10 -6
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/attached-context.md +3 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +625 -935
- data/docs/internals/llm-context-forget.md +8 -3
- data/docs/internals/objectives.md +2 -2
- data/docs/internals/prompt-caching.md +4 -1
- data/docs/internals/remote-sessions.md +3 -1
- data/docs/internals/working-set.md +1 -1
- data/docs/sessions.md +2 -2
- data/docs/system-one.md +3 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +3 -3
- data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +7 -51
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +1 -1
- data/lib/samagotchi/config.rb +17 -112
- data/lib/samagotchi/context_command.rb +1 -2
- data/lib/samagotchi/context_window.rb +3 -3
- data/lib/samagotchi/desktop/macos.rb +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -11
- data/lib/samagotchi/engine/messages.rb +1 -1
- data/lib/samagotchi/engine/models.rb +5 -2
- data/lib/samagotchi/engine/objectives.rb +29 -11
- data/lib/samagotchi/engine/side_requests.rb +21 -11
- data/lib/samagotchi/engine/toolkit.rb +10 -9
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +7 -7
- data/lib/samagotchi/engine/verify.rb +1 -1
- data/lib/samagotchi/engine.rb +14 -9
- data/lib/samagotchi/guardrails/targets.rb +24 -9
- data/lib/samagotchi/host_config.rb +72 -1
- data/lib/samagotchi/host_model.rb +2 -6
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +46 -121
- data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
- data/lib/samagotchi/llm/chat_loop.rb +34 -18
- data/lib/samagotchi/llm/native_backend.rb +1 -1
- data/lib/samagotchi/llm_context_apply.rb +54 -1
- data/lib/samagotchi/llm_context_notice.rb +1 -1
- data/lib/samagotchi/llm_context_strategy.rb +7 -9
- data/lib/samagotchi/llm_context_view.rb +9 -0
- data/lib/samagotchi/model_price.rb +4 -10
- data/lib/samagotchi/model_profile.rb +6 -6
- data/lib/samagotchi/model_settings.rb +37 -2
- data/lib/samagotchi/note_command.rb +1 -2
- data/lib/samagotchi/objectives_audit.rb +4 -3
- data/lib/samagotchi/participants.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +2 -5
- data/lib/samagotchi/plugin/tool_spec.rb +5 -3
- data/lib/samagotchi/project_env.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +3 -3
- data/lib/samagotchi/reminder_carry.rb +1 -1
- data/lib/samagotchi/reprefill_meter.rb +45 -0
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/sampling_settings.rb +3 -3
- data/lib/samagotchi/self_report.rb +7 -7
- data/lib/samagotchi/session.rb +50 -7
- data/lib/samagotchi/session_access/local.rb +7 -5
- data/lib/samagotchi/session_access/remote.rb +17 -3
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +7 -6
- data/lib/samagotchi/session_continue.rb +6 -5
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_manager/delivery.rb +254 -0
- data/lib/samagotchi/session_manager/emptiness.rb +138 -0
- data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
- data/lib/samagotchi/session_manager/listing.rb +189 -0
- data/lib/samagotchi/session_manager/removal.rb +190 -0
- data/lib/samagotchi/session_manager/spawn.rb +242 -0
- data/lib/samagotchi/session_manager/worker_command.rb +157 -0
- data/lib/samagotchi/session_manager.rb +26 -1540
- data/lib/samagotchi/session_metrics.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/share_command.rb +1 -1
- data/lib/samagotchi/starting_marker.rb +65 -0
- data/lib/samagotchi/steer_cut_after.rb +3 -3
- data/lib/samagotchi/system_one/recorder.rb +49 -0
- data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +2 -6
- data/lib/samagotchi/testing/sessions.rb +1 -1
- data/lib/samagotchi/thinking.rb +3 -3
- data/lib/samagotchi/token_usage.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +10 -4
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tool_output_cut.rb +17 -0
- data/lib/samagotchi/tool_runner.rb +7 -4
- data/lib/samagotchi/toolkit_command.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +10 -5
- data/lib/samagotchi/tools/delegate_result.rb +4 -4
- data/lib/samagotchi/tools/index_hint.rb +14 -0
- data/lib/samagotchi/tools/registry.rb +28 -11
- data/lib/samagotchi/tools/send_note.rb +4 -6
- data/lib/samagotchi/tools/tool_index.rb +52 -51
- data/lib/samagotchi/tools/tool_surface.rb +22 -8
- data/lib/samagotchi/turn_context.rb +2 -1
- data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
- data/lib/samagotchi/turn_stages/objectives.rb +56 -52
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
- data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
- data/lib/samagotchi/turn_stages/warmup.rb +14 -15
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +3 -3
- data/lib/samagotchi/web/app.rb +15 -8
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +3 -3
- data/lib/samagotchi/web/session_create.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +3 -3
- data/lib/samagotchi/web/session_summary.rb +3 -4
- data/lib/samagotchi/worker.rb +55 -27
- data/lib/samagotchi/worker_boot.rb +184 -0
- data/lib/samagotchi/worker_signals.rb +1 -1
- data/lib/samagotchi/worker_task_wakes.rb +2 -2
- 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
|