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.
@@ -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 still running, waiting, live or with a reply it wasn't given:
122
- # +ids+ names them) or :folder_gone (its folder isn't there).
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: #{detail}; wait for them, stop them or archive them, then continue"
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 running,
232
- # a prompt queued or the step-limit question refuse; an idle worker is
233
- # stopped), so a refusal starts nothing. Before that its live worker is
234
- # asked for a recap and given up to +recap_wait+ seconds to write it.
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 still open, or its folder is gone
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
- check_open_children!(id, state_dir)
279
- already = [id, *descendant_ids(id, state_dir)].select do |sid|
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: what this archived goes back to the lists, and
287
- # a new link saved before its worker failed to spawn goes (left,
288
- # it would hold the chain: "continued already", last: to it).
289
- discard_failed_link(id, state_dir)
290
- archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
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
- private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:)
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
- spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
309
- model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
310
- continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
311
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir)
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), a delegate still open (its report would go to the
316
- # archived link), the folder gone (the worker would run elsewhere), a
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
- check_open_children!(previous.id, state_dir)
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 the next link couldn't follow once its parent is archived.
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
- # @raise [ContinueRefused] while #open_children has any
336
- private_class_method def self.check_open_children!(id, state_dir)
337
- open = open_children(id, state_dir)
338
- return if open.empty?
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: open.map(&:id),
341
- detail: open.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
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
- private_class_method def self.open_children(id, state_dir)
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
- def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
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
- private_class_method def self.descendant_ids(id, state_dir)
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, or open in a chi REPL: the ring
709
- # waits on disk for whoever runs it next (#resume_session itself would
710
- # clear the stop). A stop landing between the check and the resume is
711
- # a small window, left as is.
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
- return :archived if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
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("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
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), so a Mutex is enough for its
37
- # turn thread vs its loop thread; the file is replaced whole.
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
- LOCK.synchronize do
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, so only task_stop asks it, just before the signal;
226
- # a refresh (each task_wait poll, task_list) checks the group only.
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)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.47.0"
4
+ VERSION = "0.49.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- open = profile.image_open_token
77
- unless open.empty? || body["chat_template"].to_s.include?(open)
78
- return no("the server's chat template doesn't use #{open} (profile #{profile.name})")
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)
@@ -655,9 +655,11 @@ module Samagotchi
655
655
  return error if error
656
656
 
657
657
  port = await_bridge_port(session.id)
658
- [session.continues, session.id].each { |sid| @hub&.touch(sid) }
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) drawCommandBubble(queued, view);
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; a refusal for delegates
2867
- // still open names them, each a link.
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(`Continued ${short} in ${next.slice(0, 8)}`, { label: "Open", run: () => select(next) });
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(`Continued ${short} in ${next.slice(0, 8)}; ${short} is archived`);
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 named,
228
- // each a link to its session.
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 is" : "delegates are"} still open. Wait for ${n === 1 ? "it" : "them"}, stop or archive ${n === 1 ? "it" : "them"}, then continue.</div><ul>${items}</ul></div>`;
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
  }
@@ -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