samagotchi 0.158.0 → 0.159.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -6
  5. data/docs/cli.md +56 -1
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +3 -1
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/bridge.md +1 -0
  11. data/docs/internals/bundles.md +9 -7
  12. data/docs/memory.md +5 -1
  13. data/docs/scheduling.md +228 -0
  14. data/docs/sessions.md +25 -1
  15. data/lib/samagotchi/bridge.rb +26 -2
  16. data/lib/samagotchi/bundle_command.rb +38 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +13 -3
  19. data/lib/samagotchi/config.rb +12 -0
  20. data/lib/samagotchi/context_move.rb +74 -0
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  22. data/lib/samagotchi/memory_bundle/asset_installer.rb +84 -103
  23. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  24. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  25. data/lib/samagotchi/memory_bundle/shipped_update.rb +17 -12
  26. data/lib/samagotchi/memory_bundle/status.rb +20 -1
  27. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  28. data/lib/samagotchi/run_options.rb +1 -0
  29. data/lib/samagotchi/schedule_command.rb +252 -0
  30. data/lib/samagotchi/schedules.rb +948 -0
  31. data/lib/samagotchi/session_access/contract.rb +1 -1
  32. data/lib/samagotchi/session_access/local.rb +20 -0
  33. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  34. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  35. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +43 -0
  37. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  38. data/lib/samagotchi/tools/builtins.rb +17 -0
  39. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  40. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  41. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/public/chains.js +32 -8
  44. data/lib/samagotchi/web/public/index.html +4 -1
  45. data/lib/samagotchi/web/public/info_bar.js +4 -3
  46. data/lib/samagotchi/web/public/turn_events.js +4 -0
  47. data/lib/samagotchi/web/session_hub.rb +20 -0
  48. data/lib/samagotchi/worker.rb +24 -6
  49. data/lib/samagotchi/worker_boot.rb +3 -4
  50. data/lib/samagotchi/worker_inbound.rb +23 -0
  51. data/lib/samagotchi/worker_link.rb +5 -0
  52. data/lib/samagotchi/worker_wakes.rb +9 -1
  53. metadata +8 -1
