samagotchi 0.159.0 → 0.161.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 +125 -1
- data/docs/architecture.md +12 -9
- data/docs/cli.md +7 -5
- data/docs/context.md +10 -1
- data/docs/guardrails.md +33 -5
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +28 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/engine.md +7 -1
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +43 -13
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +53 -8
- data/docs/sessions.md +17 -12
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +103 -44
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +57 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +6 -0
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +10 -4
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +25 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +93 -23
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
- data/lib/samagotchi/memory_bundle/status.rb +3 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
- data/lib/samagotchi/memory_orphans.rb +54 -13
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +374 -76
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +59 -7
- data/lib/samagotchi/session_access/local.rb +53 -22
- data/lib/samagotchi/session_access/remote.rb +13 -5
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +20 -4
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +162 -24
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +167 -100
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +129 -0
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +19 -6
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +9 -4
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -5
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +15 -11
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +61 -11
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +62 -10
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +9 -1
|
@@ -37,6 +37,17 @@ module Samagotchi
|
|
|
37
37
|
|
|
38
38
|
def detail = ack["detail"]
|
|
39
39
|
|
|
40
|
+
# How a delivery that did not go in reads, as one line: the word for it
|
|
41
|
+
# (refused: a worker older than queueing would steer the running turn,
|
|
42
|
+
# or a remote refused it as chi send refuses it here; failed: it simply
|
|
43
|
+
# did not get in) and its reason. chi send prints it, and the delegate
|
|
44
|
+
# tool's follow-up says it after "Error: session <id8>".
|
|
45
|
+
# @return [String]
|
|
46
|
+
def refusal_text
|
|
47
|
+
refused = error == Delivery::QUEUE_UNAVAILABLE["error"] || DeliverResult::REMOTE_REFUSALS.include?(error)
|
|
48
|
+
"#{refused ? "refused" : "failed"}: #{detail || "could not queue it"}"
|
|
49
|
+
end
|
|
50
|
+
|
|
40
51
|
# What the worker applied (a Bridge ack only): the delivery asked for,
|
|
41
52
|
# or next_step for a --queue that found no turn running; nil from the
|
|
42
53
|
# input file or a worker older than delivery.
|
|
@@ -49,6 +60,13 @@ module Samagotchi
|
|
|
49
60
|
def cut_after = ack["cut_after"]
|
|
50
61
|
end
|
|
51
62
|
|
|
63
|
+
# A remote's refusals of what this machine checks before sending (a
|
|
64
|
+
# parent's /continue yes, a loosening /parent-answers, images to a
|
|
65
|
+
# model that can't see them): its turn route's error codes. Named on
|
|
66
|
+
# DeliverResult, not inside its Data.define block, where a constant
|
|
67
|
+
# would land on SessionManager.
|
|
68
|
+
DeliverResult::REMOTE_REFUSALS = %w[parent_answers_refused parent_continue_refused no_vision].freeze
|
|
69
|
+
|
|
52
70
|
# The unclaimed input files +author+ wrote.
|
|
53
71
|
PARTICIPANT_QUEUE_LOCK = "participants.lock"
|
|
54
72
|
|
|
@@ -118,7 +136,10 @@ module Samagotchi
|
|
|
118
136
|
link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
|
|
119
137
|
link.live? ? link.client(session_id) : nil
|
|
120
138
|
end
|
|
121
|
-
|
|
139
|
+
# Its refusals (no session, a one-turn run's, a scratch one) are
|
|
140
|
+
# resume_session's to raise, in their one wording.
|
|
141
|
+
start_for_send(manager, session_id, state_dir, before: true)
|
|
142
|
+
enqueued_id = SessionInbox.new_enqueued_id
|
|
122
143
|
if (client = bridge.call)
|
|
123
144
|
if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
|
|
124
145
|
return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
|
|
@@ -130,7 +151,7 @@ module Samagotchi
|
|
|
130
151
|
return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
|
|
131
152
|
|
|
132
153
|
begin
|
|
133
|
-
options = { prompt: prompt, client_id: client_id }
|
|
154
|
+
options = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id }
|
|
134
155
|
options[:images] = images unless images.empty?
|
|
135
156
|
# An old worker ignores the field: it cuts anyway (its own default).
|
|
136
157
|
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
@@ -164,12 +185,12 @@ module Samagotchi
|
|
|
164
185
|
# still holding the session with its Bridge gone (exiting) may be an
|
|
165
186
|
# older one, which would run it without the review boundary.
|
|
166
187
|
# (A one-turn chi run holding it is refused as for anyone, below.)
|
|
167
|
-
if Steer.participant_author?(author) && (holder =
|
|
188
|
+
if Steer.participant_author?(author) && (holder = delivery_presence(manager, session_id, state_dir)).owned? &&
|
|
189
|
+
!holder.tui_owned?
|
|
168
190
|
return DeliverResult.new(status: :timeout, via: :file,
|
|
169
191
|
ack: { "error" => "worker_timeout",
|
|
170
192
|
"detail" => "the session's worker is exiting, so the message was not sent; send it again" })
|
|
171
193
|
end
|
|
172
|
-
enqueued_id = SecureRandom.uuid
|
|
173
194
|
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
174
195
|
input[:images] = images unless images.empty?
|
|
175
196
|
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
@@ -189,21 +210,51 @@ module Samagotchi
|
|
|
189
210
|
end
|
|
190
211
|
return DeliverResult.new(status: :failed) unless path
|
|
191
212
|
|
|
192
|
-
owner = delivery_owner(manager, session_id, state_dir)
|
|
193
213
|
# A TUI that took the session between the resume and the write never
|
|
194
214
|
# reads input files; a later worker would replay this one.
|
|
195
|
-
if owner&.tui?
|
|
196
|
-
FileUtils.rm_f(path) if path.is_a?(String)
|
|
197
|
-
raise OwnedByTUI, session_id
|
|
198
|
-
end
|
|
199
215
|
# A worker that idle-exited since the resume never reads it either:
|
|
200
216
|
# wake a new one. (The exiting worker also looks for input it left;
|
|
201
217
|
# one the first resume spawned that is still starting is left be.)
|
|
202
|
-
manager
|
|
218
|
+
start_for_send(manager, session_id, state_dir, before: false) do
|
|
219
|
+
FileUtils.rm_f(path) if path.is_a?(String)
|
|
220
|
+
raise OwnedByTUI, session_id
|
|
221
|
+
end
|
|
203
222
|
DeliverResult.new(status: :accepted, via: :file,
|
|
204
223
|
ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
|
|
205
224
|
end
|
|
206
225
|
|
|
226
|
+
# deliver_turn's two liveness checks, on the session inbox's send row
|
|
227
|
+
# (SessionInbox::WakePolicy: send un-stops and starts an archived
|
|
228
|
+
# link; it is pinned to the id it names): resume_session unless a
|
|
229
|
+
# worker holds the session or one is being started. Its refusals (no
|
|
230
|
+
# session, a one-turn run's, a scratch one) are resume_session's to
|
|
231
|
+
# raise, in their one wording: +before+ the write, a session it would
|
|
232
|
+
# not resume is checked for them (WakePolicy#refusal: the file read
|
|
233
|
+
# only then, resume_session reads it otherwise); after it, a one-turn
|
|
234
|
+
# run that took the session since runs the block, and a worker that
|
|
235
|
+
# is leaving (OwnerLock::Owner#leaving?: its Bridge closed, the lock
|
|
236
|
+
# held for its last work) has its input served by its own respawn
|
|
237
|
+
# (WorkerBoot's leave): a stop that landed before this send is
|
|
238
|
+
# cleared, as resume_session clears it (a send un-stops); one that
|
|
239
|
+
# lands after it still wins.
|
|
240
|
+
private def start_for_send(manager, session_id, state_dir, before:)
|
|
241
|
+
presence = delivery_presence(manager, session_id, state_dir)
|
|
242
|
+
policy = SessionInbox::WakePolicy.for(:send)
|
|
243
|
+
outcome = policy.decide(presence)
|
|
244
|
+
yield if outcome == :owned && block_given?
|
|
245
|
+
if !before && outcome == :live && presence.stop_marked? && presence.owner&.leaving?
|
|
246
|
+
Session.clear_stopped(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
247
|
+
Log.info(:worker, "send_unstopped_leaving", sid: session_id[0, 8])
|
|
248
|
+
end
|
|
249
|
+
if %i[live starting].include?(outcome)
|
|
250
|
+
return unless before
|
|
251
|
+
|
|
252
|
+
load = -> { Session.load(session_id, state_dir: state_dir || Session.default_state_dir) }
|
|
253
|
+
return unless policy.refusal(presence, load: load)
|
|
254
|
+
end
|
|
255
|
+
manager.resume_session(session_id, state_dir: state_dir)
|
|
256
|
+
end
|
|
257
|
+
|
|
207
258
|
# Whether the worker +client+ talks to names +feature+: the features of
|
|
208
259
|
# the sidecar the client was made from (the Live link WorkerLink.await
|
|
209
260
|
# found), not a second read of bridge.json.
|
|
@@ -244,8 +295,11 @@ module Samagotchi
|
|
|
244
295
|
"detail" => "the session's worker did not answer, so the message was not sent" })
|
|
245
296
|
end
|
|
246
297
|
|
|
247
|
-
|
|
248
|
-
|
|
298
|
+
# The session's WorkerPresence, its owner read through +manager+ (a
|
|
299
|
+
# stand-in's #session_owner is optional: then none).
|
|
300
|
+
private def delivery_presence(manager, session_id, state_dir)
|
|
301
|
+
owner = Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
|
|
302
|
+
WorkerPresence.read(session_id, state_dir: state_dir, owner: owner)
|
|
249
303
|
end
|
|
250
304
|
end
|
|
251
305
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../analytics_file"
|
|
4
4
|
require_relative "../session_inbox"
|
|
5
5
|
require_relative "../worker_link"
|
|
6
|
+
require_relative "../starting_marker"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
class SessionManager
|
|
@@ -12,7 +13,9 @@ module Samagotchi
|
|
|
12
13
|
# nothing writes or reads it now, but a folder from before still has one
|
|
13
14
|
# and is still empty. A turn's drawing (Bridge::TurnDrawings::FILE)
|
|
14
15
|
# exists only after a turn that kept messages: never a skeleton file.
|
|
15
|
-
|
|
16
|
+
# The starting marker is one too: a start left one behind; a fresh one
|
|
17
|
+
# keeps the session from the deleters itself.
|
|
18
|
+
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME, StartingMarker::FILE].freeze
|
|
16
19
|
EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
|
|
17
20
|
EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
|
|
18
21
|
|
|
@@ -94,7 +97,9 @@ module Samagotchi
|
|
|
94
97
|
elsif name == "cards.json"
|
|
95
98
|
cards_empty?(path)
|
|
96
99
|
else
|
|
97
|
-
|
|
100
|
+
# A starting claim's temp files a kill left (StartingMarker.claim:
|
|
101
|
+
# starting.<pid>.<hex>, starting.old.<pid>.<hex>) are the marker's.
|
|
102
|
+
EMPTY_SKELETON_FILES.include?(name) || name.start_with?("#{StartingMarker::FILE}.")
|
|
98
103
|
end
|
|
99
104
|
end
|
|
100
105
|
end
|
|
@@ -220,7 +220,8 @@ module Samagotchi
|
|
|
220
220
|
# A stop drops the reminders: a restart's hand-off no successor took
|
|
221
221
|
# yet must not bring them back on the next resume.
|
|
222
222
|
ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
|
|
223
|
-
|
|
223
|
+
# A deleter holding the lock (chi web, the CLI) is no worker to stop.
|
|
224
|
+
pid = owner&.deleting? ? 0 : owner&.pid.to_i
|
|
224
225
|
begin
|
|
225
226
|
Process.kill("TERM", pid) if pid && pid > 0
|
|
226
227
|
rescue Errno::ESRCH
|
|
@@ -11,7 +11,9 @@ module Samagotchi
|
|
|
11
11
|
# +include_archived+: archived sessions too (Session#archived says which).
|
|
12
12
|
def list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
|
|
13
13
|
include_archived: false)
|
|
14
|
-
|
|
14
|
+
sd = state_dir || Session.default_state_dir
|
|
15
|
+
SessionContinue.repair_interrupted(state_dir: sd)
|
|
16
|
+
Session.list(state_dir: sd, sort: sort, order: order, limit: limit,
|
|
15
17
|
offset: offset, project_root: project_root, include_archived: include_archived)
|
|
16
18
|
end
|
|
17
19
|
|
|
@@ -34,6 +36,7 @@ module Samagotchi
|
|
|
34
36
|
def session_rows(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
35
37
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
36
38
|
sd = state_dir || Session.default_state_dir
|
|
39
|
+
SessionContinue.repair_interrupted(state_dir: sd)
|
|
37
40
|
root = cwd && folder_path(cwd)
|
|
38
41
|
roots = {}
|
|
39
42
|
# One registry per listing, built only when a row has a saved context
|
|
@@ -6,12 +6,20 @@ module Samagotchi
|
|
|
6
6
|
# reason: :worker_running (not asked to stop it) or :still_stopping
|
|
7
7
|
# (stopped, but the worker outlived the wait).
|
|
8
8
|
class DeleteRefused < StandardError
|
|
9
|
-
attr_reader :session_id, :reason
|
|
9
|
+
attr_reader :session_id, :reason, :schedule_id
|
|
10
10
|
|
|
11
|
-
def initialize(session_id, reason)
|
|
11
|
+
def initialize(session_id, reason, message: nil, schedule_id: nil)
|
|
12
12
|
@session_id = session_id
|
|
13
13
|
@reason = reason
|
|
14
|
-
|
|
14
|
+
@schedule_id = schedule_id
|
|
15
|
+
message ||= case reason
|
|
16
|
+
when :still_stopping then "session #{session_id}'s worker is still shutting down"
|
|
17
|
+
when :scheduled
|
|
18
|
+
"this session is the target of an enabled schedule" \
|
|
19
|
+
"#{"; cancel it with chi schedule cancel #{schedule_id}" if schedule_id}"
|
|
20
|
+
else "session #{session_id}'s worker is running"
|
|
21
|
+
end
|
|
22
|
+
super(message)
|
|
15
23
|
end
|
|
16
24
|
end
|
|
17
25
|
|
|
@@ -37,6 +45,8 @@ module Samagotchi
|
|
|
37
45
|
when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
|
|
38
46
|
when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
|
|
39
47
|
when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
|
|
48
|
+
when :starting then "its worker is starting; try again in a moment"
|
|
49
|
+
when :starting_child then "its delegate #{@busy_id[0, 8]}'s worker is starting; try again in a moment"
|
|
40
50
|
else "a turn is running; wait for it or cancel it first"
|
|
41
51
|
end
|
|
42
52
|
end
|
|
@@ -47,6 +57,12 @@ module Samagotchi
|
|
|
47
57
|
# workers stopped first; discarded: empty sessions its stopping worker deleted.
|
|
48
58
|
ArchiveResult = Data.define(:id, :archived, :stopped, :discarded)
|
|
49
59
|
|
|
60
|
+
# Every store with session-scoped state. Deletion, retention and failed
|
|
61
|
+
# continuation cleanup all pass through this registry.
|
|
62
|
+
SESSION_STATE_STORES = %i[
|
|
63
|
+
session_files plugin_session_state context_sources participants system_one_runs context_candidates_log
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
50
66
|
# Removal: archive, unarchive and delete a session (an archive takes its
|
|
51
67
|
# delegates too), and the retention sweep's entry. Calls LifecyclePart's
|
|
52
68
|
# stop_session, ListingPart's descendant_ids and the facade's ownership
|
|
@@ -87,23 +103,29 @@ module Samagotchi
|
|
|
87
103
|
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
88
104
|
|
|
89
105
|
tree = [id, *descendant_ids(id, state_dir, except: except)]
|
|
90
|
-
|
|
106
|
+
presences = tree.to_h do |sid|
|
|
107
|
+
[sid, WorkerPresence.read(sid, state_dir: state_dir, owner: session_owner(sid, state_dir: state_dir))]
|
|
108
|
+
end
|
|
91
109
|
tree.each do |sid|
|
|
92
|
-
refusal = archive_refusal(sid,
|
|
110
|
+
refusal = archive_refusal(sid, presences[sid], state_dir)
|
|
93
111
|
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
94
112
|
end
|
|
95
|
-
[tree,
|
|
113
|
+
[tree, presences.transform_values(&:owner)]
|
|
96
114
|
end
|
|
97
115
|
|
|
98
|
-
# What keeps +sid+ (in an archive's tree) from being archived:
|
|
116
|
+
# What keeps +sid+ (in an archive's tree) from being archived:
|
|
117
|
+
# :starting (a worker is being started for it, holding no lock yet: a
|
|
118
|
+
# continue's new link, a resume), :busy (a
|
|
99
119
|
# turn runs), :queued (input/ holds a prompt, which outlives the stop and
|
|
100
120
|
# runs on a resume, live worker or not), :offer (a live worker's continue
|
|
101
121
|
# offer, which the stop would drop), or nil.
|
|
122
|
+
# The status is read only while someone owns it (+presence+'s owner).
|
|
102
123
|
# @raise [OwnedByTUI] a one-turn chi run owns it
|
|
103
|
-
private def archive_refusal(sid,
|
|
104
|
-
raise OwnedByTUI, sid if
|
|
124
|
+
private def archive_refusal(sid, presence, state_dir)
|
|
125
|
+
raise OwnedByTUI, sid if presence.tui_owned?
|
|
126
|
+
return :starting if presence.starting? && !presence.owned?
|
|
105
127
|
|
|
106
|
-
session =
|
|
128
|
+
session = presence.owned? ? Session.load(sid, state_dir: state_dir) : nil
|
|
107
129
|
return :busy if session&.status == Session::STATUS_RUNNING
|
|
108
130
|
return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
|
|
109
131
|
|
|
@@ -135,12 +157,14 @@ module Samagotchi
|
|
|
135
157
|
# @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
|
|
136
158
|
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
137
159
|
# @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
|
|
138
|
-
# @raise [DeleteRefused] a worker owns it
|
|
139
|
-
# outlived the wait
|
|
160
|
+
# @raise [DeleteRefused] a worker owns it or is being started for it
|
|
161
|
+
# (the starting marker) and +stop+ is false, or it outlived the wait
|
|
140
162
|
# @param purge_runs [Boolean] also delete System 1's saved runs of it and
|
|
141
163
|
# its candidate-log lines; a session a worker left empty has none worth
|
|
142
164
|
# the read
|
|
143
|
-
|
|
165
|
+
# @param allow_scheduled [Boolean] internal rollback of a failed continue
|
|
166
|
+
def delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true,
|
|
167
|
+
allow_scheduled: false)
|
|
144
168
|
sd = state_dir || Session.default_state_dir
|
|
145
169
|
given = id_or_prefix.to_s
|
|
146
170
|
# Only a plain id: anything else could name a path outside the state dir.
|
|
@@ -152,12 +176,81 @@ module Samagotchi
|
|
|
152
176
|
# A folder with no file left is still deleted (not resolve!).
|
|
153
177
|
raise Session::NotFound, given unless File.exist?(path) || Dir.exist?(dir)
|
|
154
178
|
|
|
155
|
-
|
|
156
|
-
|
|
179
|
+
unless allow_scheduled
|
|
180
|
+
require_relative "../schedules"
|
|
181
|
+
schedule = Schedules.new(state_dir: sd).protecting(id)
|
|
182
|
+
if schedule
|
|
183
|
+
raise DeleteRefused.new(id, :scheduled, schedule_id: schedule.id)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
presence = WorkerPresence.read(id, state_dir: sd, owner: session_owner(id, state_dir: sd))
|
|
188
|
+
raise OwnedByTUI, id if presence.tui_owned?
|
|
189
|
+
|
|
190
|
+
held = presence.owned? || presence.starting?
|
|
191
|
+
if held
|
|
157
192
|
raise DeleteRefused.new(id, :worker_running) unless stop
|
|
158
|
-
raise DeleteRefused.new(id, :still_stopping) unless
|
|
193
|
+
raise DeleteRefused.new(id, :still_stopping) unless stop_held(id, state_dir: sd, wait: wait)
|
|
159
194
|
end
|
|
160
195
|
|
|
196
|
+
removed = remove_files(id, path, dir, state_dir: sd, purge_runs: purge_runs)
|
|
197
|
+
{ id: id, removed: removed, stopped: held }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Remove every store with state scoped to +id+. The file and sidecar
|
|
201
|
+
# directory keep the deleting owner lock; the other stores have their
|
|
202
|
+
# own locking where they need it.
|
|
203
|
+
# @return [Array<String>] the session file, sidecar and context paths removed
|
|
204
|
+
def remove_files(id, path, dir, state_dir:, purge_runs: true)
|
|
205
|
+
removed = []
|
|
206
|
+
SESSION_STATE_STORES.each do |store|
|
|
207
|
+
case store
|
|
208
|
+
when :session_files
|
|
209
|
+
removed.concat(remove_session_files(id, path, dir, state_dir: state_dir))
|
|
210
|
+
when :plugin_session_state
|
|
211
|
+
PluginSessionState.remove(id)
|
|
212
|
+
when :context_sources
|
|
213
|
+
removed.concat(ContextSources.remove_session(id, state_dir: state_dir))
|
|
214
|
+
when :participants
|
|
215
|
+
Participants.end_sessions([id], by: "delete", state_dir: state_dir)
|
|
216
|
+
when :system_one_runs
|
|
217
|
+
next
|
|
218
|
+
when :context_candidates_log
|
|
219
|
+
next
|
|
220
|
+
else
|
|
221
|
+
raise ArgumentError, "no cleanup for session store #{store.inspect}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
purge_removal_stores([id], state_dir: state_dir) if purge_runs
|
|
225
|
+
removed
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The session-scoped logs are expensive to scan; callers removing a
|
|
229
|
+
# batch purge them once after the session files are gone.
|
|
230
|
+
def purge_removal_stores(ids, state_dir:)
|
|
231
|
+
return if ids.empty?
|
|
232
|
+
|
|
233
|
+
purge_session_runs(ids, state_dir)
|
|
234
|
+
ContextCandidatesLog.purge_sessions(ids, state_dir: state_dir)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The session's file and directory, removed while this process holds
|
|
238
|
+
# its owner lock (kind deleting, which a stop never signals), so no
|
|
239
|
+
# worker takes the session in the middle; the starting marker is read
|
|
240
|
+
# again under it (a resume that claimed the start since the caller's
|
|
241
|
+
# check keeps the session; one after it sees the lock held). Taken
|
|
242
|
+
# without waiting: one that took it since the check keeps the session.
|
|
243
|
+
# The lock file goes with the directory; a worker starting after that
|
|
244
|
+
# makes a new one, and finds no session (Session.load). The delete and
|
|
245
|
+
# the retention sweep and failed-continue cleanup use #remove_files.
|
|
246
|
+
# @return [Array<String>] the paths removed
|
|
247
|
+
# @raise [DeleteRefused] a worker took the lock, or a start was
|
|
248
|
+
# claimed, after the caller's check
|
|
249
|
+
def remove_session_files(id, path, dir, state_dir:)
|
|
250
|
+
lock = Dir.exist?(dir) ? OwnerLock.acquire(dir, kind: OwnerLock::DELETING, wait: 0) : nil
|
|
251
|
+
raise DeleteRefused.new(id, :worker_running) if Dir.exist?(dir) && lock.nil?
|
|
252
|
+
raise DeleteRefused.new(id, :worker_running) if StartingMarker.fresh?(id, state_dir: state_dir)
|
|
253
|
+
|
|
161
254
|
removed = []
|
|
162
255
|
if File.exist?(path)
|
|
163
256
|
FileUtils.rm_f(path)
|
|
@@ -167,21 +260,32 @@ module Samagotchi
|
|
|
167
260
|
FileUtils.rm_rf(dir)
|
|
168
261
|
removed << dir
|
|
169
262
|
end
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
263
|
+
removed
|
|
264
|
+
ensure
|
|
265
|
+
lock&.release
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Stop the worker that owns +id+ or is being started for it (the stop
|
|
269
|
+
# marker: one that takes the lock leaves at once), and wait for both to
|
|
270
|
+
# be gone.
|
|
271
|
+
# @return [Boolean] whether no owner and no start was left within +wait+
|
|
272
|
+
private def stop_held(id, state_dir:, wait:)
|
|
273
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait.to_f
|
|
274
|
+
return false unless stop_session(id, state_dir: state_dir, wait: wait)
|
|
275
|
+
|
|
276
|
+
while StartingMarker.fresh?(id, state_dir: state_dir) || session_owner(id, state_dir: state_dir)
|
|
277
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
278
|
+
|
|
279
|
+
sleep(OwnerLock::RETRY_INTERVAL)
|
|
176
280
|
end
|
|
177
|
-
|
|
281
|
+
true
|
|
178
282
|
end
|
|
179
283
|
|
|
180
284
|
# Never a reason a delete fails: one warn, and the sweep ages them out.
|
|
181
|
-
private def purge_session_runs(
|
|
182
|
-
SystemOne::Runs.
|
|
285
|
+
private def purge_session_runs(ids, state_dir)
|
|
286
|
+
SystemOne::Runs.purge_sessions(ids, sessions_dir: state_dir)
|
|
183
287
|
rescue StandardError => e
|
|
184
|
-
Log.warn(:worker, "session_runs_purge_failed", sid:
|
|
288
|
+
Log.warn(:worker, "session_runs_purge_failed", sid: ids.first, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
185
289
|
end
|
|
186
290
|
end
|
|
187
291
|
|