samagotchi 0.160.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 +86 -1
- data/docs/architecture.md +7 -3
- data/docs/cli.md +6 -4
- data/docs/context.md +10 -1
- data/docs/guardrails.md +12 -3
- data/docs/internals/bridge.md +21 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +4 -2
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +37 -5
- data/docs/sessions.md +14 -11
- 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 +92 -42
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +56 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +2 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/engine/events.rb +12 -0
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- 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/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
- 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/installer.rb +79 -12
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/status.rb +1 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
- data/lib/samagotchi/memory_orphans.rb +40 -9
- 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/project_init.rb +3 -3
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +286 -48
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +42 -5
- data/lib/samagotchi/session_access/local.rb +45 -17
- data/lib/samagotchi/session_access/remote.rb +11 -4
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +16 -3
- data/lib/samagotchi/session_continue.rb +155 -21
- 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 +164 -104
- 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 +18 -7
- 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/delegate.rb +9 -4
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +7 -2
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- 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 +12 -8
- 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 +45 -7
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +41 -8
- 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 +5 -1
|
@@ -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
|
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../worker_link"
|
|
4
|
+
require_relative "../starting_marker"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class SessionManager
|
|
7
|
-
# How long
|
|
8
|
-
#
|
|
8
|
+
# How long a worker's starter waits for it to take the session
|
|
9
|
+
# (WorkerBoot.detach_from_spawner): the starting marker naming the
|
|
10
|
+
# starter holds that long at most.
|
|
9
11
|
WORKER_START_GRACE = WorkerLink::START_LIMIT
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
# The starters this process spawned (their Process.detach waiters), for
|
|
14
|
+
# chi web's restart to adopt (#live_spawn_pids). Decides nothing: the
|
|
15
|
+
# starting marker is what holds a second spawn off.
|
|
16
|
+
@starters = []
|
|
17
|
+
@starters_mutex = Mutex.new
|
|
13
18
|
|
|
14
19
|
# Spawn: deciding to start a session's worker (a new session, a resume, a
|
|
15
|
-
# delegate's report waking its parent) and the
|
|
16
|
-
#
|
|
20
|
+
# delegate's report waking its parent) and the starters this process
|
|
21
|
+
# spawned. Calls WorkerCommandPart's spawn_options and worker_command and
|
|
17
22
|
# the facade's ownership checks. Meant to be loaded through samagotchi/session_manager.
|
|
18
23
|
module SpawnPart
|
|
19
24
|
# Spawn a new background session that processes the given prompt (or,
|
|
@@ -22,9 +27,6 @@ module Samagotchi
|
|
|
22
27
|
# Returns the session object with its ID. Every worker always starts its
|
|
23
28
|
# per-session Bridge (the single live client transport), so external
|
|
24
29
|
# clients can reach it once the sidecar is published.
|
|
25
|
-
# @param memories [Array<String>] --memory: preloaded into the worker's prompt
|
|
26
|
-
# @param muted_memories [Array<String>] --mute: hidden from the session
|
|
27
|
-
# (both are session fields, so a respawn keeps them)
|
|
28
30
|
# @param parent_id [String, nil] the session that delegates this one (the
|
|
29
31
|
# `delegate` tool) or was forked from; a session field too
|
|
30
32
|
# @param messages [Array<Hash>] a conversation to start from (a plugin's
|
|
@@ -35,9 +37,9 @@ module Samagotchi
|
|
|
35
37
|
# @param title [String, nil] the session's own name (Session#title; a
|
|
36
38
|
# continue's link inherits its previous link's), saved before the
|
|
37
39
|
# worker starts: the worker owns the file from then on
|
|
38
|
-
# @param setup [SessionSetup] the session's own settings (chi
|
|
39
|
-
# --llm-context; a fork and a continue copy their session's;
|
|
40
|
-
# child the toolkit its delegate call named); without a toolkit the
|
|
40
|
+
# @param setup [SessionSetup] the session's own settings (chi --memory,
|
|
41
|
+
# --mute, --llm-context; a fork and a continue copy their session's;
|
|
42
|
+
# a delegate child the memory and toolkit its delegate call named); without a toolkit the
|
|
41
43
|
# session is stored on config's toolkit.default, a delegate child on
|
|
42
44
|
# toolkit.child (Toolkit.configured)
|
|
43
45
|
# @param continues [String, nil] the session this one continues (a
|
|
@@ -54,17 +56,20 @@ module Samagotchi
|
|
|
54
56
|
# @param scratch [Boolean] a `chi scratch` session (its terminal's
|
|
55
57
|
# AttachLauncher): its worker runs a scratch Engine and deletes it
|
|
56
58
|
# once its last client is gone
|
|
57
|
-
# @param client_id [String, nil] who sent +prompt+
|
|
58
|
-
#
|
|
59
|
-
#
|
|
59
|
+
# @param client_id [String, nil] who sent +prompt+ (a ClientId); required
|
|
60
|
+
# even when nil is intentional for a session with no initial prompt
|
|
61
|
+
# @param session_id [String, nil] an id reserved by a durable operation
|
|
60
62
|
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
61
63
|
# and #model_warning: an id its host's saved list doesn't have
|
|
62
|
-
def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
client_id: nil)
|
|
64
|
+
def spawn_session(prompt:, client_id:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
65
|
+
parent_id: nil, messages: [], images_from: nil, preview: nil, title: nil, delegate: false,
|
|
66
|
+
setup: SessionSetup.new, continues: nil, note: nil, note_source: "chi", before_spawn: nil,
|
|
67
|
+
review: nil, verify_fresh: nil, scratch: false, session_id: nil)
|
|
67
68
|
sd = state_dir || Session.default_state_dir
|
|
69
|
+
if session_id && (!Session.valid_id?(session_id) || Session.exist?(session_id, state_dir: sd))
|
|
70
|
+
raise ArgumentError, "invalid or existing session id #{session_id.inspect}"
|
|
71
|
+
end
|
|
72
|
+
|
|
68
73
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
69
74
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
70
75
|
# refuses here, before anything is saved or spawned; a model id the
|
|
@@ -79,8 +84,6 @@ module Samagotchi
|
|
|
79
84
|
model_name: ref,
|
|
80
85
|
model_typed: typed == ref ? nil : typed,
|
|
81
86
|
working_directory: working_directory || Dir.pwd,
|
|
82
|
-
preloaded_memory_names: memories,
|
|
83
|
-
muted_memory_names: muted_memories,
|
|
84
87
|
parent_id: parent_id,
|
|
85
88
|
messages: messages,
|
|
86
89
|
delegate: delegate,
|
|
@@ -89,6 +92,7 @@ module Samagotchi
|
|
|
89
92
|
review: review,
|
|
90
93
|
scratch: scratch
|
|
91
94
|
)
|
|
95
|
+
session.id = session_id if session_id
|
|
92
96
|
session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
|
|
93
97
|
session.toolkit_origin = setup.toolkit_origin || "default"
|
|
94
98
|
# Saved before the worker starts: its first turn is already the requester's.
|
|
@@ -104,93 +108,115 @@ module Samagotchi
|
|
|
104
108
|
# carry the id.
|
|
105
109
|
unless prompt.to_s.strip.empty?
|
|
106
110
|
session.first_turn_id = SecureRandom.uuid
|
|
107
|
-
session.first_turn_client_id = client_id
|
|
111
|
+
session.first_turn_client_id = client_id unless ClientId.human?(client_id)
|
|
108
112
|
end
|
|
109
113
|
# The worker takes last_prompt and clears it, and messages are saved at
|
|
110
114
|
# the turn's end: until then this is the only preview a list has.
|
|
111
115
|
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
|
112
116
|
session.title = title
|
|
113
117
|
session_dir = Session.session_dir(session.id, state_dir: sd)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
# Starting before its file exists: the file is never there unowned and
|
|
119
|
+
# unmarked (a sweep would take a scratch session from under its
|
|
120
|
+
# worker). spawn_worker_for_session hands it to the starter.
|
|
121
|
+
StartingMarker.mark(session.id, state_dir: sd)
|
|
122
|
+
begin
|
|
123
|
+
unless session.messages.empty?
|
|
124
|
+
session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
|
|
125
|
+
session.seed_images_dropped = dropped
|
|
126
|
+
end
|
|
127
|
+
setup_session_directory(session_dir, session, state_dir: sd)
|
|
128
|
+
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
129
|
+
before_spawn&.call(session)
|
|
130
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
# Nothing is starting: a caller's cleanup (a continue's failed link) may delete it.
|
|
133
|
+
StartingMarker.clear(session.id, state_dir: sd)
|
|
134
|
+
raise
|
|
117
135
|
end
|
|
118
|
-
setup_session_directory(session_dir, session, state_dir: sd)
|
|
119
|
-
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
120
|
-
before_spawn&.call(session)
|
|
121
|
-
spawn_worker_for_session(session, state_dir: sd)
|
|
122
136
|
session
|
|
123
137
|
end
|
|
124
138
|
|
|
125
139
|
# Ensure an existing session has a live worker process.
|
|
126
140
|
# Returns the loaded session after state reconciliation.
|
|
127
141
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
142
|
+
# @yieldparam outcome [Symbol] what it did, when a block is given:
|
|
143
|
+
# :woken (it spawned the worker), :live (a worker holds the session,
|
|
144
|
+
# or took it since the first read) or :starting (another start is
|
|
145
|
+
# pending, or won the claim)
|
|
128
146
|
def resume_session(session_id, state_dir: nil)
|
|
147
|
+
session, outcome = resume(session_id, state_dir: state_dir)
|
|
148
|
+
yield outcome if block_given?
|
|
149
|
+
session
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# resume_session's work, with its outcome.
|
|
153
|
+
# @return [Array(Session, Symbol)]
|
|
154
|
+
private def resume(session_id, state_dir:)
|
|
129
155
|
sd = state_dir || Session.default_state_dir
|
|
130
156
|
session = Session.load(session_id, state_dir: sd)
|
|
131
157
|
# Its terminal's worker came from spawn_session; nothing wakes another.
|
|
132
158
|
raise ScratchSession, session.id if session.scratch
|
|
133
|
-
return session if refuse_tui!(session.id, state_dir: sd)
|
|
134
159
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# One this process just spawned may not hold the lock yet (a slow
|
|
139
|
-
# start, deliver_turn's second resume): it would only lose it.
|
|
140
|
-
return session if still_starting?(session.id, state_dir: sd)
|
|
141
|
-
# A continue moving delegates to it spawns its worker once they are
|
|
142
|
-
# moved; one spawned now (a moved child's ring) would race the move.
|
|
143
|
-
return session if StartingMarker.fresh?(session.id, state_dir: sd)
|
|
160
|
+
presence = WorkerPresence.read(session.id, state_dir: sd, owner: session_owner(session.id, state_dir: sd))
|
|
161
|
+
raise OwnedByTUI, session.id if presence.tui_owned?
|
|
162
|
+
return [session, :live] if presence.owned?
|
|
144
163
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
session
|
|
150
|
-
end
|
|
164
|
+
# A worker being started for it, by this process or another (chi web,
|
|
165
|
+
# a `chi send`, a child's ring; a continue moving delegates to its new
|
|
166
|
+
# link), may not hold the lock yet: a second one would only lose it,
|
|
167
|
+
# or race the move (#resume_held_off?).
|
|
168
|
+
return [session, :starting] if presence.resume_held_off?
|
|
151
169
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
claimed = claim_start(session.id, state_dir: sd, stopped: presence.stop_marked?)
|
|
171
|
+
return [session, claimed] unless claimed == :claimed
|
|
172
|
+
|
|
173
|
+
# The new worker must not find the session stopped (it would exit).
|
|
174
|
+
# Nothing else is written: the file is the worker's, which turns a
|
|
175
|
+
# status from before it idle under the lock (Worker#settle_stale_status).
|
|
176
|
+
begin
|
|
177
|
+
Session.clear_stopped(session.id, state_dir: sd)
|
|
178
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
179
|
+
rescue StandardError
|
|
180
|
+
# Its claim holds nothing now (a command or the options failed
|
|
181
|
+
# before Process.spawn): no 60 s hold-off.
|
|
182
|
+
StartingMarker.clear(session.id, state_dir: sd)
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
[session, :woken]
|
|
167
186
|
end
|
|
168
187
|
|
|
169
|
-
# A
|
|
170
|
-
#
|
|
188
|
+
# A ring waits in a parent's children/ (a failed continue's previous
|
|
189
|
+
# link got rings back): wake it as the ring's own delivery would
|
|
190
|
+
# (SessionInbox.wake :ring, its WakePolicy). Not for a parent that is
|
|
191
|
+
# gone, stopped (the stop marker; the file's status never decides),
|
|
192
|
+
# archived, a scratch session, a one-turn run's or owned, or a
|
|
193
|
+
# continue's new link still taking its delegates (its own spawn
|
|
194
|
+
# comes): the ring waits on disk for whoever runs it next. A stop
|
|
195
|
+
# landing between the check and the resume is a small window, left as
|
|
196
|
+
# is. An archived parent with a later link wakes that link (one hop:
|
|
197
|
+
# ChildReports reads its predecessor's rings, no further).
|
|
171
198
|
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
172
199
|
# :scratch, :owned, :starting)
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
|
|
200
|
+
def wake_for_report(session_id, state_dir: nil)
|
|
201
|
+
sd = state_dir || Session.default_state_dir
|
|
202
|
+
outcome = SessionInbox.wake(:ring, session_id, state_dir: sd, owner: ->(id) { session_owner(id, state_dir: sd) }).wake
|
|
203
|
+
outcome == :live ? :owned : outcome
|
|
176
204
|
end
|
|
177
205
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
206
|
+
# Start a worker for a delivery whose WakePolicy decided :start
|
|
207
|
+
# (SessionInbox.deliver): resume_session, which keeps its own checks
|
|
208
|
+
# (a claim on the starting marker: of starts racing, one spawns),
|
|
209
|
+
# logged as +event+.
|
|
210
|
+
# @param event [Symbol] :delegate_woke_parent (a ring) or :context_woke_session (a push)
|
|
211
|
+
# @return [Symbol] :woken (this start spawned it), :starting (another
|
|
212
|
+
# start won the claim, or is pending), :live (a worker took it since)
|
|
213
|
+
# or :owned (a one-turn chi run or its terminal's scratch session did)
|
|
214
|
+
def start_for_inbox(session_id, state_dir:, event:)
|
|
215
|
+
# A stand-in resume_session that yields nothing counts as a start.
|
|
216
|
+
outcome = :woken
|
|
217
|
+
resume_session(session_id, state_dir: state_dir) { |done| outcome = done }
|
|
218
|
+
return outcome unless outcome == :woken
|
|
188
219
|
|
|
189
|
-
session = Session.load(session_id, state_dir: state_dir)
|
|
190
|
-
return :stopped if session.status == Session::STATUS_STOPPED
|
|
191
|
-
return :scratch if session.scratch
|
|
192
|
-
|
|
193
|
-
resume_session(session_id, state_dir: state_dir)
|
|
194
220
|
details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
|
|
195
221
|
Log.info(:worker, event, **details)
|
|
196
222
|
:woken
|
|
@@ -198,37 +224,46 @@ module Samagotchi
|
|
|
198
224
|
:owned
|
|
199
225
|
end
|
|
200
226
|
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
227
|
+
# Take the start for resume_session: the marker claimed (of resumes
|
|
228
|
+
# racing from any processes, one claims it), then the owner read
|
|
229
|
+
# again: a worker that took the lock since the first read cleared an
|
|
230
|
+
# earlier start's marker, and keeps the session. After a stop, a start
|
|
231
|
+
# marked before it is taken over (a stop cancels the hold-off), one a
|
|
232
|
+
# resume made after it holds (two sends after a stop start one worker).
|
|
233
|
+
# @return [Symbol] :claimed (this resume spawns), :starting (another
|
|
234
|
+
# start holds the claim) or :live (a worker took the lock since)
|
|
235
|
+
private def claim_start(session_id, state_dir:, stopped:)
|
|
236
|
+
over_before = stopped ? stopped_at(session_id, state_dir) : nil
|
|
237
|
+
return :starting unless StartingMarker.claim(session_id, state_dir: state_dir, over_before: over_before)
|
|
238
|
+
return :claimed unless session_owner(session_id, state_dir: state_dir)
|
|
208
239
|
|
|
209
|
-
|
|
240
|
+
StartingMarker.clear(session_id, state_dir: state_dir)
|
|
241
|
+
:live
|
|
210
242
|
end
|
|
211
243
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
244
|
+
# When the stop marker was written (Session.stopped_at); nil when it
|
|
245
|
+
# is gone (another resume cleared it: its start came after the stop,
|
|
246
|
+
# and holds).
|
|
247
|
+
private def stopped_at(session_id, state_dir) = Session.stopped_at(session_id, state_dir: state_dir)
|
|
248
|
+
|
|
249
|
+
private def note_starter(waiter)
|
|
250
|
+
@starters_mutex.synchronize do
|
|
251
|
+
@starters.select! { |w| w.respond_to?(:alive?) && w.alive? }
|
|
252
|
+
@starters << waiter
|
|
216
253
|
end
|
|
217
254
|
end
|
|
218
255
|
|
|
219
|
-
# Forget the
|
|
256
|
+
# Forget the starters this process spawned (specs).
|
|
220
257
|
def forget_spawns
|
|
221
|
-
@
|
|
258
|
+
@starters_mutex.synchronize { @starters.clear }
|
|
222
259
|
end
|
|
223
260
|
|
|
224
261
|
# Starter processes are children of chi web, unlike the detached workers
|
|
225
262
|
# they launch; the web restart adopts these pids before replacing itself.
|
|
226
263
|
def live_spawn_pids
|
|
227
|
-
@
|
|
228
|
-
@
|
|
229
|
-
@
|
|
230
|
-
waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
|
|
231
|
-
end.uniq.sort
|
|
264
|
+
@starters_mutex.synchronize do
|
|
265
|
+
@starters.select! { |waiter| waiter.respond_to?(:alive?) && waiter.alive? }
|
|
266
|
+
@starters.filter_map { |waiter| waiter.pid if waiter.respond_to?(:pid) }.uniq.sort
|
|
232
267
|
end
|
|
233
268
|
end
|
|
234
269
|
|
|
@@ -248,14 +283,39 @@ module Samagotchi
|
|
|
248
283
|
opts = spawn_options(session)
|
|
249
284
|
env = opts.delete(:env)
|
|
250
285
|
command = worker_command(session.id, state_dir: state_dir)
|
|
251
|
-
|
|
252
|
-
#
|
|
286
|
+
# Starting from here (WorkerPresence#starting?), for as long as this
|
|
287
|
+
# process runs, until the spawn below names its starter.
|
|
288
|
+
StartingMarker.mark(session.id, state_dir: state_dir)
|
|
289
|
+
begin
|
|
290
|
+
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
291
|
+
rescue StandardError
|
|
292
|
+
StartingMarker.clear(session.id, state_dir: state_dir)
|
|
293
|
+
raise
|
|
294
|
+
end
|
|
253
295
|
# +pid+ is the worker's starter, which exits once the worker it forked
|
|
254
|
-
# holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is
|
|
255
|
-
|
|
296
|
+
# holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is
|
|
297
|
+
# the worker's, and the marker holds exactly while the starter runs
|
|
298
|
+
# (most spawners exit right after this). The worker removes it once it
|
|
299
|
+
# holds the lock (Worker#start).
|
|
300
|
+
if starter_running?(pid)
|
|
301
|
+
StartingMarker.mark(session.id, state_dir: state_dir, pid: pid)
|
|
302
|
+
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
303
|
+
note_starter(Process.detach(pid))
|
|
304
|
+
else
|
|
305
|
+
# It failed to exec or boot: nothing is starting.
|
|
306
|
+
StartingMarker.clear(session.id, state_dir: state_dir)
|
|
307
|
+
end
|
|
256
308
|
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
257
309
|
pid
|
|
258
310
|
end
|
|
311
|
+
|
|
312
|
+
# Whether the starter just spawned still runs (reaped here if it
|
|
313
|
+
# already exited). Not a child of this process: nothing to wait for.
|
|
314
|
+
private def starter_running?(pid)
|
|
315
|
+
Process.waitpid(pid, Process::WNOHANG).nil?
|
|
316
|
+
rescue Errno::ECHILD
|
|
317
|
+
false
|
|
318
|
+
end
|
|
259
319
|
end
|
|
260
320
|
|
|
261
321
|
extend SpawnPart
|