@@ -0,0 +1,948 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+ require "fileutils"
8
+
9
+ require_relative "atomic_file"
10
+ require_relative "archive_store"
11
+ require_relative "client_id"
12
+ require_relative "config"
13
+ require_relative "continue_offer"
14
+ require_relative "delivery"
15
+ require_relative "log"
16
+ require_relative "session"
17
+ require_relative "session_chain"
18
+ require_relative "session_inbox"
19
+ require_relative "session_manager"
20
+ require_relative "session_row"
21
+ require_relative "worker_link"
22
+
23
+ module Samagotchi
24
+ # Durable schedules: `chi schedule` and the schedule tools. A schedule lives
25
+ # in one file next to the sessions and outlives every worker: a tick run by
26
+ # cron, systemd, launchd or chi web's hub (docs/scheduling.md) finds the
27
+ # entries whose time has come and runs one of two things against a session:
28
+ #
29
+ # chain (default) — the next link of the session's chain
30
+ # (SessionContinue.run): the previous link is archived, the new one
31
+ # starts with its recap and last answer. "The next run of the same
32
+ # routine", and the reason a long-lived coordinator's context stays
33
+ # healthy.
34
+ # same — the prompt is queued as a turn of the session itself
35
+ # (SessionManager.deliver_turn, delivery: queue), waking a worker when
36
+ # none runs.
37
+ #
38
+ # Unlike register_reminder (ReminderStore, in a live worker's memory, which
39
+ # keeps that worker up), a schedule needs no worker at all: that is the whole
40
+ # point of it. A schedule never holds an idle worker up.
41
+ #
42
+ # Every read-modify-write of the file holds flock on schedules.lock, so a
43
+ # cron tick and a hub tick (or two crontabs, or `chi schedule add` from a
44
+ # terminal) never lose an update. The tick holds that lock only for the
45
+ # read-modify-write, never across the action: SessionContinue.run waits up to
46
+ # 30 s for a recap and 5 s for an archive, and an `add` must not wait on it.
47
+ class Schedules
48
+ FILE = "schedules.json"
49
+ LOCK = "schedules.lock"
50
+ VERSION = 1
51
+
52
+ CHAIN = "chain"
53
+ SAME = "same"
54
+ MODES = [CHAIN, SAME].freeze
55
+ CATCH_UP = %w[once none].freeze
56
+ WEEKDAYS = { 1 => "Mon", 2 => "Tue", 3 => "Wed", 4 => "Thu", 5 => "Fri", 6 => "Sat", 7 => "Sun" }.freeze
57
+ # A schedule prompt is a chain link's first message or a turn's prompt: the
58
+ # same limit the note inbox uses.
59
+ PROMPT_MAX_BYTES = SessionInbox::NOTE_MAX_BYTES
60
+ # Seconds a tick waits for a live worker's GET /activity.
61
+ ACTIVITY_TIMEOUT = 2.0
62
+ # Refusals that mean "not now" rather than "this schedule is broken".
63
+ SKIP_REASONS = %w[busy queued offer busy_child queued_child offer_child continued open_children].freeze
64
+
65
+ # The add itself is wrong (no prompt, two recurrence kinds, a scratch
66
+ # target): a usage error, exit 2.
67
+ class Usage < StandardError; end
68
+
69
+ # schedules.json exists but is not readable JSON: a write would start from
70
+ # an empty list and erase every schedule, so it refuses instead.
71
+ class Corrupt < StandardError; end
72
+
73
+ # One schedule as the file stores it. `anchor` never changes (the chain's
74
+ # root and the provenance of the schedule); `head` is the chain's current
75
+ # link, re-anchored after every firing — the link a chain continue made, or
76
+ # the session a same-mode turn was queued into. That is what makes
77
+ # keep_links pruning provably safe: everything deleted is behind it.
78
+ # `due_was` is the occurrence the last tick attempted: a skip or a failure
79
+ # puts the clock back to it.
80
+ Entry = Data.define(:id, :title, :anchor, :head, :mode, :prompt, :recurrence, :busy, :catch_up,
81
+ :keep_links, :max_failures, :max_skips, :next_due, :due_was, :last_fired, :last_result,
82
+ :firing_since, :failures, :skips, :enabled, :created_at) do
83
+ # The recurrence comes back from JSON with string keys: every reader of
84
+ # it below matches on `recurrence[:kind]`, so it is symbolised here once.
85
+ # A file written by a newer chi may carry keys this one does not know:
86
+ # they are dropped rather than raising in Data#initialize.
87
+ def self.from_h(hash)
88
+ fields = hash.transform_keys(&:to_sym).select { |key| Entry.members.include?(key) }
89
+ recurrence = fields[:recurrence]
90
+ new(**fields, recurrence: recurrence.is_a?(Hash) ? recurrence.transform_keys(&:to_sym) : recurrence)
91
+ end
92
+
93
+ def due?(now)
94
+ Time.iso8601(next_due.to_s) <= now
95
+ rescue ArgumentError
96
+ false
97
+ end
98
+
99
+ # `every 30m`, `Mon 09:00`, `2026-11-20 09:00` — what `list` prints.
100
+ def recurrence_words
101
+ case recurrence[:kind]
102
+ when "every" then "every #{recurrence[:interval]}"
103
+ when "weekday" then "#{WEEKDAYS[recurrence[:weekday]]} #{recurrence[:time]}"
104
+ when "date" then Time.iso8601(recurrence[:at]).localtime.strftime("%Y-%m-%d %H:%M")
105
+ else recurrence.to_s
106
+ end
107
+ rescue StandardError
108
+ recurrence.to_s
109
+ end
110
+
111
+ def target_words = "#{mode} · #{head.to_s[0, 8]}"
112
+ end
113
+
114
+ # What one entry did in a tick: "fired", "skipped", "missed", "failed" or
115
+ # "dry" (a --dry-run's "would fire"). +detail+ is the reason.
116
+ Outcome = Data.define(:id, :title, :status, :detail, :entry) do
117
+ def initialize(id:, status:, title: nil, detail: nil, entry: nil) = super
118
+ end
119
+
120
+ # A whole tick.
121
+ Tick = Data.define(:locked, :outcomes) do
122
+ def initialize(locked: false, outcomes: []) = super
123
+
124
+ def fired = outcomes.select { |outcome| outcome.status == "fired" }
125
+ end
126
+
127
+ # @param state_dir [String] the sessions' directory; the schedule file and
128
+ # its lock sit in the state root above it (docs/scheduling.md), so the
129
+ # session listing never mistakes it for a session
130
+ # @param clock [#call] "now", as a Time
131
+ def initialize(state_dir: nil, clock: -> { Time.now })
132
+ @state_dir = state_dir || Session.default_state_dir
133
+ # ~/.local/state/samagotchi for the default sessions dir; expand_path
134
+ # first so a trailing slash does not make the parent the sessions dir.
135
+ @store_dir = File.dirname(File.expand_path(@state_dir))
136
+ @clock = clock
137
+ end
138
+
139
+ attr_reader :state_dir, :store_dir
140
+
141
+ def path = File.join(@store_dir, FILE)
142
+ def lock_path = File.join(@store_dir, LOCK)
143
+
144
+ # @param include_disabled [Boolean] the disabled and the fired one-offs too
145
+ # @return [Array<Entry>] every entry, the earliest due first
146
+ def entries(include_disabled: false)
147
+ list = read.sort_by { |entry| [entry.next_due.to_s, entry.id] }
148
+ include_disabled ? list : list.select(&:enabled)
149
+ end
150
+
151
+ # @param id_or_name [String] an id, a unique prefix of one, or a title
152
+ # @return [Entry, nil]
153
+ # @raise [Usage] the prefix or the title names more than one schedule
154
+ def lookup(id_or_name)
155
+ given = id_or_name.to_s.strip
156
+ return nil if given.empty?
157
+
158
+ list = read
159
+ exact = list.find { |entry| entry.id == given }
160
+ return exact if exact
161
+
162
+ matches = ambiguous(list.select { |entry| entry.id.start_with?(given) }, given)
163
+ return matches.first unless matches.empty?
164
+
165
+ ambiguous(list.select { |entry| entry.title.to_s.casecmp?(given) }, given).first
166
+ end
167
+
168
+ # One match is the answer; several are a usage error naming them, the way
169
+ # Session::AmbiguousId does for session prefixes.
170
+ def ambiguous(entries, given)
171
+ return entries if entries.size < 2
172
+
173
+ raise Usage, "#{given} matches #{entries.size} schedules: #{entries.map(&:id).join(", ")} — use the full id"
174
+ end
175
+
176
+ # Add a schedule. +target+ is the session it runs against (an id or a
177
+ # unique prefix of one); +recurrence+ is one kind, from #recurrence.
178
+ # @return [Entry]
179
+ # @raise [Usage] the add is wrong
180
+ # @raise [Session::NotFound, Session::AmbiguousId] no such session
181
+ def add(target:, prompt:, recurrence:, title: nil, mode: CHAIN, catch_up: nil, keep_links: nil,
182
+ max_failures: nil, max_skips: nil)
183
+ raise Usage, "a schedule needs a session to run against" if target.to_s.strip.empty?
184
+ raise Usage, "mode must be one of #{MODES.join(", ")}" unless MODES.include?(mode)
185
+
186
+ given_catch_up = catch_up.nil? || catch_up.to_s.strip.empty? ? Config.get("schedule.default_catch_up") : catch_up.to_s.strip
187
+ raise Usage, "catch_up must be one of #{CATCH_UP.join(", ")}" unless CATCH_UP.include?(given_catch_up)
188
+
189
+ prompt = prompt.to_s
190
+ raise Usage, "a schedule needs a prompt" if prompt.strip.empty?
191
+ if prompt.bytesize > PROMPT_MAX_BYTES
192
+ raise Usage, "the prompt is #{prompt.bytesize} bytes; the limit is 16 KiB (#{PROMPT_MAX_BYTES} bytes)"
193
+ end
194
+
195
+ anchor = Session.resolve!(target.to_s, state_dir: @state_dir)
196
+ if Session.load(anchor, state_dir: @state_dir).scratch
197
+ raise Usage, "a scratch session is deleted when its terminal leaves: a schedule cannot run against one"
198
+ end
199
+
200
+ # A model told to "make sure the Monday audit is scheduled" on every
201
+ # firing would otherwise multiply the same schedule: the chain keeps one
202
+ # entry per thing it does, and the machine a bounded number of them.
203
+ same = duplicate_in_chain(anchor, prompt: prompt, title: title, recurrence: recurrence)
204
+ if same
205
+ raise Usage, "#{same.id} already schedules this (#{same.recurrence_words}, #{mode}: #{same.prompt}); " \
206
+ "cancel it with chi schedule cancel #{same.id} to change it"
207
+ end
208
+
209
+ ids = chain_of(anchor)
210
+ in_chain = entries.count { |entry| ids.include?(entry.anchor) || ids.include?(entry.head) }
211
+ per_chain = per_chain_limit
212
+ raise Usage, "this chain has #{in_chain} schedules, the limit is #{per_chain} (schedule.max_per_session)" if in_chain >= per_chain
213
+
214
+ total = entries.size
215
+ raise Usage, "this machine has #{total} schedules, the limit is #{total_limit} (schedule.max_total)" if total >= total_limit
216
+
217
+ now = @clock.call
218
+ entry = Entry.new(
219
+ id: SecureRandom.hex(8), title: title.to_s.strip.empty? ? nil : title.to_s.strip,
220
+ anchor: anchor, head: anchor, mode: mode, prompt: prompt, recurrence: recurrence, busy: "skip",
221
+ catch_up: given_catch_up,
222
+ keep_links: int_or_default(keep_links, "schedule.default_keep_links", 5, min: 0, name: "keep_links"),
223
+ max_failures: int_or_default(max_failures, "schedule.default_max_failures", 3, min: 1),
224
+ max_skips: int_or_default(max_skips, "schedule.default_max_skips", 12, min: 1),
225
+ next_due: next_due_for(recurrence, from: now).utc.iso8601, due_was: nil,
226
+ last_fired: nil, last_result: nil, firing_since: nil,
227
+ failures: 0, skips: 0, enabled: true, created_at: now.utc.iso8601
228
+ )
229
+ write_under_lock { |list| list << entry }
230
+ entry
231
+ end
232
+
233
+ # @return [Entry, nil] nil when no schedule matches
234
+ def cancel(id_or_name) = change(id_or_name, delete: true)
235
+ def enable(id_or_name) = change(id_or_name, enabled: true)
236
+ def disable(id_or_name) = change(id_or_name, enabled: false)
237
+
238
+ # Every session of the chain +session_id+ belongs to: the head of the
239
+ # chain and each link back to its root. A schedule belongs to a chain when
240
+ # its anchor or its head is one of them.
241
+ # @return [Array<String>]
242
+ def chain_of(session_id)
243
+ index = Index.new(@state_dir)
244
+ head = index.latest(session_id)
245
+ ids = [head]
246
+ id = head
247
+ while (previous = index.continues(id)) && !ids.include?(previous)
248
+ ids << previous
249
+ id = previous
250
+ end
251
+ ids
252
+ end
253
+
254
+ # The schedules of this session's chain — the ones the tools may touch
255
+ # without `all: true`.
256
+ def for_chain(session_id, include_disabled: false)
257
+ ids = chain_of(session_id)
258
+ entries(include_disabled: include_disabled).select { |entry| ids.include?(entry.anchor) || ids.include?(entry.head) }
259
+ end
260
+
261
+ # An enabled schedule of the same chain that says the same thing: the same
262
+ # prompt and recurrence, or the same title.
263
+ # @return [Entry, nil]
264
+ def duplicate_in_chain(session_id, prompt:, title:, recurrence:)
265
+ given_title = title.to_s.strip
266
+ ids = chain_of(session_id)
267
+ entries.find do |entry|
268
+ next unless ids.include?(entry.anchor) || ids.include?(entry.head)
269
+
270
+ (entry.prompt == prompt && entry.recurrence == recurrence) ||
271
+ (!given_title.empty? && entry.title.to_s.strip.casecmp?(given_title))
272
+ end
273
+ end
274
+
275
+ # How many enabled schedules one chain may hold, and one machine.
276
+ def per_chain_limit = positive_config("schedule.max_per_session", 10)
277
+ def total_limit = positive_config("schedule.max_total", 50)
278
+
279
+ # One pass over the file: the entries due now, each checked for busy, then
280
+ # advanced and (outside the lock) fired.
281
+ # @param dry_run [Boolean] report what would fire, change nothing
282
+ # @param now [Time] "now"
283
+ # @return [Tick]
284
+ def tick(dry_run: false, now: @clock.call)
285
+ outcomes = []
286
+ pending = []
287
+ index = nil
288
+
289
+ FileUtils.mkdir_p(@store_dir)
290
+ FileUtils.mkdir_p(@state_dir)
291
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
292
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
293
+ # Another tick (cron and the hub, or two crontabs) is already at
294
+ # work: never two firings of the same entry.
295
+ return Tick.new(locked: true, outcomes: [])
296
+ end
297
+
298
+ list = read
299
+ # Ordered by the moment each entry is due, then id: with one chain
300
+ # firing per tick, the entries of that chain take turns (a skipped
301
+ # entry keeps its `next_due`, so the earliest waiting one goes next).
302
+ # File order would let the first entry added win every tick and starve
303
+ # the rest into their max_skips.
304
+ due = list.select { |entry| entry.enabled && entry.due?(now) }
305
+ .sort_by { |entry| [entry.next_due.to_s, entry.id] }
306
+ return Tick.new(locked: false, outcomes: []) if due.empty?
307
+
308
+ # One session listing for the whole tick: a chain walk re-globbing
309
+ # every session per call is the tick's dominant cost.
310
+ index = Index.new(@state_dir)
311
+ updated = list.dup
312
+ # One firing per chain per tick. A second chain entry of the same chain
313
+ # would continue the link the first one just made: that link has no
314
+ # worker lock yet and is not in this tick's index, so the busy check
315
+ # cannot see it, and its prompt would be archived before it ever ran.
316
+ fired_chains = {}
317
+ due.each do |entry|
318
+ position = updated.index { |candidate| candidate.id == entry.id }
319
+ verdict = check(entry, index)
320
+ if verdict
321
+ kind, reason = verdict
322
+ unless dry_run
323
+ updated[position] = kind == :skip ? skipped(entry, reason) : failed(entry, reason)
324
+ end
325
+ outcomes << Outcome.new(id: entry.id, title: entry.title, status: kind == :skip ? "skipped" : "failed",
326
+ detail: reason, entry: entry)
327
+ next
328
+ end
329
+ if entry.mode == CHAIN && fired_chains[chain_key(index, entry)]
330
+ reason = "the chain just fired; retry at the next tick"
331
+ updated[position] = skipped(entry, reason) unless dry_run
332
+ outcomes << Outcome.new(id: entry.id, title: entry.title, status: "skipped", detail: reason, entry: entry)
333
+ next
334
+ end
335
+ if dry_run
336
+ fired_chains[chain_key(index, entry)] = true if entry.mode == CHAIN
337
+ outcomes << Outcome.new(id: entry.id, title: entry.title, status: "dry", detail: entry.recurrence_words, entry: entry)
338
+ next
339
+ end
340
+ if entry.catch_up == "none" && late?(entry, now)
341
+ updated[position] = reschedule(entry, now)
342
+ outcomes << Outcome.new(id: entry.id, title: entry.title, status: "missed", detail: "past its time", entry: entry)
343
+ next
344
+ end
345
+ advanced = advance(entry, now)
346
+ updated[position] = advanced
347
+ pending << advanced
348
+ fired_chains[chain_key(index, entry)] = true if entry.mode == CHAIN
349
+ end
350
+ write(list: updated) unless dry_run
351
+ end
352
+
353
+ pending.each { |entry| outcomes << fire_and_record(entry, index) }
354
+ Tick.new(locked: false, outcomes: outcomes)
355
+ end
356
+
357
+ # Fire one entry now, whatever its next_due says (`chi schedule run-now`).
358
+ # A refusal is recorded exactly as a tick records one — the counters move,
359
+ # and max_skips or max_failures can disable a schedule that can never run.
360
+ # `due_was` is not involved: run-now is not a scheduled occurrence, so
361
+ # there is no clock to put back and next_due stays where it was.
362
+ # @return [Outcome]
363
+ def run_now(id_or_name)
364
+ entry = lookup(id_or_name)
365
+ return Outcome.new(id: id_or_name.to_s, status: "failed", detail: "no such schedule") if entry.nil?
366
+
367
+ index = Index.new(@state_dir)
368
+ verdict = check(entry, index)
369
+ if verdict
370
+ kind, reason = verdict
371
+ status = kind == :skip ? "skipped" : "failed"
372
+ record_refusal(entry, status, reason)
373
+ return Outcome.new(id: entry.id, title: entry.title, status: status, detail: reason, entry: entry)
374
+ end
375
+
376
+ fire_and_record(advance(entry, @clock.call), index)
377
+ end
378
+
379
+ # Minutes between the hub's ticks when schedule.auto_tick is on.
380
+ def self.tick_interval_minutes
381
+ value = Config.get("schedule.tick_interval_minutes").to_i
382
+ value.positive? ? value : 5
383
+ end
384
+
385
+ # A recurrence from the CLI's flags or the tool's arguments. Blank strings
386
+ # (what a model sends for the fields it did not use) count as absent.
387
+ # @return [Hash] {kind:, ...}
388
+ # @raise [Usage] none or more than one kind given, or an interval that is
389
+ # not a positive amount of time
390
+ def self.recurrence(every: nil, weekday: nil, time: nil, date: nil)
391
+ every = nil if every.to_s.strip.empty?
392
+ weekday = nil if weekday.to_s.strip.empty?
393
+ time = nil if time.to_s.strip.empty?
394
+ date = nil if date.to_s.strip.empty?
395
+
396
+ kinds = [every, weekday, date].compact
397
+ raise Usage, "a schedule needs one of every, weekday or date" if kinds.empty?
398
+ raise Usage, "give one of every, weekday or date, not more" if kinds.size > 1
399
+
400
+ if every
401
+ raise Usage, "time is only for a weekday schedule" if time && !weekday
402
+
403
+ interval = every.to_s.strip
404
+ raise Usage, "every needs an interval like 30m, 2h or 1d, got #{every}" unless interval.match?(/\A\d+[smhd]?\z/)
405
+
406
+ seconds = interval_seconds(interval)
407
+ raise Usage, "every must be at least 1s, got #{every}" if seconds.nil? || seconds.zero?
408
+
409
+ { kind: "every", interval: interval }
410
+ elsif weekday
411
+ raise Usage, "weekday needs time HH:MM" if time.nil?
412
+
413
+ day = weekday.to_s.strip
414
+ raise Usage, "weekday must be 1 (Mon) to 7 (Sun), got #{weekday}" unless day.match?(/\A[1-7]\z/)
415
+
416
+ { kind: "weekday", weekday: day.to_i, time: time.to_s.strip }
417
+ else
418
+ { kind: "date", at: local_moment(*parse_local(date)).utc.iso8601 }
419
+ end
420
+ end
421
+
422
+ # The chain a tick walks: the sessions once, with their predecessors,
423
+ # their successors and whether they are archived.
424
+ class Index
425
+ def initialize(state_dir)
426
+ @state_dir = state_dir
427
+ sessions = Session.list(state_dir: state_dir, include_archived: true, sort: "created_at", order: "asc")
428
+ @by_id = sessions.to_h { |session| [session.id, session] }
429
+ @archived = @by_id.to_h { |id, _session| [id, ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))] }
430
+ @nexts = {}
431
+ sessions.each { |session| @nexts[session.continues] ||= session.id if session.continues }
432
+ end
433
+
434
+ def session(id) = @by_id[id]
435
+
436
+ def gone?(id) = @by_id[id].nil?
437
+
438
+ def archived?(id) = @archived[id]
439
+
440
+ attr_reader :state_dir
441
+
442
+ # The chain's latest link from +id+ on, in this one listing.
443
+ def latest(id)
444
+ seen = [id]
445
+ while (following = @nexts[seen.last]) && !seen.include?(following)
446
+ seen << following
447
+ end
448
+ seen.last
449
+ end
450
+
451
+ # A link's predecessor, or nil (a pruned or never-existing one).
452
+ def continues(id) = @by_id[id]&.continues
453
+
454
+ # A chain's root: +id+ back through `continues` until a link with no
455
+ # predecessor. Entries of one chain share this root whichever link each
456
+ # of them was added against.
457
+ def root(id)
458
+ seen = [id]
459
+ while (previous = @by_id[seen.last]&.continues) && !seen.include?(previous)
460
+ seen << previous
461
+ end
462
+ seen.last
463
+ end
464
+ end
465
+
466
+ # The length of an interval in seconds; nil for something that is not an
467
+ # interval, and nil for zero so a `0m` schedule can never fire every tick.
468
+ def self.interval_seconds(interval)
469
+ match = interval.to_s.strip.match(/\A(\d+)(s|m|h|d)?\z/)
470
+ return nil unless match
471
+
472
+ seconds = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400, nil => 60 }[match[2]] * match[1].to_i
473
+ seconds.positive? ? seconds : nil
474
+ end
475
+
476
+ def self.parse_clock(time)
477
+ match = time.to_s.strip.match(/\A(\d{1,2}):(\d{1,2})\z/)
478
+ raise Usage, "time must be HH:MM, got #{time}" unless match
479
+
480
+ hour = match[1].to_i
481
+ minute = match[2].to_i
482
+ raise Usage, "time must be HH:MM, got #{time}" unless (0..23).cover?(hour) && (0..59).cover?(minute)
483
+
484
+ [hour, minute]
485
+ end
486
+
487
+ def self.parse_local(text)
488
+ match = text.to_s.strip.match(/\A(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{1,2}):(\d{2}))?\z/)
489
+ raise Usage, "date must be YYYY-MM-DD or YYYY-MM-DDTHH:MM, got #{text}" unless match
490
+
491
+ year = match[1].to_i
492
+ month = match[2].to_i
493
+ day = match[3].to_i
494
+ hour = match[4] ? match[4].to_i : 0
495
+ minute = match[5] ? match[5].to_i : 0
496
+ unless (1..12).cover?(month) && (1..31).cover?(day) && (0..23).cover?(hour) && (0..59).cover?(minute)
497
+ raise Usage, "date must be a real date, got #{text}"
498
+ end
499
+
500
+ [year, month, day, hour, minute]
501
+ end
502
+
503
+ # A local moment that exists. When the requested minute falls in a
504
+ # spring-forward gap, the first valid minute whose wall clock is at or
505
+ # after it.
506
+ def self.local_moment(year, month, day, hour, minute)
507
+ moment = Time.local(year, month, day, hour, minute)
508
+ return moment if moment.hour == hour && moment.min == minute
509
+
510
+ wanted = (hour * 60) + minute
511
+ day_start = Time.local(year, month, day, 0, 0)
512
+ 1440.times do |step|
513
+ probe = day_start + (step * 60)
514
+ return probe if (probe.hour * 60) + probe.min >= wanted
515
+ end
516
+ moment
517
+ end
518
+
519
+ private
520
+
521
+ # @param strict [Boolean] a file that is not readable JSON raises rather
522
+ # than reading as empty: a write starting from an empty list would rewrite
523
+ # the file and lose every schedule on it. Listing stays lenient.
524
+ def read(strict: false)
525
+ return [] unless File.exist?(path)
526
+
527
+ data = JSON.parse(File.read(path))
528
+ Array(data["schedules"]).map { |hash| Entry.from_h(hash) }
529
+ rescue JSON::ParserError => e
530
+ Log.warn(:worker, "store_unreadable", error: e.class.name, msg: e.message)
531
+ raise Corrupt, "#{path} is not readable JSON (#{e.message}): fix or remove it before changing schedules" if strict
532
+
533
+ []
534
+ end
535
+
536
+ def write(list:)
537
+ FileUtils.mkdir_p(@store_dir)
538
+ FileUtils.mkdir_p(@state_dir)
539
+ body = { "version" => VERSION, "updated_at" => Time.now.utc.iso8601, "schedules" => list.map(&:to_h) }
540
+ AtomicFile.write(path, "#{JSON.pretty_generate(body)}\n")
541
+ end
542
+
543
+ # Every write of the file is a read-modify-write under the lock.
544
+ def write_under_lock
545
+ FileUtils.mkdir_p(@store_dir)
546
+ FileUtils.mkdir_p(@state_dir)
547
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
548
+ lock.flock(File::LOCK_EX)
549
+ list = read(strict: true)
550
+ yield(list)
551
+ write(list: list)
552
+ end
553
+ end
554
+
555
+ # The same resolution as #lookup — an id, a unique prefix, or a title, and
556
+ # an ambiguous one raises Usage rather than taking the first match.
557
+ def change(id_or_name, enabled: nil, delete: false)
558
+ entry = lookup(id_or_name)
559
+ return nil if entry.nil?
560
+
561
+ write_under_lock do |list|
562
+ position = list.index { |candidate| candidate.id == entry.id }
563
+ next if position.nil?
564
+
565
+ if delete
566
+ list[position] = nil
567
+ list.compact!
568
+ else
569
+ list[position] = entry.with(enabled: enabled, last_result: enabled ? "enabled" : "disabled")
570
+ end
571
+ end
572
+ entry
573
+ end
574
+
575
+ # +name+ is what the caller's flag is called, so a bad value says which one.
576
+ def int_or_default(value, key, fallback, min:, name: key.split(".").last)
577
+ unless value.nil? || value.to_s.strip.empty?
578
+ raise Usage, "#{name} must be a number, got #{value.inspect}" unless value.to_s.strip.match?(/\A-?\d+\z/)
579
+
580
+ return [value.to_i, min].max
581
+ end
582
+
583
+ configured = Config.get(key)
584
+ configured.to_s.strip.match?(/\A-?\d+\z/) ? [configured.to_i, min].max : fallback
585
+ end
586
+
587
+ def positive_config(key, fallback)
588
+ value = Config.get(key).to_i
589
+ value.positive? ? value : fallback
590
+ end
591
+
592
+ # The next occurrence strictly after +from+.
593
+ def next_due_for(recurrence, from: @clock.call)
594
+ case recurrence[:kind]
595
+ when "every" then from + (Schedules.interval_seconds(recurrence[:interval] || "1d") || 86_400)
596
+ when "weekday" then weekday_moment(recurrence[:weekday], recurrence[:time], from: from)
597
+ when "date" then Time.iso8601(recurrence[:at])
598
+ else from
599
+ end
600
+ end
601
+
602
+ # The next +weekday+ at +time+ in the machine's local zone, strictly after
603
+ # +from+ (today counts when the time is still ahead). cwday, not wday:
604
+ # Ruby's wday is 0 for Sunday while the CLI, the tool and the docs say
605
+ # 7=Sun. Built with Time.local, never now + 86400*n: a schedule at 09:00
606
+ # keeps its wall clock across a DST change, while `every 1d` drifts an hour
607
+ # across the fold (docs/scheduling.md says so and points at --weekday for a
608
+ # time of day). Eight days is more than a week, so a valid weekday always
609
+ # has an occurrence: reaching the raise is a bug, and a silent fallback
610
+ # would turn a weekly schedule into a daily one.
611
+ def weekday_moment(weekday, time, from:)
612
+ hour, minute = Schedules.parse_clock(time)
613
+ start = from.to_date
614
+ 8.times do |step|
615
+ date = start + step
616
+ candidate = Schedules.local_moment(date.year, date.month, date.day, hour, minute)
617
+ next if candidate <= from || candidate.to_date.cwday != weekday
618
+
619
+ return candidate
620
+ end
621
+ raise Usage, "no #{WEEKDAYS[weekday]} occurs within 8 days of #{from}"
622
+ end
623
+
624
+ # catch_up: none fires only when the tick arrives at the moment. "Late"
625
+ # means a whole cycle, not a late tick: an entry is missed only when the
626
+ # occurrence after the one it missed is past as well, which is what the file
627
+ # looks like when the machine was asleep across the interval. A Mon 09:00
628
+ # schedule ticked at 09:20 is simply late and fires; one first seen the
629
+ # following Monday is missed and moves to the Monday after. A `date` one-off
630
+ # has no next occurrence to compare with, so it is never missed: it fires
631
+ # late, once.
632
+ def late?(entry, now)
633
+ return false if entry.recurrence[:kind] == "date"
634
+
635
+ # due? has already accepted next_due, so it parses.
636
+ next_due_for(entry.recurrence, from: Time.iso8601(entry.next_due.to_s)) <= now
637
+ end
638
+
639
+ # The clock moves to the next occurrence. A `date` one-off has none, so it
640
+ # is disabled — `late?` never sends a one-off here, but a spent `date` entry
641
+ # arriving from an older file is still spent.
642
+ def reschedule(entry, now)
643
+ if entry.recurrence[:kind] == "date"
644
+ entry.with(next_due: nil, last_result: "missed", firing_since: nil, enabled: false)
645
+ else
646
+ entry.with(next_due: next_due_for(entry.recurrence, from: now).utc.iso8601, last_result: "missed", firing_since: nil)
647
+ end
648
+ end
649
+
650
+ # The clock advances and the entry is marked firing before the action: a
651
+ # tick killed mid-continue cannot fire the same entry twice. The occurrence
652
+ # it tried is kept as due_was, so a skip or a failure puts it back: the
653
+ # schedule was not late, it just could not run now. A `date` one-off has no
654
+ # next occurrence, so advancing it spends it — the entry is disabled until
655
+ # a skip or a failure puts its moment back.
656
+ def advance(entry, now)
657
+ one_off = entry.recurrence[:kind] == "date"
658
+ entry.with(due_was: entry.next_due,
659
+ next_due: one_off ? nil : next_due_for(entry.recurrence, from: now).utc.iso8601,
660
+ last_fired: now.utc.iso8601, firing_since: now.utc.iso8601, last_result: nil,
661
+ enabled: one_off ? false : entry.enabled)
662
+ end
663
+
664
+ def skipped(entry, reason)
665
+ skips = entry.skips + 1
666
+ disabled = skips >= entry.max_skips
667
+ entry.with(skips: skips, firing_since: nil,
668
+ last_result: disabled ? "disabled: #{reason}" : "skipped", enabled: !disabled)
669
+ end
670
+
671
+ def failed(entry, reason)
672
+ failures = entry.failures + 1
673
+ entry.with(failures: failures, firing_since: nil, last_result: reason, enabled: failures < entry.max_failures)
674
+ end
675
+
676
+ # :skip (busy now, try the next tick) or :fail (the target is gone or
677
+ # archived); nil when the entry may fire.
678
+ def check(entry, index)
679
+ target, reason = target_of(entry, index)
680
+ return [:fail, reason] if target.nil?
681
+
682
+ busy_reason(entry, index, target)
683
+ end
684
+
685
+ # The session the entry would act on: its head, followed through every
686
+ # continue made since it was last written — a chain firing's new link or a
687
+ # human `chi send --continues`/`chi resume` (SessionChain.latest, the same
688
+ # walk SessionContinue.run does for `last:`). Both modes use the walk: a
689
+ # chain continues that link, a same-mode entry queues a turn in it. The
690
+ # anchor alone is not enough for either: the first continue archives it, so
691
+ # a same-mode entry read literally would fail "archived" from then on, and
692
+ # the chain would continue a link that is no longer the end. The anchor is
693
+ # the fallback when the head is gone (pruned or deleted by hand): the walk
694
+ # from it still finds the chain's end.
695
+ def target_of(entry, index)
696
+ head = index.latest(index.gone?(entry.head) ? entry.anchor : entry.head)
697
+ if index.gone?(head)
698
+ return [nil, entry.mode == CHAIN ? "the chain is gone" : "the session it was made against is gone"]
699
+ end
700
+
701
+ return [nil, "archived"] if index.archived?(head)
702
+
703
+ [head, nil]
704
+ end
705
+
706
+ # Which chain an entry belongs to, for the one-firing-per-chain-per-tick
707
+ # guard: the root of the chain its resolved target is in. Every entry of a
708
+ # chain has the same root, whichever link it was added against.
709
+ def chain_key(index, entry)
710
+ target, _reason = target_of(entry, index)
711
+ index.root(target)
712
+ end
713
+
714
+ # Two paths (FR7): a live worker is asked through its Bridge; with no
715
+ # worker the files say what the tick can know.
716
+ def busy_reason(entry, index, target)
717
+ dir = Session.session_dir(target, state_dir: @state_dir)
718
+ owner = SessionManager.session_owner(target, state_dir: @state_dir)
719
+ link = WorkerLink.resolve(dir, unlink: false, owner: owner)
720
+
721
+ if link.worker_owned?
722
+ # A worker that has not published its Bridge yet (or is closing it):
723
+ # nothing can be read from it, and firing now would race with it.
724
+ return [:skip, "its worker is starting or leaving"] unless link.live?
725
+
726
+ activity = ask_activity(link, target)
727
+ if activity == :old
728
+ # Its chi predates GET /activity. The file checks cannot see an open
729
+ # tab, so a chain firing would archive under someone's terminal:
730
+ # refuse with a restart, never silently degrade. Same mode only
731
+ # queues a turn, so for it the files are enough.
732
+ return [:skip, "its worker is too old to ask: chi sessions restart #{target[0, 8]}"] if entry.mode == CHAIN
733
+
734
+ return file_busy_reason(index.session(target), dir)
735
+ end
736
+ return [:skip, "its worker is not answering"] if activity.nil?
737
+
738
+ return [:skip, "a turn is running in it"] if activity[:turn_running]
739
+ return [:skip, "a prompt is queued for it"] if activity[:input_pending]
740
+ return [:skip, "its step-limit question waits for an answer"] if activity[:awaiting_continue]
741
+
742
+ # A chain firing archives the session and stops its worker: a
743
+ # terminal or a web tab on it would be cut off. A queued turn into
744
+ # an open session is fine, so same mode does not count streams.
745
+ return [:skip, "a terminal or a web tab is open on it"] if entry.mode == CHAIN && activity[:open_streams].to_i.positive?
746
+
747
+ nil
748
+ end
749
+
750
+ file_busy_reason(index.session(target), dir)
751
+ end
752
+
753
+ # The worker's activity snapshot, :old when it has no /activity route, or
754
+ # nil when it did not answer at all. A worker published its features when
755
+ # it started, so a sidecar without "activity" is an older chi's: asking it
756
+ # would only cost a round trip to a 404.
757
+ def ask_activity(link, session_id)
758
+ return :old unless Array(link.features).include?(WorkerLink::ACTIVITY_FEATURE)
759
+
760
+ status, body = link.client(session_id, read_timeout: ACTIVITY_TIMEOUT).get("activity")
761
+ return nil if status.nil?
762
+ return body.transform_keys(&:to_sym) if status == 200 && body.is_a?(Hash)
763
+
764
+ :old
765
+ rescue StandardError => e
766
+ Log.warn(:worker, "activity_failed", sid: session_id, error: e.class.name, msg: e.message)
767
+ nil
768
+ end
769
+
770
+ # The signals RemovalPart#archive_refusal reads, from the files alone.
771
+ def file_busy_reason(session, dir)
772
+ return nil if session.nil?
773
+
774
+ if SessionRow.running_or_starting?(saved_status: session.status, live: false, busy: false, updated_at: session.updated_at)
775
+ return [:skip, "a turn is running in it"]
776
+ end
777
+ return [:skip, "a prompt is queued for it"] unless SessionInbox.find_new_input_files(dir).empty?
778
+
779
+ return [:skip, "its step-limit question waits for an answer"] if session.waiting_question(live: false)&.dig(:kind) == ContinueOffer::KIND
780
+
781
+ nil
782
+ end
783
+
784
+ def fire_and_record(entry, index)
785
+ outcome = fire(entry, index)
786
+ record(entry, outcome)
787
+ prune(outcome.entry, index) if outcome.status == "fired" && entry.mode == CHAIN && entry.keep_links.positive?
788
+ outcome
789
+ end
790
+
791
+ def fire(entry, index)
792
+ entry.mode == CHAIN ? fire_chain(entry, index) : fire_same(entry, index)
793
+ rescue StandardError => e
794
+ # run_locked re-raises whatever spawn_continuation throws after undoing
795
+ # the move: a spawn failure or an ENOSPC is a failure, not a skip.
796
+ Log.warn(:worker, "fire_failed", sid: entry.anchor, id: entry.id, error: e.class.name, msg: e.message)
797
+ Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "#{e.class}: #{e.message}", entry: entry)
798
+ end
799
+
800
+ def fire_chain(entry, index)
801
+ head, _reason = target_of(entry, index)
802
+ return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "the chain is gone", entry: entry) if head.nil?
803
+
804
+ # `last:<head>` so SessionContinue.run's own TRIES/`:moved` loop follows
805
+ # a continue a human made between here and its lock.
806
+ link = SessionContinue.run("last:#{head}", prompt: entry.prompt, state_dir: @state_dir)
807
+ Outcome.new(id: entry.id, title: entry.title, status: "fired", detail: "new link #{link.id[0, 8]}",
808
+ entry: entry.with(head: link.id))
809
+ rescue SessionContinue::Refused => e
810
+ classify(entry, e.reason, e.message)
811
+ rescue SessionManager::ArchiveRefused => e
812
+ classify(entry, e.reason, e.message)
813
+ end
814
+
815
+ def fire_same(entry, index)
816
+ target, reason = target_of(entry, index)
817
+ return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: reason, entry: entry) if target.nil?
818
+
819
+ # Checked again, against the files: a continue made between the tick's
820
+ # listing and this moment archives the target it just resolved to.
821
+ dir = Session.session_dir(target, state_dir: @state_dir)
822
+ return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "archived", entry: entry) if ArchiveStore.archived?(dir)
823
+
824
+ result = SessionManager.deliver_turn(target, prompt: entry.prompt,
825
+ client_id: ClientId.schedule(entry.id),
826
+ delivery: Delivery::QUEUE, state_dir: @state_dir)
827
+ if result.accepted?
828
+ # The head follows the turn: the next tick resolves from the link this
829
+ # one fired into, so the walk survives a link deleted in between.
830
+ Outcome.new(id: entry.id, title: entry.title, status: "fired", detail: "queued a turn", entry: entry.with(head: target))
831
+ else
832
+ Outcome.new(id: entry.id, title: entry.title, status: "failed",
833
+ detail: "the turn was not accepted (#{result.status}: #{result.error || result.detail})", entry: entry)
834
+ end
835
+ end
836
+
837
+ def classify(entry, reason, message)
838
+ status = SKIP_REASONS.include?(reason.to_s) ? "skipped" : "failed"
839
+ Outcome.new(id: entry.id, title: entry.title, status: status, detail: message, entry: entry)
840
+ end
841
+
842
+ # A run-now refused before anything was tried: the same counters a tick
843
+ # moves for a skip or a failure. `due_was` is untouched — run-now is not a
844
+ # scheduled occurrence, so there is no occurrence to put the clock back to.
845
+ def record_refusal(entry, status, reason)
846
+ write_under_lock do |list|
847
+ position = list.index { |candidate| candidate.id == entry.id }
848
+ next if position.nil?
849
+
850
+ stored = list[position]
851
+ list[position] = status == "skipped" ? skipped(stored, reason) : failed(stored, reason)
852
+ end
853
+ end
854
+
855
+ # The result goes back under the lock before any pruning, so a prune that
856
+ # raises midway cannot lose the new head.
857
+ def record(entry, outcome)
858
+ write_under_lock do |list|
859
+ position = list.index { |candidate| candidate.id == entry.id }
860
+ next if position.nil?
861
+
862
+ stored = list[position]
863
+ updated = if outcome.status == "fired"
864
+ # Any success clears the skip and failure counters: twelve
865
+ # skips spread over months do not kill a healthy schedule.
866
+ # A `date` schedule is a one-off: `advance` already left it
867
+ # with no next_due and disabled, and a firing keeps it so.
868
+ stored.with(head: outcome.entry.head, last_result: outcome.detail, firing_since: nil,
869
+ failures: 0, skips: 0, last_fired: @clock.call.utc.iso8601)
870
+ elsif outcome.status == "skipped"
871
+ skipped(stored, outcome.detail).with(firing_since: nil)
872
+ else
873
+ failed(stored, outcome.detail)
874
+ end
875
+
876
+ # A skip or a failure is "not now", not "late": the occurrence the tick
877
+ # tried comes back, so the next tick tries it again. max_skips and
878
+ # max_failures bound how often that happens.
879
+ updated = updated.with(next_due: stored.due_was) if outcome.status != "fired" && !stored.due_was.nil?
880
+ list[position] = updated
881
+ end
882
+ end
883
+
884
+ # The links behind the kept window, with the delegates that hung under
885
+ # them: archiving a link archives its descendants, and archived sessions
886
+ # are outside the retention sweep, so the delegate pile is the bulk of the
887
+ # archived count. Oldest link first, and within a link its children first
888
+ # (deepest first) and the link last. The walk stops at a link that is not
889
+ # archived, one whose subtree has a live worker, or one with a descendant
890
+ # still unarchived: nothing is deleted in that case, and the next firing
891
+ # retries (a schedule failure it is not). The anchor is never pruned:
892
+ # `last:<anchor>` must keep resolving, and it is the id the user remembers.
893
+ def prune(entry, index)
894
+ links = []
895
+ id = entry.head
896
+ seen = []
897
+ while id && !seen.include?(id)
898
+ seen << id
899
+ links << id
900
+ # The walk stops at the anchor: the links before it are the user's own
901
+ # history, made before this schedule existed, and the anchor itself is
902
+ # the id `last:<anchor>` and the user remember.
903
+ break if id == entry.anchor
904
+
905
+ # The link a firing just made is not in the tick's listing; every
906
+ # older one is.
907
+ session = index.session(id) || (File.exist?(Session.session_file(id, state_dir: @state_dir)) ? Session.load(id, state_dir: @state_dir) : nil)
908
+ id = session&.continues
909
+ end
910
+ older = links[entry.keep_links..] || []
911
+ return if older.empty?
912
+
913
+ older.reverse_each do |link|
914
+ next if link == entry.anchor
915
+ break unless ArchiveStore.archived?(Session.session_dir(link, state_dir: @state_dir))
916
+
917
+ descendants = SessionManager.descendant_ids(link, @state_dir)
918
+ # The whole subtree must be ready: a live worker anywhere in it, or a
919
+ # link's descendant that is not archived, stops the walk here rather
920
+ # than half-deleting a tree.
921
+ owned = [link, *descendants].select { |sid| SessionManager.session_owner(sid, state_dir: @state_dir) }
922
+ unless owned.empty?
923
+ Log.warn(:worker, "prune_waiting", sid: link, id: entry.id, owned: owned.map { |sid| sid[0, 8] })
924
+ break
925
+ end
926
+
927
+ open = descendants.reject { |sid| ArchiveStore.archived?(Session.session_dir(sid, state_dir: @state_dir)) }
928
+ unless open.empty?
929
+ Log.warn(:worker, "prune_open_children", sid: link, id: entry.id, open: open.map { |sid| sid[0, 8] })
930
+ break
931
+ end
932
+
933
+ # Children first (deepest first), the link last: if a delete is
934
+ # refused partway, nothing above it has gone, so the next firing
935
+ # retries the same set instead of leaving children under a link that
936
+ # is already deleted.
937
+ (descendants.reverse + [link]).each do |sid|
938
+ SessionManager.delete_session(sid, state_dir: @state_dir)
939
+ end
940
+ Log.info(:worker, "pruned_link", sid: link, id: entry.id, links: descendants.size + 1)
941
+ end
942
+ rescue SessionManager::DeleteRefused => e
943
+ Log.warn(:worker, "prune_stopped", sid: e.session_id, id: entry.id, msg: e.message)
944
+ rescue Session::NotFound => e
945
+ Log.warn(:worker, "prune_missing", id: entry.id, msg: e.message)
946
+ end
947
+ end
948
+ end