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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -7
- data/docs/cli.md +57 -2
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +24 -3
- data/docs/hooks.md +3 -0
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +46 -14
- data/docs/scheduling.md +241 -0
- data/docs/sessions.md +28 -2
- data/lib/samagotchi/bridge.rb +37 -4
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +39 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +17 -3
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_command.rb +9 -3
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
- data/lib/samagotchi/memory_bundle/status.rb +22 -3
- data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +1008 -0
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +28 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +34 -13
- data/lib/samagotchi/session_manager/worker_command.rb +11 -23
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +42 -12
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +44 -2
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- 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
|