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