samagotchi 0.47.0 → 0.49.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 +50 -1
- data/docs/configuration.md +1 -1
- data/docs/context.md +9 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +39 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +249 -0
- data/lib/samagotchi/child_reports.rb +32 -8
- data/lib/samagotchi/engine.rb +2 -1
- data/lib/samagotchi/model_profile.rb +11 -4
- data/lib/samagotchi/plugin/context.rb +13 -1
- data/lib/samagotchi/prompt.rb +26 -11
- data/lib/samagotchi/prompt_literal_guard.rb +4 -1
- data/lib/samagotchi/send_command.rb +6 -1
- data/lib/samagotchi/session.rb +35 -0
- data/lib/samagotchi/session_manager.rb +131 -53
- data/lib/samagotchi/terminal_ui/question_prompt.rb +7 -0
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +31 -3
- data/lib/samagotchi/tools/task_runtime.rb +4 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +3 -3
- data/lib/samagotchi/web/app.rb +4 -2
- data/lib/samagotchi/web/public/app.js +13 -7
- data/lib/samagotchi/web/public/chains.js +17 -4
- data/lib/samagotchi/worker.rb +12 -1
- metadata +2 -1
|
@@ -31,6 +31,7 @@ require_relative "image_store"
|
|
|
31
31
|
require_relative "plugin_session_state"
|
|
32
32
|
require_relative "context_sources"
|
|
33
33
|
require_relative "session_chain"
|
|
34
|
+
require_relative "child_move"
|
|
34
35
|
require_relative "terminal_ui"
|
|
35
36
|
|
|
36
37
|
module Samagotchi
|
|
@@ -118,8 +119,9 @@ module Samagotchi
|
|
|
118
119
|
|
|
119
120
|
# A continue that can't happen (#continue_session). reason: :continued
|
|
120
121
|
# (another session continues it already: +ids+ names it), :open_children
|
|
121
|
-
# (children
|
|
122
|
-
# +ids+ names them) or :folder_gone (its
|
|
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).
|
|
123
125
|
class ContinueRefused < StandardError
|
|
124
126
|
attr_reader :session_id, :reason, :ids
|
|
125
127
|
|
|
@@ -137,7 +139,8 @@ module Samagotchi
|
|
|
137
139
|
case @reason
|
|
138
140
|
when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
|
|
139
141
|
when :open_children
|
|
140
|
-
"#{short} has delegates still open
|
|
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)"
|
|
141
144
|
else "#{short}'s folder #{detail} is gone"
|
|
142
145
|
end
|
|
143
146
|
end
|
|
@@ -166,12 +169,14 @@ module Samagotchi
|
|
|
166
169
|
# @param note [String, nil] a context note the session starts with,
|
|
167
170
|
# from +note_source+: queued before its worker spawns, so its first
|
|
168
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
|
|
169
174
|
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
170
175
|
# and #model_warning: an id its host's saved list doesn't have
|
|
171
176
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
172
177
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
173
178
|
title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
|
|
174
|
-
note_source: "chi")
|
|
179
|
+
note_source: "chi", before_spawn: nil)
|
|
175
180
|
sd = state_dir || Session.default_state_dir
|
|
176
181
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
177
182
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
@@ -210,6 +215,7 @@ module Samagotchi
|
|
|
210
215
|
end
|
|
211
216
|
setup_session_directory(session_dir, session, state_dir: sd)
|
|
212
217
|
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
218
|
+
before_spawn&.call(session)
|
|
213
219
|
spawn_worker_for_session(session, state_dir: sd)
|
|
214
220
|
session
|
|
215
221
|
end
|
|
@@ -228,10 +234,13 @@ module Samagotchi
|
|
|
228
234
|
# it was typed as, so an alias holds) and with its own llm_context,
|
|
229
235
|
# starting with a context note from chi: the link and the previous
|
|
230
236
|
# link's recap (SessionChain.note_text). The previous link is archived
|
|
231
|
-
# first, with its delegates (archive_session's rules: a turn
|
|
232
|
-
# a prompt queued or the step-limit question refuse; an idle
|
|
233
|
-
# stopped), so a refusal starts nothing.
|
|
234
|
-
#
|
|
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.
|
|
235
244
|
# One continue of a session at a time (a lock in its folder): a double
|
|
236
245
|
# click finds the first one's link and is refused; a last:<id> one
|
|
237
246
|
# follows the chain to its new end instead.
|
|
@@ -239,9 +248,11 @@ module Samagotchi
|
|
|
239
248
|
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
240
249
|
# @param title [String, nil] what the lists show before the first turn;
|
|
241
250
|
# by default the prompt, else the previous link's preview
|
|
242
|
-
# @return [Session] the new link (#model_warning as spawn_session's
|
|
251
|
+
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
252
|
+
# #moved_children: the delegates it took over)
|
|
243
253
|
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
244
|
-
# @raise [ContinueRefused] continued already, delegates
|
|
254
|
+
# @raise [ContinueRefused] continued already, delegates open that can't
|
|
255
|
+
# move, or its folder is gone
|
|
245
256
|
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
246
257
|
def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
|
|
247
258
|
archive_wait: 5)
|
|
@@ -270,82 +281,135 @@ module Samagotchi
|
|
|
270
281
|
|
|
271
282
|
private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
272
283
|
previous = Session.load(id, state_dir: state_dir)
|
|
273
|
-
check_continue!(previous, state_dir)
|
|
284
|
+
plan = check_continue!(previous, state_dir)
|
|
274
285
|
# archive_session's refusals that can be known now, before the wait.
|
|
275
|
-
archivable!(id, state_dir)
|
|
286
|
+
archivable!(id, state_dir, except: plan.move)
|
|
276
287
|
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
277
|
-
# Again: a turn sent to it during the wait may have delegated
|
|
278
|
-
|
|
279
|
-
|
|
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|
|
|
280
292
|
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
281
293
|
end
|
|
282
|
-
archived = archive_session(id, state_dir: state_dir, wait: archive_wait)[:archived] - already
|
|
294
|
+
archived = archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)[:archived] - already
|
|
283
295
|
begin
|
|
284
|
-
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir)
|
|
296
|
+
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
|
|
285
297
|
rescue StandardError
|
|
286
|
-
# Nothing started:
|
|
287
|
-
# a new link saved before its worker failed
|
|
288
|
-
# it would hold the chain: "continued already",
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
291
309
|
raise
|
|
292
310
|
end
|
|
293
311
|
end
|
|
294
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
|
+
|
|
295
324
|
# The link a failed #continue_session saved (no other continues +id+:
|
|
296
|
-
# check_continue! made sure under the lock)
|
|
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.
|
|
297
330
|
private_class_method def self.discard_failed_link(id, state_dir)
|
|
298
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)
|
|
299
337
|
delete_session(orphan, state_dir: state_dir)
|
|
338
|
+
rescue DeleteRefused
|
|
339
|
+
raise
|
|
300
340
|
rescue StandardError => e
|
|
301
341
|
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
302
342
|
end
|
|
303
343
|
|
|
304
|
-
|
|
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:)
|
|
305
357
|
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
306
358
|
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
307
359
|
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
312
370
|
end
|
|
313
371
|
|
|
314
372
|
# The refusals that come before anything changes: continued already (no
|
|
315
|
-
# forks in a chain),
|
|
316
|
-
#
|
|
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
|
|
317
375
|
# model whose host config.yml no longer has.
|
|
376
|
+
# @return [ChildMove::Plan]
|
|
318
377
|
# @raise [ContinueRefused, ModelProfile::MissingModel]
|
|
319
378
|
private_class_method def self.check_continue!(previous, state_dir)
|
|
320
379
|
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
321
380
|
raise ContinueRefused.new(previous.id, :continued, ids: [following])
|
|
322
381
|
end
|
|
323
382
|
|
|
324
|
-
|
|
383
|
+
plan = move_plan!(previous.id, state_dir)
|
|
325
384
|
# Its model's host, as spawn_session checks it: before the archive
|
|
326
385
|
# stops the previous link's worker.
|
|
327
386
|
ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
|
|
328
387
|
dir = previous.working_directory.to_s
|
|
329
388
|
raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
389
|
+
|
|
390
|
+
plan
|
|
330
391
|
end
|
|
331
392
|
|
|
332
|
-
# A child
|
|
393
|
+
# A child still open below a session (#open_children); why: running,
|
|
394
|
+
# waiting, live, unreported reply, or why it can't move (ChildMove).
|
|
333
395
|
OpenChild = Data.define(:id, :short_id, :why)
|
|
334
396
|
|
|
335
|
-
#
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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?
|
|
339
403
|
|
|
340
|
-
raise ContinueRefused.new(id, :open_children, ids:
|
|
341
|
-
detail:
|
|
404
|
+
raise ContinueRefused.new(id, :open_children, ids: plan.refuse.map(&:id),
|
|
405
|
+
detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
|
|
342
406
|
end
|
|
343
407
|
|
|
344
408
|
# The unarchived children, at any depth (the archive's cascade), of the
|
|
345
409
|
# session still running, waiting for an answer, with a live worker, or
|
|
346
410
|
# (a delegate) with a reply its parent wasn't given (ChildrenStatus).
|
|
347
411
|
# @return [Array<OpenChild>]
|
|
348
|
-
|
|
412
|
+
def self.open_children(id, state_dir)
|
|
349
413
|
require_relative "children_status"
|
|
350
414
|
[id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
|
|
351
415
|
.filter_map do |child|
|
|
@@ -557,10 +621,12 @@ module Samagotchi
|
|
|
557
621
|
# @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
|
|
558
622
|
# queued in one (a later resume would run it), a live one offers to
|
|
559
623
|
# continue past the step limit, or it is a scratch session
|
|
560
|
-
|
|
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: [])
|
|
561
627
|
sd = state_dir || Session.default_state_dir
|
|
562
628
|
id = archive_target(id_or_prefix, sd)
|
|
563
|
-
tree, owners = archivable!(id, sd)
|
|
629
|
+
tree, owners = archivable!(id, sd, except: except)
|
|
564
630
|
stopped = tree.select { |sid| owners[sid] }
|
|
565
631
|
stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
|
|
566
632
|
archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
|
|
@@ -571,10 +637,10 @@ module Samagotchi
|
|
|
571
637
|
# archived now (#archive_refusal for each), else the refusal.
|
|
572
638
|
# @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
|
|
573
639
|
# @raise [ArchiveRefused, OwnedByTUI]
|
|
574
|
-
private_class_method def self.archivable!(id, state_dir)
|
|
640
|
+
private_class_method def self.archivable!(id, state_dir, except: [])
|
|
575
641
|
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
576
642
|
|
|
577
|
-
tree = [id, *descendant_ids(id, state_dir)]
|
|
643
|
+
tree = [id, *descendant_ids(id, state_dir, except: except)]
|
|
578
644
|
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
|
|
579
645
|
tree.each do |sid|
|
|
580
646
|
refusal = archive_refusal(sid, owners[sid], state_dir)
|
|
@@ -620,13 +686,13 @@ module Samagotchi
|
|
|
620
686
|
end
|
|
621
687
|
|
|
622
688
|
# Children, their children, …: a delegated session doesn't delegate
|
|
623
|
-
# further today, a plugin's fork may.
|
|
624
|
-
|
|
689
|
+
# further today, a plugin's fork may. The walk doesn't enter +except+.
|
|
690
|
+
def self.descendant_ids(id, state_dir, except: [])
|
|
625
691
|
seen = [id]
|
|
626
692
|
queue = [id]
|
|
627
693
|
until queue.empty?
|
|
628
694
|
children_of(queue.shift, state_dir: state_dir).each do |child|
|
|
629
|
-
next if seen.include?(child[:id])
|
|
695
|
+
next if seen.include?(child[:id]) || except.include?(child[:id])
|
|
630
696
|
|
|
631
697
|
seen << child[:id]
|
|
632
698
|
queue << child[:id]
|
|
@@ -695,6 +761,9 @@ module Samagotchi
|
|
|
695
761
|
# One this process just spawned may not hold the lock yet (a slow
|
|
696
762
|
# start, deliver_turn's second resume): it would only lose it.
|
|
697
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)
|
|
698
767
|
|
|
699
768
|
Session.clear_stopped(session.id, state_dir: sd)
|
|
700
769
|
session.status = Session::STATUS_IDLE
|
|
@@ -705,18 +774,27 @@ module Samagotchi
|
|
|
705
774
|
|
|
706
775
|
# A delegate child rang a parent that has no worker (ChildRing): start
|
|
707
776
|
# one, which runs a turn for the report. Not for a parent that is gone,
|
|
708
|
-
# stopped, archived, a scratch session,
|
|
709
|
-
#
|
|
710
|
-
#
|
|
711
|
-
#
|
|
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).
|
|
712
785
|
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
713
|
-
# :scratch, :owned)
|
|
714
|
-
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)
|
|
715
788
|
sd = state_dir || Session.default_state_dir
|
|
716
789
|
return :gone unless Session.exist?(session_id, state_dir: sd)
|
|
717
790
|
return :owned if session_owner(session_id, state_dir: sd)
|
|
718
791
|
return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
|
|
719
|
-
|
|
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)
|
|
720
798
|
|
|
721
799
|
session = Session.load(session_id, state_dir: sd)
|
|
722
800
|
return :stopped if session.status == Session::STATUS_STOPPED
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "question_slot"
|
|
4
4
|
require_relative "../turn_flow"
|
|
5
|
+
require_relative "../question_desk"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
class TerminalUI
|
|
@@ -80,9 +81,15 @@ module Samagotchi
|
|
|
80
81
|
" waiting in parent #{@relayed_to} too (answering here works)" if @relayed_to
|
|
81
82
|
end
|
|
82
83
|
|
|
84
|
+
# The line a question dismissed in a --non-interactive run leaves (no
|
|
85
|
+
# one could answer it there): the REPL's own, and an attached UI's on
|
|
86
|
+
# its cancel (QuestionDesk::UNANSWERABLE_REASON).
|
|
87
|
+
UNANSWERABLE_TEXT = "(dismissed: no one to answer in a non-interactive run)"
|
|
88
|
+
|
|
83
89
|
# What a question closed with no answer here says: a relayed approval
|
|
84
90
|
# names where it went; else "(question cancelled)".
|
|
85
91
|
def closed_text(reason)
|
|
92
|
+
return UNANSWERABLE_TEXT if reason.to_s == QuestionDesk::UNANSWERABLE_REASON
|
|
86
93
|
return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
|
|
87
94
|
return "(question cancelled)" unless @delegate
|
|
88
95
|
|
|
@@ -1356,7 +1356,7 @@ module Samagotchi
|
|
|
1356
1356
|
# dismissed it.
|
|
1357
1357
|
def dismiss_unaskable_question(prompt)
|
|
1358
1358
|
@engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
|
|
1359
|
-
banner(prompt.summary(
|
|
1359
|
+
banner(prompt.summary(QuestionPrompt::UNANSWERABLE_TEXT, paint: method(:paint)))
|
|
1360
1360
|
false
|
|
1361
1361
|
end
|
|
1362
1362
|
|
|
@@ -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)
|
|
@@ -213,7 +213,7 @@ module Samagotchi
|
|
|
213
213
|
return record unless record["status"] == "running"
|
|
214
214
|
|
|
215
215
|
pid = record["pid"]
|
|
216
|
-
return record if chis_group?(record)
|
|
216
|
+
return record if chis_group?(record, started: record["pid_started"])
|
|
217
217
|
# Gone: its exit code says how it ended.
|
|
218
218
|
return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
|
|
219
219
|
|
|
@@ -222,8 +222,9 @@ module Samagotchi
|
|
|
222
222
|
|
|
223
223
|
# Whether the record's pid still leads its own group. With +started+
|
|
224
224
|
# (the start time recorded at task_create), also the same process: a
|
|
225
|
-
# ps per call
|
|
226
|
-
#
|
|
225
|
+
# ps per call on macOS (~5 ms), asked by each refresh (task_list,
|
|
226
|
+
# task_get, each task_wait poll) so a reused pid reads as exited, and
|
|
227
|
+
# by task_stop again just before the signal.
|
|
227
228
|
def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
|
|
228
229
|
|
|
229
230
|
# A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -73,9 +73,9 @@ module Samagotchi
|
|
|
73
73
|
return no("the server gives no media marker (update llama.cpp)") unless media_marker(props)
|
|
74
74
|
return no("profile #{profile&.name || "?"} has no image template yet") unless profile&.image_template
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
unless
|
|
78
|
-
return no("the server's chat template doesn't use #{
|
|
76
|
+
check = profile.image_check_token
|
|
77
|
+
unless check.empty? || body["chat_template"].to_s.include?(check)
|
|
78
|
+
return no("the server's chat template doesn't use #{check} (profile #{profile.name})")
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
Answer.new(value: true, reason: nil)
|
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -655,9 +655,11 @@ module Samagotchi
|
|
|
655
655
|
return error if error
|
|
656
656
|
|
|
657
657
|
port = await_bridge_port(session.id)
|
|
658
|
-
|
|
658
|
+
moved = session.moved_children || []
|
|
659
|
+
# The moved delegates' cards fold under the new link now.
|
|
660
|
+
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
659
661
|
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
660
|
-
json_response(201, session_to_json(session).merge(bridge_port: port))
|
|
662
|
+
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved))
|
|
661
663
|
end
|
|
662
664
|
|
|
663
665
|
# SessionManager.continue_session, its refusals as responses: [session,
|
|
@@ -37,7 +37,7 @@ import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
|
37
37
|
import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
|
|
38
38
|
import { createStageView } from "./stage_view.js";
|
|
39
39
|
import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
|
|
40
|
-
import { CONTINUE_OPENER, allViewChains, chainChipHtml, chainTitle, isOpener, chainDate, chainNeighbours, chainRowsHtml, continueRefusal, foldChains, openChildrenHtml } from "./chains.js";
|
|
40
|
+
import { CONTINUE_OPENER, allViewChains, chainChipHtml, chainTitle, isOpener, chainDate, chainNeighbours, chainRowsHtml, continueRefusal, continuedText, foldChains, openChildrenHtml } from "./chains.js";
|
|
41
41
|
import { createTaskStopper } from "./task_stop.js";
|
|
42
42
|
import { appendQuote } from "./annotations.js";
|
|
43
43
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -2265,10 +2265,15 @@ const streamHandlers = {
|
|
|
2265
2265
|
if (view.resync) await resync();
|
|
2266
2266
|
// A card's action that isn't anytime (the step-limit question's
|
|
2267
2267
|
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
2268
|
-
// one's bubble was drawn at its command_queued: filled in here.
|
|
2268
|
+
// one's bubble was drawn at its command_queued: filled in here. One
|
|
2269
|
+
// queued for after the turn ran after it: its bubble moves from among
|
|
2270
|
+
// the turn's rows to after its answer and timing line.
|
|
2269
2271
|
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
2270
2272
|
if (view.hidden) queued?.remove();
|
|
2271
|
-
else if (queued
|
|
2273
|
+
else if (queued && view.queued && !view.anytime) {
|
|
2274
|
+
queued.remove();
|
|
2275
|
+
addCommandBubble(view);
|
|
2276
|
+
} else if (queued) drawCommandBubble(queued, view);
|
|
2272
2277
|
else addCommandBubble(view);
|
|
2273
2278
|
},
|
|
2274
2279
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
@@ -2863,8 +2868,9 @@ $("#infoArchiveBtn").addEventListener("click", () => {
|
|
|
2863
2868
|
// Continue → starts the next link of the open session's chain (the server
|
|
2864
2869
|
// waits up to 30 s for its recap, archives it, starts the new one with the
|
|
2865
2870
|
// recap as a note) and opens it with the opener in the composer, not sent.
|
|
2866
|
-
// A link continued already opens that next link;
|
|
2867
|
-
//
|
|
2871
|
+
// A link continued already opens that next link; its open delegates move
|
|
2872
|
+
// to the new link (the toast counts them); a refusal for open delegates
|
|
2873
|
+
// that can't move names them, each a link.
|
|
2868
2874
|
// One continue per session at a time (the server locks it too); another
|
|
2869
2875
|
// session's continue may run beside it.
|
|
2870
2876
|
const continuingIds = new Set();
|
|
@@ -2881,12 +2887,12 @@ async function continueSelected() {
|
|
|
2881
2887
|
addCreatedSession(created);
|
|
2882
2888
|
const next = created.id;
|
|
2883
2889
|
if (selected !== id) {
|
|
2884
|
-
showToast(
|
|
2890
|
+
showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) });
|
|
2885
2891
|
return;
|
|
2886
2892
|
}
|
|
2887
2893
|
await select(next);
|
|
2888
2894
|
if (selected === next) fillOpener();
|
|
2889
|
-
showToast(
|
|
2895
|
+
showToast(continuedText(id, created));
|
|
2890
2896
|
} catch (e) {
|
|
2891
2897
|
const refusal = continueRefusal(e, allSessions);
|
|
2892
2898
|
if (refusal.kind === "open") {
|
|
@@ -201,7 +201,7 @@ export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
|
|
|
201
201
|
// What the page does with a refused continue (POST /api/sessions
|
|
202
202
|
// continues, api()'s error with the answer's body): "open" the link that
|
|
203
203
|
// continues it already (409 continued, next_id), "children" naming the
|
|
204
|
-
// delegates still open (409 open_children, ids: {id, short, text} each,
|
|
204
|
+
// delegates still open that can't move (409 open_children, ids: {id, short, text} each,
|
|
205
205
|
// the text its preview and why it is open: the server's word from its
|
|
206
206
|
// detail, "<short> (running)", else its state here), or "error" with the
|
|
207
207
|
// server's words.
|
|
@@ -224,11 +224,24 @@ export function continueRefusal(error, sessions = []) {
|
|
|
224
224
|
return { kind: "error", text };
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
// The open-children refusal as the toast shows it: the delegates
|
|
228
|
-
//
|
|
227
|
+
// The open-children refusal as the toast shows it: the open delegates the
|
|
228
|
+
// continue can't move to the next link (an older chi's worker, a chi
|
|
229
|
+
// REPL), each a link to its session with what to do about it.
|
|
229
230
|
// @param hrefFor (id) → its page's href
|
|
230
231
|
export function openChildrenHtml(prevId, children, hrefFor) {
|
|
231
232
|
const n = children.length;
|
|
232
233
|
const items = children.map((c) => `<li><a href="${escapeHtml(hrefFor(c.id))}" class="cr-child">${escapeHtml(c.short)}</a>${c.text ? ` ${escapeHtml(c.text)}` : ""}</li>`).join("");
|
|
233
|
-
return `<div class="continue-refused"><div>Can't continue ${escapeHtml(String(prevId).slice(0, 8))}: ${n} ${n === 1 ? "delegate
|
|
234
|
+
return `<div class="continue-refused"><div>Can't continue ${escapeHtml(String(prevId).slice(0, 8))}: ${n} open ${n === 1 ? "delegate" : "delegates"} can't move to the next link. Do what ${n === 1 ? "it says" : "each says"}, or wait for ${n === 1 ? "it" : "them"}, then continue.</div><ul>${items}</ul></div>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The toast after a continue: the new link, the previous one archived (when
|
|
238
|
+
// the page shows the new one), and how many open delegates moved to it
|
|
239
|
+
// (the answer's moved).
|
|
240
|
+
export function continuedText(prevId, created, { opened = true } = {}) {
|
|
241
|
+
const short = String(prevId).slice(0, 8);
|
|
242
|
+
const moved = Array.isArray(created?.moved) ? created.moved.length : 0;
|
|
243
|
+
const parts = [`Continued ${short} in ${String(created?.id || "").slice(0, 8)}`];
|
|
244
|
+
if (opened) parts.push(`${short} is archived`);
|
|
245
|
+
if (moved) parts.push(`${moved} open ${moved === 1 ? "delegate" : "delegates"} moved`);
|
|
246
|
+
return parts.join("; ");
|
|
234
247
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "iteration_limit"
|
|
|
22
22
|
require_relative "session_commands"
|
|
23
23
|
require_relative "model_profile"
|
|
24
24
|
require_relative "child_reports"
|
|
25
|
+
require_relative "child_move"
|
|
25
26
|
require_relative "context_absorber"
|
|
26
27
|
require_relative "context_poller"
|
|
27
28
|
require_relative "tools/task_runtime"
|
|
@@ -162,6 +163,11 @@ module Samagotchi
|
|
|
162
163
|
def start
|
|
163
164
|
# The wake turns' budget (delegate reports, attached context), from now.
|
|
164
165
|
@wakes = WorkerWakes.new(grace: WAKE_START_GRACE)
|
|
166
|
+
# This worker holds the session now (a continue's starting marker held
|
|
167
|
+
# off any other until it did), and a continue that died moving
|
|
168
|
+
# delegates here left its intent: finish it.
|
|
169
|
+
ChildMove.clear_starting(@session_id, state_dir: @state_dir)
|
|
170
|
+
ChildMove.adopt(@session_id, state_dir: @state_dir)
|
|
165
171
|
@session = Session.load(@session_id, state_dir: @state_dir)
|
|
166
172
|
drop_dead_question
|
|
167
173
|
@engine = build_engine
|
|
@@ -195,7 +201,8 @@ module Samagotchi
|
|
|
195
201
|
ring_question(event) if event[:type] == :question_requested
|
|
196
202
|
})
|
|
197
203
|
# This session's own delegate children's news (rings in children/).
|
|
198
|
-
@child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir
|
|
204
|
+
@child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir,
|
|
205
|
+
predecessor: @session.continues)
|
|
199
206
|
# Its attached context's notes (ContextSources).
|
|
200
207
|
@context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
|
|
201
208
|
project_root: @session.project_root)
|
|
@@ -802,6 +809,10 @@ module Samagotchi
|
|
|
802
809
|
restores = @turn_flow.restores
|
|
803
810
|
begin
|
|
804
811
|
@session.status = Session::STATUS_RUNNING
|
|
812
|
+
# A continue that moved this session to the chain's new link wrote
|
|
813
|
+
# the new parent beside it (Session::PARENT_FILE): this turn's
|
|
814
|
+
# status, its rings and every save from here on say so.
|
|
815
|
+
@session.parent_id = Session.parent_override(@session_id, state_dir: @state_dir) || @session.parent_id
|
|
805
816
|
@session.save(state_dir: @state_dir)
|
|
806
817
|
result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, **turn_args)
|
|
807
818
|
rescue StandardError => e
|