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.
@@ -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:, waiting_id:, stopped_by:}; busy =
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), recap = the saved recap's first sentence, project =
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), delegate: s.delegate?, archived: s.archived,
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
- 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: [])
358
627
  sd = state_dir || Session.default_state_dir
359
628
  id = archive_target(id_or_prefix, sd)
360
- raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: sd).scratch
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
- 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: [])
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, or open in a chi REPL: the ring
498
- # waits on disk for whoever runs it next (#resume_session itself would
499
- # clear the stop). A stop landing between the check and the resume is
500
- # 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).
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
- 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)
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), 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)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.46.1"
4
+ VERSION = "0.48.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end