samagotchi 0.159.0 → 0.161.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 +125 -1
- data/docs/architecture.md +12 -9
- data/docs/cli.md +7 -5
- data/docs/context.md +10 -1
- data/docs/guardrails.md +33 -5
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +28 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/engine.md +7 -1
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +43 -13
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +53 -8
- data/docs/sessions.md +17 -12
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +103 -44
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +57 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +6 -0
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/compat.rb +32 -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_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +10 -4
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +25 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- 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/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +93 -23
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
- data/lib/samagotchi/memory_bundle/status.rb +3 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
- data/lib/samagotchi/memory_orphans.rb +54 -13
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- 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/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +374 -76
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +59 -7
- data/lib/samagotchi/session_access/local.rb +53 -22
- data/lib/samagotchi/session_access/remote.rb +13 -5
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +20 -4
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +162 -24
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +167 -100
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +129 -0
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +19 -6
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +9 -4
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -5
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +15 -11
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +61 -11
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +62 -10
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +9 -1
data/lib/samagotchi/schedules.rb
CHANGED
|
@@ -8,7 +8,9 @@ require "fileutils"
|
|
|
8
8
|
|
|
9
9
|
require_relative "atomic_file"
|
|
10
10
|
require_relative "archive_store"
|
|
11
|
+
require_relative "worker_presence"
|
|
11
12
|
require_relative "client_id"
|
|
13
|
+
require_relative "command_intake"
|
|
12
14
|
require_relative "config"
|
|
13
15
|
require_relative "continue_offer"
|
|
14
16
|
require_relative "delivery"
|
|
@@ -18,6 +20,8 @@ require_relative "session_chain"
|
|
|
18
20
|
require_relative "session_inbox"
|
|
19
21
|
require_relative "session_manager"
|
|
20
22
|
require_relative "session_row"
|
|
23
|
+
require_relative "state_format"
|
|
24
|
+
require_relative "turn_checkpoint"
|
|
21
25
|
require_relative "worker_link"
|
|
22
26
|
|
|
23
27
|
module Samagotchi
|
|
@@ -59,8 +63,16 @@ module Samagotchi
|
|
|
59
63
|
PROMPT_MAX_BYTES = SessionInbox::NOTE_MAX_BYTES
|
|
60
64
|
# Seconds a tick waits for a live worker's GET /activity.
|
|
61
65
|
ACTIVITY_TIMEOUT = 2.0
|
|
66
|
+
# A chain firing can wait 30 s for a recap and 5 s for an archive; 5 s
|
|
67
|
+
# more leaves margin before an unfinished turn is called interrupted.
|
|
68
|
+
FIRING_TIMEOUT = 40
|
|
69
|
+
PENDING_MIN_SECONDS = 10 * 60
|
|
70
|
+
PENDING_MAX_SECONDS = 6 * 60 * 60
|
|
62
71
|
# Refusals that mean "not now" rather than "this schedule is broken".
|
|
63
|
-
|
|
72
|
+
# (starting: the link's worker holds no lock yet, e.g. another ticker's
|
|
73
|
+
# firing just continued the chain.)
|
|
74
|
+
SKIP_REASONS = %w[busy queued offer busy_child queued_child offer_child starting starting_child continued
|
|
75
|
+
open_children].freeze
|
|
64
76
|
|
|
65
77
|
# The add itself is wrong (no prompt, two recurrence kinds, a scratch
|
|
66
78
|
# target): a usage error, exit 2.
|
|
@@ -79,15 +91,21 @@ module Samagotchi
|
|
|
79
91
|
# puts the clock back to it.
|
|
80
92
|
Entry = Data.define(:id, :title, :anchor, :head, :mode, :prompt, :recurrence, :busy, :catch_up,
|
|
81
93
|
:keep_links, :max_failures, :max_skips, :next_due, :due_was, :last_fired, :last_result,
|
|
82
|
-
:firing_since, :failures, :skips, :enabled,
|
|
94
|
+
:firing_since, :firing_token, :pending_session_id, :pending_turn_id, :failures, :skips, :enabled,
|
|
95
|
+
:created_at, :extra) do
|
|
83
96
|
# The recurrence comes back from JSON with string keys: every reader of
|
|
84
97
|
# it below matches on `recurrence[:kind]`, so it is symbolised here once.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def self.from_h(hash)
|
|
88
|
-
fields = hash.transform_keys(&:to_sym).select { |key| Entry.members.include?(key) }
|
|
98
|
+
def self.from_record(record)
|
|
99
|
+
fields = record.attributes
|
|
89
100
|
recurrence = fields[:recurrence]
|
|
90
|
-
|
|
101
|
+
fields[:recurrence] = recurrence.transform_keys(&:to_sym) if recurrence.is_a?(Hash)
|
|
102
|
+
new(**fields, extra: record.extra)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_h
|
|
106
|
+
fields = super
|
|
107
|
+
extra = fields.delete(:extra) || {}
|
|
108
|
+
extra.transform_keys(&:to_s).merge(fields.transform_keys(&:to_s))
|
|
91
109
|
end
|
|
92
110
|
|
|
93
111
|
def due?(now)
|
|
@@ -111,6 +129,38 @@ module Samagotchi
|
|
|
111
129
|
def target_words = "#{mode} · #{head.to_s[0, 8]}"
|
|
112
130
|
end
|
|
113
131
|
|
|
132
|
+
FORMAT = StateFormat.new(
|
|
133
|
+
version: VERSION,
|
|
134
|
+
members: [
|
|
135
|
+
StateFormat.required(:id),
|
|
136
|
+
StateFormat.added(:title, in_version: VERSION, default: nil),
|
|
137
|
+
StateFormat.required(:anchor),
|
|
138
|
+
StateFormat.added(:head, in_version: VERSION, default: ->(values) { values[:anchor] }),
|
|
139
|
+
StateFormat.required(:mode),
|
|
140
|
+
StateFormat.required(:prompt),
|
|
141
|
+
StateFormat.required(:recurrence),
|
|
142
|
+
StateFormat.added(:busy, in_version: VERSION, default: "skip"),
|
|
143
|
+
StateFormat.added(:catch_up, in_version: VERSION, default: "once"),
|
|
144
|
+
StateFormat.added(:keep_links, in_version: VERSION, default: 5),
|
|
145
|
+
StateFormat.added(:max_failures, in_version: VERSION, default: 3),
|
|
146
|
+
StateFormat.added(:max_skips, in_version: VERSION, default: 12),
|
|
147
|
+
StateFormat.required(:next_due),
|
|
148
|
+
StateFormat.added(:due_was, in_version: VERSION, default: nil),
|
|
149
|
+
StateFormat.added(:last_fired, in_version: VERSION, default: nil),
|
|
150
|
+
StateFormat.added(:last_result, in_version: VERSION, default: nil),
|
|
151
|
+
StateFormat.added(:firing_since, in_version: VERSION, default: nil),
|
|
152
|
+
StateFormat.added(:firing_token, in_version: VERSION, default: nil),
|
|
153
|
+
StateFormat.added(:pending_session_id, in_version: VERSION, default: nil),
|
|
154
|
+
StateFormat.added(:pending_turn_id, in_version: VERSION, default: nil),
|
|
155
|
+
StateFormat.added(:failures, in_version: VERSION, default: 0),
|
|
156
|
+
StateFormat.added(:skips, in_version: VERSION, default: 0),
|
|
157
|
+
StateFormat.added(:enabled, in_version: VERSION, default: true),
|
|
158
|
+
StateFormat.added(:created_at, in_version: VERSION, default: nil),
|
|
159
|
+
StateFormat.extra(:extra, in_version: VERSION)
|
|
160
|
+
],
|
|
161
|
+
document_members: [StateFormat.added(:last_tick_at, in_version: VERSION, default: nil)]
|
|
162
|
+
)
|
|
163
|
+
|
|
114
164
|
# What one entry did in a tick: "fired", "skipped", "missed", "failed" or
|
|
115
165
|
# "dry" (a --dry-run's "would fire"). +detail+ is the reason.
|
|
116
166
|
Outcome = Data.define(:id, :title, :status, :detail, :entry) do
|
|
@@ -144,10 +194,20 @@ module Samagotchi
|
|
|
144
194
|
# @param include_disabled [Boolean] the disabled and the fired one-offs too
|
|
145
195
|
# @return [Array<Entry>] every entry, the earliest due first
|
|
146
196
|
def entries(include_disabled: false)
|
|
147
|
-
list = read.sort_by { |entry| [entry.next_due.to_s, entry.id] }
|
|
197
|
+
list = read.entries.grep(Entry).sort_by { |entry| [entry.next_due.to_s, entry.id] }
|
|
148
198
|
include_disabled ? list : list.select(&:enabled)
|
|
149
199
|
end
|
|
150
200
|
|
|
201
|
+
def protected_session_ids
|
|
202
|
+
index = Index.new(@state_dir)
|
|
203
|
+
entries.flat_map { |entry| [entry.anchor, entry.head, target_of(entry, index).first] }.compact.uniq
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def protecting(session_id)
|
|
207
|
+
index = Index.new(@state_dir)
|
|
208
|
+
entries.find { |entry| target_of(entry, index).first == session_id }
|
|
209
|
+
end
|
|
210
|
+
|
|
151
211
|
# @param id_or_name [String] an id, a unique prefix of one, or a title
|
|
152
212
|
# @return [Entry, nil]
|
|
153
213
|
# @raise [Usage] the prefix or the title names more than one schedule
|
|
@@ -155,7 +215,7 @@ module Samagotchi
|
|
|
155
215
|
given = id_or_name.to_s.strip
|
|
156
216
|
return nil if given.empty?
|
|
157
217
|
|
|
158
|
-
list = read
|
|
218
|
+
list = read.entries.grep(Entry)
|
|
159
219
|
exact = list.find { |entry| entry.id == given }
|
|
160
220
|
return exact if exact
|
|
161
221
|
|
|
@@ -188,6 +248,9 @@ module Samagotchi
|
|
|
188
248
|
|
|
189
249
|
prompt = prompt.to_s
|
|
190
250
|
raise Usage, "a schedule needs a prompt" if prompt.strip.empty?
|
|
251
|
+
# It fires from a schedule, never a person: a shell line would be
|
|
252
|
+
# refused at every firing (CommandIntake); a /word one is the model's text.
|
|
253
|
+
raise Usage, CommandIntake::REFUSED if CommandIntake.shell_line?(prompt)
|
|
191
254
|
if prompt.bytesize > PROMPT_MAX_BYTES
|
|
192
255
|
raise Usage, "the prompt is #{prompt.bytesize} bytes; the limit is 16 KiB (#{PROMPT_MAX_BYTES} bytes)"
|
|
193
256
|
end
|
|
@@ -223,10 +286,10 @@ module Samagotchi
|
|
|
223
286
|
max_failures: int_or_default(max_failures, "schedule.default_max_failures", 3, min: 1),
|
|
224
287
|
max_skips: int_or_default(max_skips, "schedule.default_max_skips", 12, min: 1),
|
|
225
288
|
next_due: next_due_for(recurrence, from: now).utc.iso8601, due_was: nil,
|
|
226
|
-
last_fired: nil, last_result: nil, firing_since: nil,
|
|
227
|
-
failures: 0, skips: 0, enabled: true, created_at: now.utc.iso8601
|
|
289
|
+
last_fired: nil, last_result: nil, firing_since: nil, firing_token: nil, pending_session_id: nil,
|
|
290
|
+
pending_turn_id: nil, failures: 0, skips: 0, enabled: true, created_at: now.utc.iso8601, extra: {}
|
|
228
291
|
)
|
|
229
|
-
write_under_lock { |
|
|
292
|
+
write_under_lock { |file| file.entries << entry }
|
|
230
293
|
entry
|
|
231
294
|
end
|
|
232
295
|
|
|
@@ -284,6 +347,7 @@ module Samagotchi
|
|
|
284
347
|
def tick(dry_run: false, now: @clock.call)
|
|
285
348
|
outcomes = []
|
|
286
349
|
pending = []
|
|
350
|
+
prune_after = []
|
|
287
351
|
index = nil
|
|
288
352
|
|
|
289
353
|
FileUtils.mkdir_p(@store_dir)
|
|
@@ -295,27 +359,65 @@ module Samagotchi
|
|
|
295
359
|
return Tick.new(locked: true, outcomes: [])
|
|
296
360
|
end
|
|
297
361
|
|
|
298
|
-
|
|
362
|
+
file = read(strict: true)
|
|
363
|
+
list = file.entries.grep(Entry)
|
|
299
364
|
# Ordered by the moment each entry is due, then id: with one chain
|
|
300
365
|
# firing per tick, the entries of that chain take turns (a skipped
|
|
301
366
|
# entry keeps its `next_due`, so the earliest waiting one goes next).
|
|
302
367
|
# File order would let the first entry added win every tick and starve
|
|
303
368
|
# the rest into their max_skips.
|
|
304
|
-
|
|
369
|
+
updated = file.entries.dup
|
|
370
|
+
pending_ids = []
|
|
371
|
+
in_flight = []
|
|
372
|
+
resolved_chains = []
|
|
373
|
+
prune_after = []
|
|
374
|
+
updated.each_with_index do |entry, position|
|
|
375
|
+
next unless entry.is_a?(Entry)
|
|
376
|
+
|
|
377
|
+
if entry.firing_since && !entry.pending_turn_id && firing_expired?(entry, now)
|
|
378
|
+
interrupted = failed(entry, "interrupted")
|
|
379
|
+
interrupted = interrupted.with(enabled: false) if entry.recurrence[:kind] == "date"
|
|
380
|
+
updated[position] = interrupted
|
|
381
|
+
pending_ids << entry.id
|
|
382
|
+
outcomes << Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "interrupted",
|
|
383
|
+
entry: interrupted)
|
|
384
|
+
next
|
|
385
|
+
end
|
|
386
|
+
next unless entry.pending_turn_id
|
|
387
|
+
|
|
388
|
+
outcome = resolve_pending(entry, now)
|
|
389
|
+
pending_ids << entry.id
|
|
390
|
+
if outcome
|
|
391
|
+
updated[position] = finish_pending(entry, outcome)
|
|
392
|
+
outcomes << outcome
|
|
393
|
+
if outcome.status == "fired" && entry.mode == CHAIN
|
|
394
|
+
resolved_chains << entry
|
|
395
|
+
prune_after << entry if entry.keep_links.positive?
|
|
396
|
+
end
|
|
397
|
+
else
|
|
398
|
+
in_flight << entry
|
|
399
|
+
detail = pending_detail(entry)
|
|
400
|
+
outcomes << Outcome.new(id: entry.id, title: entry.title, status: "pending", detail: detail, entry: entry)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
due = list.select { |entry| entry.enabled && !pending_ids.include?(entry.id) && entry.due?(now) }
|
|
305
404
|
.sort_by { |entry| [entry.next_due.to_s, entry.id] }
|
|
306
|
-
|
|
405
|
+
if due.empty?
|
|
406
|
+
write(file: file.with(entries: updated, attributes: file.attributes.merge(last_tick_at: now.utc.iso8601))) unless dry_run
|
|
407
|
+
next
|
|
408
|
+
end
|
|
307
409
|
|
|
308
410
|
# One session listing for the whole tick: a chain walk re-globbing
|
|
309
411
|
# every session per call is the tick's dominant cost.
|
|
310
412
|
index = Index.new(@state_dir)
|
|
311
|
-
updated = list.dup
|
|
312
413
|
# One firing per chain per tick. A second chain entry of the same chain
|
|
313
414
|
# would continue the link the first one just made: that link has no
|
|
314
415
|
# worker lock yet and is not in this tick's index, so the busy check
|
|
315
416
|
# cannot see it, and its prompt would be archived before it ever ran.
|
|
316
|
-
fired_chains = {}
|
|
417
|
+
fired_chains = resolved_chains.to_h { |entry| [chain_key(index, entry), true] }
|
|
418
|
+
pending_chains = in_flight.select { |entry| entry.mode == CHAIN }.map { |entry| chain_key(index, entry) }
|
|
317
419
|
due.each do |entry|
|
|
318
|
-
position = updated.index { |candidate| candidate.id == entry.id }
|
|
420
|
+
position = updated.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
|
|
319
421
|
verdict = check(entry, index)
|
|
320
422
|
if verdict
|
|
321
423
|
kind, reason = verdict
|
|
@@ -332,6 +434,12 @@ module Samagotchi
|
|
|
332
434
|
outcomes << Outcome.new(id: entry.id, title: entry.title, status: "skipped", detail: reason, entry: entry)
|
|
333
435
|
next
|
|
334
436
|
end
|
|
437
|
+
if entry.mode == CHAIN && pending_chains.include?(chain_key(index, entry))
|
|
438
|
+
reason = "a schedule turn is still pending in this chain"
|
|
439
|
+
updated[position] = skipped(entry, reason) unless dry_run
|
|
440
|
+
outcomes << Outcome.new(id: entry.id, title: entry.title, status: "skipped", detail: reason, entry: entry)
|
|
441
|
+
next
|
|
442
|
+
end
|
|
335
443
|
if dry_run
|
|
336
444
|
fired_chains[chain_key(index, entry)] = true if entry.mode == CHAIN
|
|
337
445
|
outcomes << Outcome.new(id: entry.id, title: entry.title, status: "dry", detail: entry.recurrence_words, entry: entry)
|
|
@@ -347,10 +455,16 @@ module Samagotchi
|
|
|
347
455
|
pending << advanced
|
|
348
456
|
fired_chains[chain_key(index, entry)] = true if entry.mode == CHAIN
|
|
349
457
|
end
|
|
350
|
-
write(
|
|
458
|
+
write(file: file.with(entries: updated, attributes: file.attributes.merge(last_tick_at: now.utc.iso8601))) unless dry_run
|
|
351
459
|
end
|
|
352
460
|
|
|
353
|
-
pending.each
|
|
461
|
+
pending.each do |entry|
|
|
462
|
+
outcome = fire_and_record(entry, index)
|
|
463
|
+
outcomes << outcome
|
|
464
|
+
prune_after << outcome.entry if outcome.status == "fired" && entry.mode == CHAIN && entry.keep_links.positive?
|
|
465
|
+
end
|
|
466
|
+
index ||= Index.new(@state_dir) unless prune_after.empty?
|
|
467
|
+
prune_after.each { |entry| prune(entry, index) }
|
|
354
468
|
Tick.new(locked: false, outcomes: outcomes)
|
|
355
469
|
end
|
|
356
470
|
|
|
@@ -358,22 +472,53 @@ module Samagotchi
|
|
|
358
472
|
# A refusal is recorded exactly as a tick records one — the counters move,
|
|
359
473
|
# and max_skips or max_failures can disable a schedule that can never run.
|
|
360
474
|
# `due_was` is not involved: run-now is not a scheduled occurrence, so
|
|
361
|
-
# there is no clock to put back and next_due stays where it was.
|
|
475
|
+
# there is no clock to put back and next_due stays where it was. Reserve the
|
|
476
|
+
# firing under the file lock before the action, the way a tick does.
|
|
362
477
|
# @return [Outcome]
|
|
363
478
|
def run_now(id_or_name)
|
|
364
479
|
entry = lookup(id_or_name)
|
|
365
480
|
return Outcome.new(id: id_or_name.to_s, status: "failed", detail: "no such schedule") if entry.nil?
|
|
366
481
|
|
|
367
482
|
index = Index.new(@state_dir)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
483
|
+
reserved = nil
|
|
484
|
+
outcome = nil
|
|
485
|
+
write_under_lock do |file|
|
|
486
|
+
position = file.entries.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
|
|
487
|
+
if position.nil?
|
|
488
|
+
outcome = Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "no such schedule")
|
|
489
|
+
next
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
current = file.entries[position]
|
|
493
|
+
if current.pending_turn_id
|
|
494
|
+
outcome = Outcome.new(id: current.id, title: current.title, status: "pending",
|
|
495
|
+
detail: "already waiting for turn #{current.pending_turn_id}", entry: current)
|
|
496
|
+
next
|
|
497
|
+
end
|
|
498
|
+
if current.firing_since
|
|
499
|
+
outcome = Outcome.new(id: current.id, title: current.title, status: "pending",
|
|
500
|
+
detail: "already firing since #{current.firing_since}", entry: current)
|
|
501
|
+
next
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
verdict = check(current, index)
|
|
505
|
+
if verdict
|
|
506
|
+
kind, reason = verdict
|
|
507
|
+
status = kind == :skip ? "skipped" : "failed"
|
|
508
|
+
updated = status == "skipped" ? skipped(current, reason) : failed(current, reason)
|
|
509
|
+
file.entries[position] = updated
|
|
510
|
+
outcome = Outcome.new(id: current.id, title: current.title, status: status, detail: reason, entry: updated)
|
|
511
|
+
next
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
reserved = advance(current, @clock.call)
|
|
515
|
+
file.entries[position] = reserved
|
|
374
516
|
end
|
|
375
517
|
|
|
376
|
-
|
|
518
|
+
return outcome if outcome
|
|
519
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "no such schedule") unless reserved
|
|
520
|
+
|
|
521
|
+
fire_and_record(reserved, index)
|
|
377
522
|
end
|
|
378
523
|
|
|
379
524
|
# Minutes between the hub's ticks when schedule.auto_tick is on.
|
|
@@ -382,6 +527,20 @@ module Samagotchi
|
|
|
382
527
|
value.positive? ? value : 5
|
|
383
528
|
end
|
|
384
529
|
|
|
530
|
+
def tick_warning(now: @clock.call)
|
|
531
|
+
return if Config.get("schedule.auto_tick")
|
|
532
|
+
|
|
533
|
+
last_tick_at = read.attributes[:last_tick_at]
|
|
534
|
+
stale_after = self.class.tick_interval_minutes * 120
|
|
535
|
+
return if last_tick_at && now - Time.iso8601(last_tick_at.to_s) <= stale_after
|
|
536
|
+
|
|
537
|
+
"Warning: no schedule tick in the last #{stale_after / 60} minutes; set schedule.auto_tick: true or " \
|
|
538
|
+
"run `chi schedule tick` from cron."
|
|
539
|
+
rescue ArgumentError
|
|
540
|
+
"Warning: no schedule tick in the last #{self.class.tick_interval_minutes * 2} minutes; " \
|
|
541
|
+
"set schedule.auto_tick: true or run `chi schedule tick` from cron."
|
|
542
|
+
end
|
|
543
|
+
|
|
385
544
|
# A recurrence from the CLI's flags or the tool's arguments. Blank strings
|
|
386
545
|
# (what a model sends for the fields it did not use) count as absent.
|
|
387
546
|
# @return [Hash] {kind:, ...}
|
|
@@ -522,21 +681,30 @@ module Samagotchi
|
|
|
522
681
|
# than reading as empty: a write starting from an empty list would rewrite
|
|
523
682
|
# the file and lose every schedule on it. Listing stays lenient.
|
|
524
683
|
def read(strict: false)
|
|
525
|
-
return
|
|
684
|
+
return FORMAT.read({}, collection: "schedules") unless File.exist?(path)
|
|
526
685
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
686
|
+
file = FORMAT.read(JSON.parse(File.read(path)), collection: "schedules")
|
|
687
|
+
file.with(entries: file.entries.map do |record|
|
|
688
|
+
if record.readable?
|
|
689
|
+
Entry.from_record(record)
|
|
690
|
+
else
|
|
691
|
+
Log.warn(:worker, "schedule_entry_unreadable",
|
|
692
|
+
id: record.raw.is_a?(Hash) ? record.raw["id"] : nil,
|
|
693
|
+
missing: record.missing.map(&:to_s))
|
|
694
|
+
record
|
|
695
|
+
end
|
|
696
|
+
end)
|
|
697
|
+
rescue JSON::ParserError, StateFormat::InvalidDocument => e
|
|
530
698
|
Log.warn(:worker, "store_unreadable", error: e.class.name, msg: e.message)
|
|
531
699
|
raise Corrupt, "#{path} is not readable JSON (#{e.message}): fix or remove it before changing schedules" if strict
|
|
532
700
|
|
|
533
|
-
|
|
701
|
+
FORMAT.read({}, collection: "schedules")
|
|
534
702
|
end
|
|
535
703
|
|
|
536
|
-
def write(
|
|
704
|
+
def write(file:)
|
|
537
705
|
FileUtils.mkdir_p(@store_dir)
|
|
538
706
|
FileUtils.mkdir_p(@state_dir)
|
|
539
|
-
body =
|
|
707
|
+
body = FORMAT.write(file, collection: "schedules", updated_at: Time.now.utc.iso8601)
|
|
540
708
|
AtomicFile.write(path, "#{JSON.pretty_generate(body)}\n")
|
|
541
709
|
end
|
|
542
710
|
|
|
@@ -546,9 +714,9 @@ module Samagotchi
|
|
|
546
714
|
FileUtils.mkdir_p(@state_dir)
|
|
547
715
|
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
548
716
|
lock.flock(File::LOCK_EX)
|
|
549
|
-
|
|
550
|
-
yield(
|
|
551
|
-
write(
|
|
717
|
+
file = read(strict: true)
|
|
718
|
+
yield(file)
|
|
719
|
+
write(file: file)
|
|
552
720
|
end
|
|
553
721
|
end
|
|
554
722
|
|
|
@@ -558,15 +726,15 @@ module Samagotchi
|
|
|
558
726
|
entry = lookup(id_or_name)
|
|
559
727
|
return nil if entry.nil?
|
|
560
728
|
|
|
561
|
-
write_under_lock do |
|
|
562
|
-
position =
|
|
729
|
+
write_under_lock do |file|
|
|
730
|
+
position = file.entries.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
|
|
563
731
|
next if position.nil?
|
|
564
732
|
|
|
565
733
|
if delete
|
|
566
|
-
|
|
567
|
-
|
|
734
|
+
file.entries[position] = nil
|
|
735
|
+
file.entries.compact!
|
|
568
736
|
else
|
|
569
|
-
|
|
737
|
+
file.entries[position] = entry.with(enabled: enabled, last_result: enabled ? "enabled" : "disabled")
|
|
570
738
|
end
|
|
571
739
|
end
|
|
572
740
|
entry
|
|
@@ -641,9 +809,10 @@ module Samagotchi
|
|
|
641
809
|
# arriving from an older file is still spent.
|
|
642
810
|
def reschedule(entry, now)
|
|
643
811
|
if entry.recurrence[:kind] == "date"
|
|
644
|
-
entry.with(next_due: nil, last_result: "missed", firing_since: nil, enabled: false)
|
|
812
|
+
entry.with(next_due: nil, last_result: "missed", firing_since: nil, firing_token: nil, enabled: false)
|
|
645
813
|
else
|
|
646
|
-
entry.with(next_due: next_due_for(entry.recurrence, from: now).utc.iso8601, last_result: "missed",
|
|
814
|
+
entry.with(next_due: next_due_for(entry.recurrence, from: now).utc.iso8601, last_result: "missed",
|
|
815
|
+
firing_since: nil, firing_token: nil)
|
|
647
816
|
end
|
|
648
817
|
end
|
|
649
818
|
|
|
@@ -657,20 +826,120 @@ module Samagotchi
|
|
|
657
826
|
one_off = entry.recurrence[:kind] == "date"
|
|
658
827
|
entry.with(due_was: entry.next_due,
|
|
659
828
|
next_due: one_off ? nil : next_due_for(entry.recurrence, from: now).utc.iso8601,
|
|
660
|
-
last_fired: now.utc.iso8601, firing_since: now.utc.iso8601,
|
|
829
|
+
last_fired: now.utc.iso8601, firing_since: now.utc.iso8601, firing_token: SecureRandom.uuid,
|
|
830
|
+
last_result: nil,
|
|
661
831
|
enabled: one_off ? false : entry.enabled)
|
|
662
832
|
end
|
|
663
833
|
|
|
664
834
|
def skipped(entry, reason)
|
|
665
835
|
skips = entry.skips + 1
|
|
666
836
|
disabled = skips >= entry.max_skips
|
|
667
|
-
entry.with(skips: skips, firing_since: nil,
|
|
837
|
+
entry.with(skips: skips, firing_since: nil, firing_token: nil, pending_session_id: nil, pending_turn_id: nil,
|
|
668
838
|
last_result: disabled ? "disabled: #{reason}" : "skipped", enabled: !disabled)
|
|
669
839
|
end
|
|
670
840
|
|
|
671
841
|
def failed(entry, reason)
|
|
672
842
|
failures = entry.failures + 1
|
|
673
|
-
entry.with(failures: failures, firing_since: nil,
|
|
843
|
+
entry.with(failures: failures, firing_since: nil, firing_token: nil, pending_session_id: nil, pending_turn_id: nil,
|
|
844
|
+
last_result: reason, enabled: failures < entry.max_failures)
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def pending_detail(entry)
|
|
848
|
+
session = Session.load(entry.pending_session_id, state_dir: @state_dir)
|
|
849
|
+
question = session.pending_question
|
|
850
|
+
return "turn is waiting for an answer" if question_started?(question, entry)
|
|
851
|
+
|
|
852
|
+
"waiting for turn #{entry.pending_turn_id}"
|
|
853
|
+
rescue Session::NotFound
|
|
854
|
+
"waiting for turn #{entry.pending_turn_id}"
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def resolve_pending(entry, now)
|
|
858
|
+
session = Session.load(entry.pending_session_id, state_dir: @state_dir)
|
|
859
|
+
turn = session.last_turn if session.last_turn&.enqueued_id == entry.pending_turn_id
|
|
860
|
+
turn ||= TurnCheckpoint.settled_turn(Session.session_dir(entry.pending_session_id, state_dir: @state_dir),
|
|
861
|
+
entry.pending_turn_id)
|
|
862
|
+
if SessionInbox.reply_for(Session.session_dir(entry.pending_session_id, state_dir: @state_dir), entry.pending_turn_id)
|
|
863
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "fired", detail: "turn completed", entry: entry)
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
question = session.pending_question
|
|
867
|
+
if question_started?(question, entry) && !question_after_turn?(question, turn)
|
|
868
|
+
return nil unless pending_expired?(entry, now)
|
|
869
|
+
|
|
870
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed",
|
|
871
|
+
detail: "no outcome", entry: entry)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
if turn
|
|
875
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "fired", detail: "turn completed", entry: entry) if
|
|
876
|
+
turn.outcome == "completed"
|
|
877
|
+
|
|
878
|
+
reason = [turn.outcome, turn.error_kind, turn.cancel_reason, turn.stopped_by].compact.join(": ")
|
|
879
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed",
|
|
880
|
+
detail: "turn #{reason.empty? ? "did not complete" : reason}", entry: entry)
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "no outcome", entry: entry) if
|
|
884
|
+
pending_expired?(entry, now)
|
|
885
|
+
|
|
886
|
+
nil
|
|
887
|
+
rescue Session::NotFound
|
|
888
|
+
Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "session gone", entry: entry)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def question_after_turn?(question, turn)
|
|
892
|
+
return false unless question&.created_at && turn&.ended_at
|
|
893
|
+
|
|
894
|
+
Time.iso8601(question.created_at) > Time.iso8601(turn.ended_at)
|
|
895
|
+
rescue ArgumentError
|
|
896
|
+
false
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def question_started?(question, entry)
|
|
900
|
+
return false unless question&.created_at && entry.firing_since
|
|
901
|
+
|
|
902
|
+
Time.iso8601(question.created_at) >= Time.iso8601(entry.firing_since)
|
|
903
|
+
rescue ArgumentError
|
|
904
|
+
false
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def firing_expired?(entry, now)
|
|
908
|
+
Time.iso8601(entry.firing_since) + FIRING_TIMEOUT <= now
|
|
909
|
+
rescue ArgumentError
|
|
910
|
+
false
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def pending_expired?(entry, now)
|
|
914
|
+
return true unless entry.firing_since
|
|
915
|
+
|
|
916
|
+
Time.iso8601(entry.firing_since) + pending_window(entry) <= now
|
|
917
|
+
rescue ArgumentError
|
|
918
|
+
true
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def pending_window(entry)
|
|
922
|
+
seconds = case entry.recurrence[:kind]
|
|
923
|
+
when "every"
|
|
924
|
+
self.class.interval_seconds(entry.recurrence[:interval])
|
|
925
|
+
when "weekday"
|
|
926
|
+
# Weekly intervals exceed the cap; for run-now, due_was may
|
|
927
|
+
# equal next_due and cannot represent that interval.
|
|
928
|
+
PENDING_MAX_SECONDS
|
|
929
|
+
end
|
|
930
|
+
(seconds || PENDING_MAX_SECONDS).clamp(PENDING_MIN_SECONDS, PENDING_MAX_SECONDS)
|
|
931
|
+
rescue ArgumentError, TypeError
|
|
932
|
+
PENDING_MAX_SECONDS
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def finish_pending(entry, outcome)
|
|
936
|
+
if outcome.status == "fired"
|
|
937
|
+
entry.with(firing_since: nil, firing_token: nil, due_was: nil, pending_session_id: nil, pending_turn_id: nil,
|
|
938
|
+
last_result: outcome.detail, failures: 0, skips: 0)
|
|
939
|
+
else
|
|
940
|
+
updated = failed(entry, outcome.detail).with(due_was: nil)
|
|
941
|
+
entry.recurrence[:kind] == "date" ? updated.with(enabled: false) : updated
|
|
942
|
+
end
|
|
674
943
|
end
|
|
675
944
|
|
|
676
945
|
# :skip (busy now, try the next tick) or :fail (the target is gone or
|
|
@@ -716,12 +985,13 @@ module Samagotchi
|
|
|
716
985
|
def busy_reason(entry, index, target)
|
|
717
986
|
dir = Session.session_dir(target, state_dir: @state_dir)
|
|
718
987
|
owner = SessionManager.session_owner(target, state_dir: @state_dir)
|
|
719
|
-
|
|
988
|
+
presence = WorkerPresence.read(target, state_dir: @state_dir, owner: owner, link: :look)
|
|
989
|
+
link = presence.link
|
|
720
990
|
|
|
721
|
-
if
|
|
991
|
+
if presence.worker_owned?
|
|
722
992
|
# A worker that has not published its Bridge yet (or is closing it):
|
|
723
993
|
# nothing can be read from it, and firing now would race with it.
|
|
724
|
-
return [:skip, "its worker is starting or leaving"] unless
|
|
994
|
+
return [:skip, "its worker is starting or leaving"] unless presence.live?
|
|
725
995
|
|
|
726
996
|
activity = ask_activity(link, target)
|
|
727
997
|
if activity == :old
|
|
@@ -746,6 +1016,11 @@ module Samagotchi
|
|
|
746
1016
|
|
|
747
1017
|
nil
|
|
748
1018
|
end
|
|
1019
|
+
# A worker being started for it holds no lock yet (another ticker's
|
|
1020
|
+
# firing just continued the chain to it, a resume): a chain firing
|
|
1021
|
+
# would archive it from under that worker (its archive refuses it too).
|
|
1022
|
+
# A queued turn is fine: the starting worker takes it.
|
|
1023
|
+
return [:skip, "its worker is starting or leaving"] if entry.mode == CHAIN && presence.starting?
|
|
749
1024
|
|
|
750
1025
|
file_busy_reason(index.session(target), dir)
|
|
751
1026
|
end
|
|
@@ -784,7 +1059,6 @@ module Samagotchi
|
|
|
784
1059
|
def fire_and_record(entry, index)
|
|
785
1060
|
outcome = fire(entry, index)
|
|
786
1061
|
record(entry, outcome)
|
|
787
|
-
prune(outcome.entry, index) if outcome.status == "fired" && entry.mode == CHAIN && entry.keep_links.positive?
|
|
788
1062
|
outcome
|
|
789
1063
|
end
|
|
790
1064
|
|
|
@@ -800,12 +1074,25 @@ module Samagotchi
|
|
|
800
1074
|
def fire_chain(entry, index)
|
|
801
1075
|
head, _reason = target_of(entry, index)
|
|
802
1076
|
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "the chain is gone", entry: entry) if head.nil?
|
|
1077
|
+
# One stored before #add refused it: its link would refuse the line,
|
|
1078
|
+
# so it fails here, with no link started and nothing archived.
|
|
1079
|
+
if CommandIntake.shell_line?(entry.prompt)
|
|
1080
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: CommandIntake::REFUSED, entry: entry)
|
|
1081
|
+
end
|
|
803
1082
|
|
|
804
1083
|
# `last:<head>` so SessionContinue.run's own TRIES/`:moved` loop follows
|
|
805
|
-
# a continue a human made between here and its lock.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1084
|
+
# a continue a human made between here and its lock. The link's first
|
|
1085
|
+
# turn is the schedule's (its origin), as a same-mode firing's is.
|
|
1086
|
+
link = SessionContinue.run("last:#{head}", prompt: entry.prompt, state_dir: @state_dir,
|
|
1087
|
+
client_id: ClientId.schedule(entry.id))
|
|
1088
|
+
turn_id = link.first_turn_id
|
|
1089
|
+
if turn_id.to_s.empty?
|
|
1090
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "the new link has no turn id",
|
|
1091
|
+
entry: entry)
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
Outcome.new(id: entry.id, title: entry.title, status: "pending", detail: "new link #{link.id[0, 8]}",
|
|
1095
|
+
entry: entry.with(head: link.id, pending_session_id: link.id, pending_turn_id: turn_id))
|
|
809
1096
|
rescue SessionContinue::Refused => e
|
|
810
1097
|
classify(entry, e.reason, e.message)
|
|
811
1098
|
rescue SessionManager::ArchiveRefused => e
|
|
@@ -820,14 +1107,25 @@ module Samagotchi
|
|
|
820
1107
|
# listing and this moment archives the target it just resolved to.
|
|
821
1108
|
dir = Session.session_dir(target, state_dir: @state_dir)
|
|
822
1109
|
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "archived", entry: entry) if ArchiveStore.archived?(dir)
|
|
1110
|
+
# One stored before #add refused it: the Bridge would refuse it, but a
|
|
1111
|
+
# delivery that falls back to the input file is accepted.
|
|
1112
|
+
if CommandIntake.shell_line?(entry.prompt)
|
|
1113
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: CommandIntake::REFUSED, entry: entry)
|
|
1114
|
+
end
|
|
823
1115
|
|
|
824
1116
|
result = SessionManager.deliver_turn(target, prompt: entry.prompt,
|
|
825
1117
|
client_id: ClientId.schedule(entry.id),
|
|
826
1118
|
delivery: Delivery::QUEUE, state_dir: @state_dir)
|
|
827
1119
|
if result.accepted?
|
|
1120
|
+
if result.enqueued_id.to_s.empty?
|
|
1121
|
+
return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "the accepted turn has no id",
|
|
1122
|
+
entry: entry)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
828
1125
|
# The head follows the turn: the next tick resolves from the link this
|
|
829
1126
|
# one fired into, so the walk survives a link deleted in between.
|
|
830
|
-
Outcome.new(id: entry.id, title: entry.title, status: "
|
|
1127
|
+
Outcome.new(id: entry.id, title: entry.title, status: "pending", detail: "queued a turn",
|
|
1128
|
+
entry: entry.with(head: target, pending_session_id: target, pending_turn_id: result.enqueued_id))
|
|
831
1129
|
else
|
|
832
1130
|
Outcome.new(id: entry.id, title: entry.title, status: "failed",
|
|
833
1131
|
detail: "the turn was not accepted (#{result.status}: #{result.error || result.detail})", entry: entry)
|
|
@@ -842,32 +1140,32 @@ module Samagotchi
|
|
|
842
1140
|
# A run-now refused before anything was tried: the same counters a tick
|
|
843
1141
|
# moves for a skip or a failure. `due_was` is untouched — run-now is not a
|
|
844
1142
|
# scheduled occurrence, so there is no occurrence to put the clock back to.
|
|
845
|
-
def record_refusal(entry, status, reason)
|
|
846
|
-
write_under_lock do |list|
|
|
847
|
-
position = list.index { |candidate| candidate.id == entry.id }
|
|
848
|
-
next if position.nil?
|
|
849
|
-
|
|
850
|
-
stored = list[position]
|
|
851
|
-
list[position] = status == "skipped" ? skipped(stored, reason) : failed(stored, reason)
|
|
852
|
-
end
|
|
853
|
-
end
|
|
854
|
-
|
|
855
1143
|
# The result goes back under the lock before any pruning, so a prune that
|
|
856
1144
|
# raises midway cannot lose the new head.
|
|
857
1145
|
def record(entry, outcome)
|
|
858
|
-
write_under_lock do |
|
|
859
|
-
position =
|
|
860
|
-
|
|
1146
|
+
write_under_lock do |file|
|
|
1147
|
+
position = file.entries.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
|
|
1148
|
+
stored = position && file.entries[position]
|
|
1149
|
+
unless stored&.firing_token == entry.firing_token && !entry.firing_token.to_s.empty?
|
|
1150
|
+
Log.warn(:worker, "schedule_record_stale", id: entry.id, token: entry.firing_token)
|
|
1151
|
+
next
|
|
1152
|
+
end
|
|
861
1153
|
|
|
862
|
-
|
|
863
|
-
|
|
1154
|
+
updated = case outcome.status
|
|
1155
|
+
when "pending"
|
|
1156
|
+
stored.with(head: outcome.entry.head, pending_session_id: outcome.entry.pending_session_id,
|
|
1157
|
+
pending_turn_id: outcome.entry.pending_turn_id,
|
|
1158
|
+
firing_since: stored.firing_since || entry.firing_since,
|
|
1159
|
+
due_was: stored.due_was || entry.due_was, last_result: outcome.detail)
|
|
1160
|
+
when "fired"
|
|
864
1161
|
# Any success clears the skip and failure counters: twelve
|
|
865
1162
|
# skips spread over months do not kill a healthy schedule.
|
|
866
1163
|
# A `date` schedule is a one-off: `advance` already left it
|
|
867
1164
|
# with no next_due and disabled, and a firing keeps it so.
|
|
868
|
-
stored.with(head: outcome.entry.head, last_result: outcome.detail, firing_since: nil,
|
|
1165
|
+
stored.with(head: outcome.entry.head, last_result: outcome.detail, firing_since: nil, firing_token: nil,
|
|
1166
|
+
pending_session_id: nil, pending_turn_id: nil,
|
|
869
1167
|
failures: 0, skips: 0, last_fired: @clock.call.utc.iso8601)
|
|
870
|
-
|
|
1168
|
+
when "skipped"
|
|
871
1169
|
skipped(stored, outcome.detail).with(firing_since: nil)
|
|
872
1170
|
else
|
|
873
1171
|
failed(stored, outcome.detail)
|
|
@@ -876,8 +1174,8 @@ module Samagotchi
|
|
|
876
1174
|
# A skip or a failure is "not now", not "late": the occurrence the tick
|
|
877
1175
|
# tried comes back, so the next tick tries it again. max_skips and
|
|
878
1176
|
# max_failures bound how often that happens.
|
|
879
|
-
updated = updated.with(next_due: stored.due_was) if outcome.status
|
|
880
|
-
|
|
1177
|
+
updated = updated.with(next_due: stored.due_was) if %w[failed skipped].include?(outcome.status) && !stored.due_was.nil?
|
|
1178
|
+
file.entries[position] = updated
|
|
881
1179
|
end
|
|
882
1180
|
end
|
|
883
1181
|
|