samagotchi 0.46.1 → 0.48.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 +51 -1
- data/docs/cli.md +1 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +26 -6
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +235 -0
- data/lib/samagotchi/child_reports.rb +32 -8
- data/lib/samagotchi/send_command.rb +64 -9
- data/lib/samagotchi/session.rb +48 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_manager.rb +314 -25
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +31 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +50 -0
- data/lib/samagotchi/web/public/app.js +201 -18
- data/lib/samagotchi/web/public/chains.js +247 -0
- data/lib/samagotchi/web/public/data.js +12 -0
- data/lib/samagotchi/web/public/families_view.js +3 -2
- data/lib/samagotchi/web/public/index.html +29 -6
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/sessions_list.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +7 -3
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +12 -1
- metadata +4 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "recap_store"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Session chains: a session that continues an earlier one (Session#continues),
|
|
9
|
+
# "the next day of the same routine". The chain is derived, never stored:
|
|
10
|
+
# follow +continues+ back for the earlier links, forward (the session whose
|
|
11
|
+
# +continues+ names this one) for the later ones. A link is continued at
|
|
12
|
+
# most once (SessionManager.continue_session refuses a second), so the
|
|
13
|
+
# chain's latest link is the one nobody continues.
|
|
14
|
+
module SessionChain
|
|
15
|
+
# `--continues last:<id>`: the latest link of the chain <id> is in.
|
|
16
|
+
LAST_PREFIX = "last:"
|
|
17
|
+
# Who the carry note is from (ContextNote's label: "note from session chain").
|
|
18
|
+
NOTE_SOURCE = "session chain"
|
|
19
|
+
# The most of the previous link's recap the carry note takes; a recap is
|
|
20
|
+
# a few sentences, this only keeps a runaway one under the note's 16 KiB.
|
|
21
|
+
RECAP_CHARS = 8000
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The session that continues +id+, else nil. Archived ones count: an
|
|
26
|
+
# archived link is still a link.
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
def next_of(id, state_dir:)
|
|
29
|
+
Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
|
|
30
|
+
.find { |s| s.continues == id }&.id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The chain's latest link from +id+ on: +id+ itself when nobody
|
|
34
|
+
# continues it. One listing, walked forward (a loop a hand-edited file
|
|
35
|
+
# could make ends where it started).
|
|
36
|
+
# @return [String]
|
|
37
|
+
def latest(id, state_dir:)
|
|
38
|
+
nexts = {}
|
|
39
|
+
Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true).each do |s|
|
|
40
|
+
nexts[s.continues] ||= s.id if s.continues
|
|
41
|
+
end
|
|
42
|
+
seen = [id]
|
|
43
|
+
while (following = nexts[seen.last]) && !seen.include?(following)
|
|
44
|
+
seen << following
|
|
45
|
+
end
|
|
46
|
+
seen.last
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A session to continue as the CLI and the web name it: an id, a unique
|
|
50
|
+
# prefix, or `last:<id or prefix>` for that chain's latest link.
|
|
51
|
+
# @return [String] the full id of an existing session
|
|
52
|
+
# @raise [ArgumentError] none such (Session::AmbiguousId for a prefix of several)
|
|
53
|
+
def resolve(ref, state_dir:)
|
|
54
|
+
given = ref.to_s.strip
|
|
55
|
+
last = given.start_with?(LAST_PREFIX)
|
|
56
|
+
given = given.delete_prefix(LAST_PREFIX) if last
|
|
57
|
+
raise ArgumentError, "no session #{ref}" unless Session.valid_id?(given)
|
|
58
|
+
|
|
59
|
+
id = Session.resolve_id(given, state_dir: state_dir)
|
|
60
|
+
raise ArgumentError, "no session #{ref}" unless Session.exist?(id, state_dir: state_dir)
|
|
61
|
+
|
|
62
|
+
last ? latest(id, state_dir: state_dir) : id
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The context note a new link starts with: which session it continues,
|
|
66
|
+
# from when, and that session's recap with its time, when it has one.
|
|
67
|
+
# Never more than the recap: no transcript.
|
|
68
|
+
# @param previous [Session]
|
|
69
|
+
# @param recap [Hash, nil] RecapStore.read's
|
|
70
|
+
# @return [String]
|
|
71
|
+
def note_text(previous, recap:)
|
|
72
|
+
head = "This session continues #{previous.id[0, 8]} (#{day(previous.created_at)}), the previous link of its chain."
|
|
73
|
+
text = recap && recap[:text].to_s.strip
|
|
74
|
+
return "#{head} It left no recap." if text.nil? || text.empty?
|
|
75
|
+
|
|
76
|
+
text = "#{text[0, RECAP_CHARS - 1]}…" if text.length > RECAP_CHARS
|
|
77
|
+
as_of = clock(recap[:created_at])
|
|
78
|
+
"#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def day(iso)
|
|
82
|
+
Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
|
|
83
|
+
rescue ArgumentError
|
|
84
|
+
iso.to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def clock(iso)
|
|
88
|
+
iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
89
|
+
rescue ArgumentError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -30,6 +30,8 @@ require_relative "continue_offer"
|
|
|
30
30
|
require_relative "image_store"
|
|
31
31
|
require_relative "plugin_session_state"
|
|
32
32
|
require_relative "context_sources"
|
|
33
|
+
require_relative "session_chain"
|
|
34
|
+
require_relative "child_move"
|
|
33
35
|
require_relative "terminal_ui"
|
|
34
36
|
|
|
35
37
|
module Samagotchi
|
|
@@ -115,6 +117,35 @@ module Samagotchi
|
|
|
115
117
|
end
|
|
116
118
|
end
|
|
117
119
|
|
|
120
|
+
# A continue that can't happen (#continue_session). reason: :continued
|
|
121
|
+
# (another session continues it already: +ids+ names it), :open_children
|
|
122
|
+
# (open children it can't move to the new link: on an older chi's
|
|
123
|
+
# worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
|
|
124
|
+
# folder isn't there).
|
|
125
|
+
class ContinueRefused < StandardError
|
|
126
|
+
attr_reader :session_id, :reason, :ids
|
|
127
|
+
|
|
128
|
+
def initialize(session_id, reason, ids: [], detail: nil)
|
|
129
|
+
@session_id = session_id
|
|
130
|
+
@reason = reason
|
|
131
|
+
@ids = ids
|
|
132
|
+
super(continue_refused_message(detail))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def continue_refused_message(detail)
|
|
138
|
+
short = @session_id[0, 8]
|
|
139
|
+
case @reason
|
|
140
|
+
when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
|
|
141
|
+
when :open_children
|
|
142
|
+
"#{short} has delegates still open that can't move to the next link: #{detail}; " \
|
|
143
|
+
"then continue (or wait for them, stop them or archive them)"
|
|
144
|
+
else "#{short}'s folder #{detail} is gone"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
118
149
|
# Spawn a new background session that processes the given prompt (or,
|
|
119
150
|
# with none, waits idle for input).
|
|
120
151
|
#
|
|
@@ -133,11 +164,19 @@ module Samagotchi
|
|
|
133
164
|
# (the prompt's preview by default, else the seed's first user message)
|
|
134
165
|
# @param llm_context [LLMContextOverride, nil] the session's own
|
|
135
166
|
# llm_context values (chi --llm-context; a fork copies its parent's)
|
|
167
|
+
# @param continues [String, nil] the session this one continues (a
|
|
168
|
+
# session field; #continue_session)
|
|
169
|
+
# @param note [String, nil] a context note the session starts with,
|
|
170
|
+
# from +note_source+: queued before its worker spawns, so its first
|
|
171
|
+
# turn sees it
|
|
172
|
+
# @param before_spawn [#call, nil] (session) runs once the session and
|
|
173
|
+
# its note are saved, before its worker spawns
|
|
136
174
|
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
137
175
|
# and #model_warning: an id its host's saved list doesn't have
|
|
138
176
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
139
177
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
140
|
-
title: nil, delegate: false, llm_context: nil
|
|
178
|
+
title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
|
|
179
|
+
note_source: "chi", before_spawn: nil)
|
|
141
180
|
sd = state_dir || Session.default_state_dir
|
|
142
181
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
143
182
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
@@ -158,7 +197,8 @@ module Samagotchi
|
|
|
158
197
|
parent_id: parent_id,
|
|
159
198
|
messages: messages,
|
|
160
199
|
delegate: delegate,
|
|
161
|
-
llm_context: llm_context
|
|
200
|
+
llm_context: llm_context,
|
|
201
|
+
continues: continues
|
|
162
202
|
)
|
|
163
203
|
session.model_warning = model_warning
|
|
164
204
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
@@ -174,10 +214,234 @@ module Samagotchi
|
|
|
174
214
|
session.seed_images_dropped = dropped
|
|
175
215
|
end
|
|
176
216
|
setup_session_directory(session_dir, session, state_dir: sd)
|
|
217
|
+
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
218
|
+
before_spawn&.call(session)
|
|
177
219
|
spawn_worker_for_session(session, state_dir: sd)
|
|
178
220
|
session
|
|
179
221
|
end
|
|
180
222
|
|
|
223
|
+
# How long #continue_session waits for the previous link's worker to
|
|
224
|
+
# write its recap (IdleRecap's own request timeout).
|
|
225
|
+
CONTINUE_RECAP_WAIT = 30.0
|
|
226
|
+
# In the previous link's folder: one continue of a session at a time.
|
|
227
|
+
CONTINUE_LOCK = "continue.lock"
|
|
228
|
+
# How often a last:<id> continue looks again when another continue
|
|
229
|
+
# moved the chain's end while it waited for the lock.
|
|
230
|
+
CONTINUE_TRIES = 3
|
|
231
|
+
|
|
232
|
+
# Start the next link of a chain: a new session that continues
|
|
233
|
+
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
234
|
+
# it was typed as, so an alias holds) and with its own llm_context,
|
|
235
|
+
# starting with a context note from chi: the link and the previous
|
|
236
|
+
# link's recap (SessionChain.note_text). The previous link is archived
|
|
237
|
+
# first, with its finished delegates (archive_session's rules: a turn
|
|
238
|
+
# running, a prompt queued or the step-limit question refuse; an idle
|
|
239
|
+
# worker is stopped), so a refusal starts nothing. Its open delegates
|
|
240
|
+
# (running, waiting, live, an unreported reply, at any depth below them)
|
|
241
|
+
# move to the new link instead (ChildMove): their reports reach it.
|
|
242
|
+
# Before that its live worker is asked for a recap and given up to
|
|
243
|
+
# +recap_wait+ seconds to write it.
|
|
244
|
+
# One continue of a session at a time (a lock in its folder): a double
|
|
245
|
+
# click finds the first one's link and is refused; a last:<id> one
|
|
246
|
+
# follows the chain to its new end instead.
|
|
247
|
+
# @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
|
|
248
|
+
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
249
|
+
# @param title [String, nil] what the lists show before the first turn;
|
|
250
|
+
# by default the prompt, else the previous link's preview
|
|
251
|
+
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
252
|
+
# #moved_children: the delegates it took over)
|
|
253
|
+
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
254
|
+
# @raise [ContinueRefused] continued already, delegates open that can't
|
|
255
|
+
# move, or its folder is gone
|
|
256
|
+
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
257
|
+
def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
|
|
258
|
+
archive_wait: 5)
|
|
259
|
+
sd = state_dir || Session.default_state_dir
|
|
260
|
+
CONTINUE_TRIES.times do |try|
|
|
261
|
+
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
262
|
+
result = with_continue_lock(id, sd) do
|
|
263
|
+
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
|
|
264
|
+
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
265
|
+
|
|
266
|
+
continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
267
|
+
archive_wait: archive_wait)
|
|
268
|
+
end
|
|
269
|
+
return result unless result == :moved
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private_class_method def self.with_continue_lock(id, state_dir, &)
|
|
274
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
275
|
+
FileUtils.mkdir_p(dir)
|
|
276
|
+
File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
277
|
+
lock.flock(File::LOCK_EX)
|
|
278
|
+
yield
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
283
|
+
previous = Session.load(id, state_dir: state_dir)
|
|
284
|
+
plan = check_continue!(previous, state_dir)
|
|
285
|
+
# archive_session's refusals that can be known now, before the wait.
|
|
286
|
+
archivable!(id, state_dir, except: plan.move)
|
|
287
|
+
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
288
|
+
# Again: a turn sent to it during the wait may have delegated (they
|
|
289
|
+
# move too), or a delegate finished.
|
|
290
|
+
plan = move_plan!(id, state_dir)
|
|
291
|
+
already = [id, *descendant_ids(id, state_dir, except: plan.move)].select do |sid|
|
|
292
|
+
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
293
|
+
end
|
|
294
|
+
archived = archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)[:archived] - already
|
|
295
|
+
begin
|
|
296
|
+
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
|
|
297
|
+
rescue StandardError
|
|
298
|
+
# Nothing started: the delegates go back, what this archived goes
|
|
299
|
+
# back to the lists, and a new link saved before its worker failed
|
|
300
|
+
# to spawn goes (left, it would hold the chain: "continued already",
|
|
301
|
+
# last: to it).
|
|
302
|
+
undo_move(id, state_dir, moved: plan.move)
|
|
303
|
+
begin
|
|
304
|
+
discard_failed_link(id, state_dir)
|
|
305
|
+
ensure
|
|
306
|
+
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
307
|
+
wake_for_rings(id, state_dir) unless plan.move.empty?
|
|
308
|
+
end
|
|
309
|
+
raise
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The delegates a failed #continue_session moved, or began to (its
|
|
314
|
+
# intent names them), go back to +id+.
|
|
315
|
+
private_class_method def self.undo_move(id, state_dir, moved:)
|
|
316
|
+
link = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
317
|
+
ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
|
|
318
|
+
ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
|
|
319
|
+
ChildMove.clear_intent(link, state_dir: state_dir)
|
|
320
|
+
rescue StandardError => e
|
|
321
|
+
Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The link a failed #continue_session saved (no other continues +id+:
|
|
325
|
+
# check_continue! made sure under the lock): its worker stopped if one
|
|
326
|
+
# started, then the rings a moved delegate wrote into it just before
|
|
327
|
+
# the undo go back to +id+, then the link goes. A worker that outlives
|
|
328
|
+
# the stop is raised (DeleteRefused): the link stays, and the caller
|
|
329
|
+
# hears it.
|
|
330
|
+
private_class_method def self.discard_failed_link(id, state_dir)
|
|
331
|
+
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
332
|
+
if refuse_tui!(orphan, state_dir: state_dir) && !stop_session(orphan, state_dir: state_dir, wait: 10)
|
|
333
|
+
raise DeleteRefused.new(orphan, :still_stopping)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
|
|
337
|
+
delete_session(orphan, state_dir: state_dir)
|
|
338
|
+
rescue DeleteRefused
|
|
339
|
+
raise
|
|
340
|
+
rescue StandardError => e
|
|
341
|
+
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# A failed continue's previous link, back in the lists, with rings that
|
|
345
|
+
# came in meanwhile: wake it for them, as the ring's own wake would have.
|
|
346
|
+
private_class_method def self.wake_for_rings(id, state_dir)
|
|
347
|
+
return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
|
|
348
|
+
|
|
349
|
+
wake_for_report(id, state_dir: state_dir)
|
|
350
|
+
rescue StandardError => e
|
|
351
|
+
Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
355
|
+
# while +move+ goes to it.
|
|
356
|
+
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
|
|
357
|
+
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
358
|
+
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
359
|
+
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
360
|
+
before_spawn = lambda do |link|
|
|
361
|
+
ChildMove.mark_starting(link.id, state_dir: state_dir)
|
|
362
|
+
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
363
|
+
end
|
|
364
|
+
link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
|
|
365
|
+
model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
|
|
366
|
+
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
367
|
+
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
|
|
368
|
+
link.moved_children = move
|
|
369
|
+
link
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The refusals that come before anything changes: continued already (no
|
|
373
|
+
# forks in a chain), an open delegate that can't move to the new link
|
|
374
|
+
# (ChildMove.plan), the folder gone (the worker would run elsewhere), a
|
|
375
|
+
# model whose host config.yml no longer has.
|
|
376
|
+
# @return [ChildMove::Plan]
|
|
377
|
+
# @raise [ContinueRefused, ModelProfile::MissingModel]
|
|
378
|
+
private_class_method def self.check_continue!(previous, state_dir)
|
|
379
|
+
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
380
|
+
raise ContinueRefused.new(previous.id, :continued, ids: [following])
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
plan = move_plan!(previous.id, state_dir)
|
|
384
|
+
# Its model's host, as spawn_session checks it: before the archive
|
|
385
|
+
# stops the previous link's worker.
|
|
386
|
+
ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
|
|
387
|
+
dir = previous.working_directory.to_s
|
|
388
|
+
raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
389
|
+
|
|
390
|
+
plan
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# A child still open below a session (#open_children); why: running,
|
|
394
|
+
# waiting, live, unreported reply, or why it can't move (ChildMove).
|
|
395
|
+
OpenChild = Data.define(:id, :short_id, :why)
|
|
396
|
+
|
|
397
|
+
# The delegates a continue of +id+ moves (ChildMove.plan).
|
|
398
|
+
# @return [ChildMove::Plan]
|
|
399
|
+
# @raise [ContinueRefused] for open ones it can't move
|
|
400
|
+
private_class_method def self.move_plan!(id, state_dir)
|
|
401
|
+
plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
|
|
402
|
+
return plan if plan.refuse.empty?
|
|
403
|
+
|
|
404
|
+
raise ContinueRefused.new(id, :open_children, ids: plan.refuse.map(&:id),
|
|
405
|
+
detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# The unarchived children, at any depth (the archive's cascade), of the
|
|
409
|
+
# session still running, waiting for an answer, with a live worker, or
|
|
410
|
+
# (a delegate) with a reply its parent wasn't given (ChildrenStatus).
|
|
411
|
+
# @return [Array<OpenChild>]
|
|
412
|
+
def self.open_children(id, state_dir)
|
|
413
|
+
require_relative "children_status"
|
|
414
|
+
[id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
|
|
415
|
+
.filter_map do |child|
|
|
416
|
+
why = if %w[waiting running].include?(child.state) then child.state
|
|
417
|
+
elsif child.live then "live"
|
|
418
|
+
elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
|
|
419
|
+
end
|
|
420
|
+
why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Ask the session's live worker for a recap now (Bridge POST /recap:
|
|
425
|
+
# IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
|
|
426
|
+
# to change. A worker that is gone wrote its recap when it left; one
|
|
427
|
+
# that has nothing new to say, too short a conversation, or recaps off
|
|
428
|
+
# answers at once. Best effort: the continue goes on without.
|
|
429
|
+
private_class_method def self.recap_before_archive(id, state_dir, wait:)
|
|
430
|
+
return unless worker_live?(id, state_dir: state_dir)
|
|
431
|
+
|
|
432
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
433
|
+
client = BridgeClient.discover(id, session_dir: dir) or return
|
|
434
|
+
before = RecapStore.read(dir)
|
|
435
|
+
reply = client.request_recap
|
|
436
|
+
asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
|
|
437
|
+
return unless %w[started in_flight].include?(asked)
|
|
438
|
+
|
|
439
|
+
BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
|
|
440
|
+
rescue StandardError => e
|
|
441
|
+
Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
|
|
442
|
+
nil
|
|
443
|
+
end
|
|
444
|
+
|
|
181
445
|
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
182
446
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
183
447
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
@@ -279,9 +543,11 @@ module Samagotchi
|
|
|
279
543
|
# @param include_archived [Boolean] archived sessions too
|
|
280
544
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
281
545
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
282
|
-
# parent_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
|
|
546
|
+
# parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
|
|
547
|
+
# waiting_id:, stopped_by:}; busy =
|
|
283
548
|
# live with a turn running, delegate = started by the delegate tool
|
|
284
|
-
# (Session#delegate?; a fork has a parent_id but isn't one),
|
|
549
|
+
# (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
|
|
550
|
+
# previous link of its chain, Session#continues), recap = the saved recap's first sentence, project =
|
|
285
551
|
# Session#project_root, waiting = the kind of question it waits on
|
|
286
552
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
287
553
|
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
@@ -310,7 +576,8 @@ module Samagotchi
|
|
|
310
576
|
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
311
577
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
|
|
312
578
|
budget_tokens: -> { budgets.call(s) })&.round(1),
|
|
313
|
-
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8),
|
|
579
|
+
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
|
|
580
|
+
continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
|
|
314
581
|
scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
315
582
|
end
|
|
316
583
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
@@ -354,24 +621,34 @@ module Samagotchi
|
|
|
354
621
|
# @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
|
|
355
622
|
# queued in one (a later resume would run it), a live one offers to
|
|
356
623
|
# continue past the step limit, or it is a scratch session
|
|
357
|
-
|
|
624
|
+
# @param except [Array<String>] descendants left alone with their own
|
|
625
|
+
# subtrees (delegates a continue moves to the chain's new link)
|
|
626
|
+
def self.archive_session(id_or_prefix, state_dir: nil, wait: 5, except: [])
|
|
358
627
|
sd = state_dir || Session.default_state_dir
|
|
359
628
|
id = archive_target(id_or_prefix, sd)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
tree = [id, *descendant_ids(id, sd)]
|
|
363
|
-
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
|
|
364
|
-
tree.each do |sid|
|
|
365
|
-
refusal = archive_refusal(sid, owners[sid], sd)
|
|
366
|
-
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
367
|
-
end
|
|
368
|
-
|
|
629
|
+
tree, owners = archivable!(id, sd, except: except)
|
|
369
630
|
stopped = tree.select { |sid| owners[sid] }
|
|
370
631
|
stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
|
|
371
632
|
archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
|
|
372
633
|
{ id: id, archived: archived, stopped: stopped, discarded: discarded }
|
|
373
634
|
end
|
|
374
635
|
|
|
636
|
+
# The session and its descendants, with their owners, when they can be
|
|
637
|
+
# archived now (#archive_refusal for each), else the refusal.
|
|
638
|
+
# @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
|
|
639
|
+
# @raise [ArchiveRefused, OwnedByTUI]
|
|
640
|
+
private_class_method def self.archivable!(id, state_dir, except: [])
|
|
641
|
+
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
642
|
+
|
|
643
|
+
tree = [id, *descendant_ids(id, state_dir, except: except)]
|
|
644
|
+
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
|
|
645
|
+
tree.each do |sid|
|
|
646
|
+
refusal = archive_refusal(sid, owners[sid], state_dir)
|
|
647
|
+
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
648
|
+
end
|
|
649
|
+
[tree, owners]
|
|
650
|
+
end
|
|
651
|
+
|
|
375
652
|
# What keeps +sid+ (in an archive's tree) from being archived: :busy (a
|
|
376
653
|
# turn runs), :queued (input/ holds a prompt, which outlives the stop and
|
|
377
654
|
# runs on a resume, live worker or not), :offer (a live worker's continue
|
|
@@ -409,13 +686,13 @@ module Samagotchi
|
|
|
409
686
|
end
|
|
410
687
|
|
|
411
688
|
# Children, their children, …: a delegated session doesn't delegate
|
|
412
|
-
# further today, a plugin's fork may.
|
|
413
|
-
|
|
689
|
+
# further today, a plugin's fork may. The walk doesn't enter +except+.
|
|
690
|
+
def self.descendant_ids(id, state_dir, except: [])
|
|
414
691
|
seen = [id]
|
|
415
692
|
queue = [id]
|
|
416
693
|
until queue.empty?
|
|
417
694
|
children_of(queue.shift, state_dir: state_dir).each do |child|
|
|
418
|
-
next if seen.include?(child[:id])
|
|
695
|
+
next if seen.include?(child[:id]) || except.include?(child[:id])
|
|
419
696
|
|
|
420
697
|
seen << child[:id]
|
|
421
698
|
queue << child[:id]
|
|
@@ -484,6 +761,9 @@ module Samagotchi
|
|
|
484
761
|
# One this process just spawned may not hold the lock yet (a slow
|
|
485
762
|
# start, deliver_turn's second resume): it would only lose it.
|
|
486
763
|
return session if still_starting?(session.id, state_dir: sd)
|
|
764
|
+
# A continue moving delegates to it spawns its worker once they are
|
|
765
|
+
# moved; one spawned now (a moved child's ring) would race the move.
|
|
766
|
+
return session if ChildMove.starting?(session.id, state_dir: sd)
|
|
487
767
|
|
|
488
768
|
Session.clear_stopped(session.id, state_dir: sd)
|
|
489
769
|
session.status = Session::STATUS_IDLE
|
|
@@ -494,18 +774,27 @@ module Samagotchi
|
|
|
494
774
|
|
|
495
775
|
# A delegate child rang a parent that has no worker (ChildRing): start
|
|
496
776
|
# one, which runs a turn for the report. Not for a parent that is gone,
|
|
497
|
-
# stopped, archived, a scratch session,
|
|
498
|
-
#
|
|
499
|
-
#
|
|
500
|
-
#
|
|
777
|
+
# stopped, archived, a scratch session, open in a chi REPL, or a
|
|
778
|
+
# continue's new link still taking its delegates (its own spawn comes):
|
|
779
|
+
# the ring waits on disk for whoever runs it next (#resume_session
|
|
780
|
+
# itself would clear the stop). A stop landing between the check and
|
|
781
|
+
# the resume is a small window, left as is. An archived parent with a
|
|
782
|
+
# later link in its chain wakes that link instead: a ring a moved child
|
|
783
|
+
# wrote just as the continue moved it reaches the new link that way
|
|
784
|
+
# (ChildReports reads its predecessor's rings).
|
|
501
785
|
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
502
|
-
# :scratch, :owned)
|
|
503
|
-
def self.wake_for_report(session_id, state_dir: nil)
|
|
786
|
+
# :scratch, :owned, :starting)
|
|
787
|
+
def self.wake_for_report(session_id, state_dir: nil, forward: true)
|
|
504
788
|
sd = state_dir || Session.default_state_dir
|
|
505
789
|
return :gone unless Session.exist?(session_id, state_dir: sd)
|
|
506
790
|
return :owned if session_owner(session_id, state_dir: sd)
|
|
507
791
|
return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
|
|
508
|
-
|
|
792
|
+
|
|
793
|
+
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
|
|
794
|
+
following = forward && SessionChain.next_of(session_id, state_dir: sd)
|
|
795
|
+
return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
|
|
796
|
+
end
|
|
797
|
+
return :starting if ChildMove.starting?(session_id, state_dir: sd)
|
|
509
798
|
|
|
510
799
|
session = Session.load(session_id, state_dir: sd)
|
|
511
800
|
return :stopped if session.status == Session::STATUS_STOPPED
|
|
@@ -170,6 +170,7 @@ module Samagotchi
|
|
|
170
170
|
# its git project's root, or nil
|
|
171
171
|
# parent_id: the session that delegated it (the delegate tool), or nil
|
|
172
172
|
# delegate: started by the delegate tool (a fork has a parent_id too)
|
|
173
|
+
# continues: the session it continues (its chain's previous link), or nil
|
|
173
174
|
# archived: hidden from the lists (only with --archived can it be true)
|
|
174
175
|
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
175
176
|
# leftover one at the next sweep)
|
|
@@ -178,7 +179,7 @@ module Samagotchi
|
|
|
178
179
|
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
179
180
|
# id, for chi answer --question; relayed_to: the parent session (short
|
|
180
181
|
# id) whose card it also waits in (the approval relay), or nil
|
|
181
|
-
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id delegate archived scratch ctx_pct
|
|
182
|
+
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
182
183
|
waiting waiting_id relayed_to stopped_by]
|
|
183
184
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
184
185
|
when "tsv"
|
|
@@ -199,6 +200,8 @@ module Samagotchi
|
|
|
199
200
|
end
|
|
200
201
|
# A delegated session points at its parent.
|
|
201
202
|
child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
|
|
203
|
+
# A chain's link points at the one it continues.
|
|
204
|
+
child += " ↪ #{summary[:continues_short_id]}" if summary[:continues_short_id]
|
|
202
205
|
flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
|
|
203
206
|
stopped_by: summary[:stopped_by])
|
|
204
207
|
ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
|
|
@@ -244,6 +247,7 @@ module Samagotchi
|
|
|
244
247
|
row = lambda do |s|
|
|
245
248
|
flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
|
|
246
249
|
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
250
|
+
child += " ↪ #{s.continues[0, 8]}" if s.continues
|
|
247
251
|
# A question waits for an answer (chi answer, the web, chi --attach).
|
|
248
252
|
live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
|
|
249
253
|
waiting = s.waiting_question(live: !!live)
|
|
@@ -33,10 +33,13 @@ module Samagotchi
|
|
|
33
33
|
# The parent's cursors, one per child, in <parent dir>/delegates.json, so
|
|
34
34
|
# a respawned worker (or a REPL resuming the session) doesn't hand the
|
|
35
35
|
# model a reply it already had. Written by whoever owns the parent (its
|
|
36
|
-
# worker, or the REPL its kernel runs in),
|
|
37
|
-
#
|
|
36
|
+
# worker, or the REPL its kernel runs in), and by a continue moving
|
|
37
|
+
# delegates to the chain's new link (ChildMove, from another process):
|
|
38
|
+
# a Mutex for the owner's turn thread vs its loop thread, and a flock on
|
|
39
|
+
# LOCK_FILE between processes; the file is replaced whole.
|
|
38
40
|
module DelegateCursors
|
|
39
41
|
FILE = "delegates.json"
|
|
42
|
+
LOCK_FILE = "delegates.lock"
|
|
40
43
|
LOCK = Mutex.new
|
|
41
44
|
|
|
42
45
|
module_function
|
|
@@ -51,7 +54,7 @@ module Samagotchi
|
|
|
51
54
|
# @yieldreturn [DelegateCursor]
|
|
52
55
|
# @return [DelegateCursor] the new cursor
|
|
53
56
|
def update(parent_id, child_id, state_dir:)
|
|
54
|
-
|
|
57
|
+
locked(parent_id, state_dir: state_dir) do
|
|
55
58
|
all = read(parent_id, state_dir: state_dir)
|
|
56
59
|
cursor = yield get_from(all, child_id)
|
|
57
60
|
all[child_id] = cursor.to_json_hash
|
|
@@ -60,6 +63,31 @@ module Samagotchi
|
|
|
60
63
|
end
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# Add the cursors of +entries+ (child id → cursor fields, as #read
|
|
67
|
+
# gives them) that the parent has none for; one it has is kept.
|
|
68
|
+
# @return [Array<String>] the child ids added
|
|
69
|
+
def merge(parent_id, entries, state_dir:)
|
|
70
|
+
return [] if entries.empty?
|
|
71
|
+
|
|
72
|
+
locked(parent_id, state_dir: state_dir) do
|
|
73
|
+
all = read(parent_id, state_dir: state_dir)
|
|
74
|
+
added = entries.keys - all.keys
|
|
75
|
+
write(parent_id, all.merge(entries.slice(*added)), state_dir: state_dir) unless added.empty?
|
|
76
|
+
added
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def locked(parent_id, state_dir:)
|
|
81
|
+
LOCK.synchronize do
|
|
82
|
+
dir = Session.session_dir(parent_id, state_dir: state_dir)
|
|
83
|
+
FileUtils.mkdir_p(dir)
|
|
84
|
+
File.open(File.join(dir, LOCK_FILE), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
85
|
+
lock.flock(File::LOCK_EX)
|
|
86
|
+
yield
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
63
91
|
def get_from(all, child_id)
|
|
64
92
|
data = all[child_id]
|
|
65
93
|
return DelegateCursor.empty unless data.is_a?(Hash)
|