samagotchi 0.160.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 +86 -1
- data/docs/architecture.md +7 -3
- data/docs/cli.md +6 -4
- data/docs/context.md +10 -1
- data/docs/guardrails.md +12 -3
- data/docs/internals/bridge.md +21 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +4 -2
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +37 -5
- data/docs/sessions.md +14 -11
- 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 +92 -42
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +56 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +2 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/engine/events.rb +12 -0
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- 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/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
- 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/installer.rb +79 -12
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/status.rb +1 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
- data/lib/samagotchi/memory_orphans.rb +40 -9
- 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/project_init.rb +3 -3
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +286 -48
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +42 -5
- data/lib/samagotchi/session_access/local.rb +45 -17
- data/lib/samagotchi/session_access/remote.rb +11 -4
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +16 -3
- data/lib/samagotchi/session_continue.rb +155 -21
- 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 +164 -104
- 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 +18 -7
- 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/delegate.rb +9 -4
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +7 -2
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- 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 +12 -8
- 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 +45 -7
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +41 -8
- 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 +5 -1
data/lib/samagotchi/schedules.rb
CHANGED
|
@@ -8,6 +8,7 @@ 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"
|
|
12
13
|
require_relative "command_intake"
|
|
13
14
|
require_relative "config"
|
|
@@ -20,6 +21,7 @@ require_relative "session_inbox"
|
|
|
20
21
|
require_relative "session_manager"
|
|
21
22
|
require_relative "session_row"
|
|
22
23
|
require_relative "state_format"
|
|
24
|
+
require_relative "turn_checkpoint"
|
|
23
25
|
require_relative "worker_link"
|
|
24
26
|
|
|
25
27
|
module Samagotchi
|
|
@@ -61,8 +63,16 @@ module Samagotchi
|
|
|
61
63
|
PROMPT_MAX_BYTES = SessionInbox::NOTE_MAX_BYTES
|
|
62
64
|
# Seconds a tick waits for a live worker's GET /activity.
|
|
63
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
|
|
64
71
|
# Refusals that mean "not now" rather than "this schedule is broken".
|
|
65
|
-
|
|
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
|
|
66
76
|
|
|
67
77
|
# The add itself is wrong (no prompt, two recurrence kinds, a scratch
|
|
68
78
|
# target): a usage error, exit 2.
|
|
@@ -81,7 +91,8 @@ module Samagotchi
|
|
|
81
91
|
# puts the clock back to it.
|
|
82
92
|
Entry = Data.define(:id, :title, :anchor, :head, :mode, :prompt, :recurrence, :busy, :catch_up,
|
|
83
93
|
:keep_links, :max_failures, :max_skips, :next_due, :due_was, :last_fired, :last_result,
|
|
84
|
-
:firing_since, :
|
|
94
|
+
:firing_since, :firing_token, :pending_session_id, :pending_turn_id, :failures, :skips, :enabled,
|
|
95
|
+
:created_at, :extra) do
|
|
85
96
|
# The recurrence comes back from JSON with string keys: every reader of
|
|
86
97
|
# it below matches on `recurrence[:kind]`, so it is symbolised here once.
|
|
87
98
|
def self.from_record(record)
|
|
@@ -138,12 +149,16 @@ module Samagotchi
|
|
|
138
149
|
StateFormat.added(:last_fired, in_version: VERSION, default: nil),
|
|
139
150
|
StateFormat.added(:last_result, in_version: VERSION, default: nil),
|
|
140
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),
|
|
141
155
|
StateFormat.added(:failures, in_version: VERSION, default: 0),
|
|
142
156
|
StateFormat.added(:skips, in_version: VERSION, default: 0),
|
|
143
157
|
StateFormat.added(:enabled, in_version: VERSION, default: true),
|
|
144
158
|
StateFormat.added(:created_at, in_version: VERSION, default: nil),
|
|
145
159
|
StateFormat.extra(:extra, in_version: VERSION)
|
|
146
|
-
]
|
|
160
|
+
],
|
|
161
|
+
document_members: [StateFormat.added(:last_tick_at, in_version: VERSION, default: nil)]
|
|
147
162
|
)
|
|
148
163
|
|
|
149
164
|
# What one entry did in a tick: "fired", "skipped", "missed", "failed" or
|
|
@@ -183,6 +198,16 @@ module Samagotchi
|
|
|
183
198
|
include_disabled ? list : list.select(&:enabled)
|
|
184
199
|
end
|
|
185
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
|
+
|
|
186
211
|
# @param id_or_name [String] an id, a unique prefix of one, or a title
|
|
187
212
|
# @return [Entry, nil]
|
|
188
213
|
# @raise [Usage] the prefix or the title names more than one schedule
|
|
@@ -261,8 +286,8 @@ module Samagotchi
|
|
|
261
286
|
max_failures: int_or_default(max_failures, "schedule.default_max_failures", 3, min: 1),
|
|
262
287
|
max_skips: int_or_default(max_skips, "schedule.default_max_skips", 12, min: 1),
|
|
263
288
|
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: {}
|
|
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: {}
|
|
266
291
|
)
|
|
267
292
|
write_under_lock { |file| file.entries << entry }
|
|
268
293
|
entry
|
|
@@ -322,6 +347,7 @@ module Samagotchi
|
|
|
322
347
|
def tick(dry_run: false, now: @clock.call)
|
|
323
348
|
outcomes = []
|
|
324
349
|
pending = []
|
|
350
|
+
prune_after = []
|
|
325
351
|
index = nil
|
|
326
352
|
|
|
327
353
|
FileUtils.mkdir_p(@store_dir)
|
|
@@ -333,26 +359,63 @@ module Samagotchi
|
|
|
333
359
|
return Tick.new(locked: true, outcomes: [])
|
|
334
360
|
end
|
|
335
361
|
|
|
336
|
-
file = read
|
|
362
|
+
file = read(strict: true)
|
|
337
363
|
list = file.entries.grep(Entry)
|
|
338
364
|
# Ordered by the moment each entry is due, then id: with one chain
|
|
339
365
|
# firing per tick, the entries of that chain take turns (a skipped
|
|
340
366
|
# entry keeps its `next_due`, so the earliest waiting one goes next).
|
|
341
367
|
# File order would let the first entry added win every tick and starve
|
|
342
368
|
# the rest into their max_skips.
|
|
343
|
-
|
|
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) }
|
|
344
404
|
.sort_by { |entry| [entry.next_due.to_s, entry.id] }
|
|
345
|
-
|
|
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
|
|
346
409
|
|
|
347
410
|
# One session listing for the whole tick: a chain walk re-globbing
|
|
348
411
|
# every session per call is the tick's dominant cost.
|
|
349
412
|
index = Index.new(@state_dir)
|
|
350
|
-
updated = file.entries.dup
|
|
351
413
|
# One firing per chain per tick. A second chain entry of the same chain
|
|
352
414
|
# would continue the link the first one just made: that link has no
|
|
353
415
|
# worker lock yet and is not in this tick's index, so the busy check
|
|
354
416
|
# cannot see it, and its prompt would be archived before it ever ran.
|
|
355
|
-
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) }
|
|
356
419
|
due.each do |entry|
|
|
357
420
|
position = updated.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
|
|
358
421
|
verdict = check(entry, index)
|
|
@@ -371,6 +434,12 @@ module Samagotchi
|
|
|
371
434
|
outcomes << Outcome.new(id: entry.id, title: entry.title, status: "skipped", detail: reason, entry: entry)
|
|
372
435
|
next
|
|
373
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
|
|
374
443
|
if dry_run
|
|
375
444
|
fired_chains[chain_key(index, entry)] = true if entry.mode == CHAIN
|
|
376
445
|
outcomes << Outcome.new(id: entry.id, title: entry.title, status: "dry", detail: entry.recurrence_words, entry: entry)
|
|
@@ -386,10 +455,16 @@ module Samagotchi
|
|
|
386
455
|
pending << advanced
|
|
387
456
|
fired_chains[chain_key(index, entry)] = true if entry.mode == CHAIN
|
|
388
457
|
end
|
|
389
|
-
write(file: file.with(entries: updated)) unless dry_run
|
|
458
|
+
write(file: file.with(entries: updated, attributes: file.attributes.merge(last_tick_at: now.utc.iso8601))) unless dry_run
|
|
390
459
|
end
|
|
391
460
|
|
|
392
|
-
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) }
|
|
393
468
|
Tick.new(locked: false, outcomes: outcomes)
|
|
394
469
|
end
|
|
395
470
|
|
|
@@ -397,22 +472,53 @@ module Samagotchi
|
|
|
397
472
|
# A refusal is recorded exactly as a tick records one — the counters move,
|
|
398
473
|
# and max_skips or max_failures can disable a schedule that can never run.
|
|
399
474
|
# `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.
|
|
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.
|
|
401
477
|
# @return [Outcome]
|
|
402
478
|
def run_now(id_or_name)
|
|
403
479
|
entry = lookup(id_or_name)
|
|
404
480
|
return Outcome.new(id: id_or_name.to_s, status: "failed", detail: "no such schedule") if entry.nil?
|
|
405
481
|
|
|
406
482
|
index = Index.new(@state_dir)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
413
516
|
end
|
|
414
517
|
|
|
415
|
-
|
|
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)
|
|
416
522
|
end
|
|
417
523
|
|
|
418
524
|
# Minutes between the hub's ticks when schedule.auto_tick is on.
|
|
@@ -421,6 +527,20 @@ module Samagotchi
|
|
|
421
527
|
value.positive? ? value : 5
|
|
422
528
|
end
|
|
423
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
|
+
|
|
424
544
|
# A recurrence from the CLI's flags or the tool's arguments. Blank strings
|
|
425
545
|
# (what a model sends for the fields it did not use) count as absent.
|
|
426
546
|
# @return [Hash] {kind:, ...}
|
|
@@ -689,9 +809,10 @@ module Samagotchi
|
|
|
689
809
|
# arriving from an older file is still spent.
|
|
690
810
|
def reschedule(entry, now)
|
|
691
811
|
if entry.recurrence[:kind] == "date"
|
|
692
|
-
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)
|
|
693
813
|
else
|
|
694
|
-
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)
|
|
695
816
|
end
|
|
696
817
|
end
|
|
697
818
|
|
|
@@ -705,20 +826,120 @@ module Samagotchi
|
|
|
705
826
|
one_off = entry.recurrence[:kind] == "date"
|
|
706
827
|
entry.with(due_was: entry.next_due,
|
|
707
828
|
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,
|
|
829
|
+
last_fired: now.utc.iso8601, firing_since: now.utc.iso8601, firing_token: SecureRandom.uuid,
|
|
830
|
+
last_result: nil,
|
|
709
831
|
enabled: one_off ? false : entry.enabled)
|
|
710
832
|
end
|
|
711
833
|
|
|
712
834
|
def skipped(entry, reason)
|
|
713
835
|
skips = entry.skips + 1
|
|
714
836
|
disabled = skips >= entry.max_skips
|
|
715
|
-
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,
|
|
716
838
|
last_result: disabled ? "disabled: #{reason}" : "skipped", enabled: !disabled)
|
|
717
839
|
end
|
|
718
840
|
|
|
719
841
|
def failed(entry, reason)
|
|
720
842
|
failures = entry.failures + 1
|
|
721
|
-
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
|
|
722
943
|
end
|
|
723
944
|
|
|
724
945
|
# :skip (busy now, try the next tick) or :fail (the target is gone or
|
|
@@ -764,12 +985,13 @@ module Samagotchi
|
|
|
764
985
|
def busy_reason(entry, index, target)
|
|
765
986
|
dir = Session.session_dir(target, state_dir: @state_dir)
|
|
766
987
|
owner = SessionManager.session_owner(target, state_dir: @state_dir)
|
|
767
|
-
|
|
988
|
+
presence = WorkerPresence.read(target, state_dir: @state_dir, owner: owner, link: :look)
|
|
989
|
+
link = presence.link
|
|
768
990
|
|
|
769
|
-
if
|
|
991
|
+
if presence.worker_owned?
|
|
770
992
|
# A worker that has not published its Bridge yet (or is closing it):
|
|
771
993
|
# nothing can be read from it, and firing now would race with it.
|
|
772
|
-
return [:skip, "its worker is starting or leaving"] unless
|
|
994
|
+
return [:skip, "its worker is starting or leaving"] unless presence.live?
|
|
773
995
|
|
|
774
996
|
activity = ask_activity(link, target)
|
|
775
997
|
if activity == :old
|
|
@@ -794,6 +1016,11 @@ module Samagotchi
|
|
|
794
1016
|
|
|
795
1017
|
nil
|
|
796
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?
|
|
797
1024
|
|
|
798
1025
|
file_busy_reason(index.session(target), dir)
|
|
799
1026
|
end
|
|
@@ -832,7 +1059,6 @@ module Samagotchi
|
|
|
832
1059
|
def fire_and_record(entry, index)
|
|
833
1060
|
outcome = fire(entry, index)
|
|
834
1061
|
record(entry, outcome)
|
|
835
|
-
prune(outcome.entry, index) if outcome.status == "fired" && entry.mode == CHAIN && entry.keep_links.positive?
|
|
836
1062
|
outcome
|
|
837
1063
|
end
|
|
838
1064
|
|
|
@@ -859,8 +1085,14 @@ module Samagotchi
|
|
|
859
1085
|
# turn is the schedule's (its origin), as a same-mode firing's is.
|
|
860
1086
|
link = SessionContinue.run("last:#{head}", prompt: entry.prompt, state_dir: @state_dir,
|
|
861
1087
|
client_id: ClientId.schedule(entry.id))
|
|
862
|
-
|
|
863
|
-
|
|
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))
|
|
864
1096
|
rescue SessionContinue::Refused => e
|
|
865
1097
|
classify(entry, e.reason, e.message)
|
|
866
1098
|
rescue SessionManager::ArchiveRefused => e
|
|
@@ -885,9 +1117,15 @@ module Samagotchi
|
|
|
885
1117
|
client_id: ClientId.schedule(entry.id),
|
|
886
1118
|
delivery: Delivery::QUEUE, state_dir: @state_dir)
|
|
887
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
|
+
|
|
888
1125
|
# The head follows the turn: the next tick resolves from the link this
|
|
889
1126
|
# one fired into, so the walk survives a link deleted in between.
|
|
890
|
-
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))
|
|
891
1129
|
else
|
|
892
1130
|
Outcome.new(id: entry.id, title: entry.title, status: "failed",
|
|
893
1131
|
detail: "the turn was not accepted (#{result.status}: #{result.error || result.detail})", entry: entry)
|
|
@@ -902,32 +1140,32 @@ module Samagotchi
|
|
|
902
1140
|
# A run-now refused before anything was tried: the same counters a tick
|
|
903
1141
|
# moves for a skip or a failure. `due_was` is untouched — run-now is not a
|
|
904
1142
|
# 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
1143
|
# The result goes back under the lock before any pruning, so a prune that
|
|
916
1144
|
# raises midway cannot lose the new head.
|
|
917
1145
|
def record(entry, outcome)
|
|
918
1146
|
write_under_lock do |file|
|
|
919
1147
|
position = file.entries.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
|
|
920
|
-
|
|
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
|
|
921
1153
|
|
|
922
|
-
|
|
923
|
-
|
|
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"
|
|
924
1161
|
# Any success clears the skip and failure counters: twelve
|
|
925
1162
|
# skips spread over months do not kill a healthy schedule.
|
|
926
1163
|
# A `date` schedule is a one-off: `advance` already left it
|
|
927
1164
|
# 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,
|
|
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,
|
|
929
1167
|
failures: 0, skips: 0, last_fired: @clock.call.utc.iso8601)
|
|
930
|
-
|
|
1168
|
+
when "skipped"
|
|
931
1169
|
skipped(stored, outcome.detail).with(firing_since: nil)
|
|
932
1170
|
else
|
|
933
1171
|
failed(stored, outcome.detail)
|
|
@@ -936,7 +1174,7 @@ module Samagotchi
|
|
|
936
1174
|
# A skip or a failure is "not now", not "late": the occurrence the tick
|
|
937
1175
|
# tried comes back, so the next tick tries it again. max_skips and
|
|
938
1176
|
# max_failures bound how often that happens.
|
|
939
|
-
updated = updated.with(next_due: stored.due_was) if outcome.status
|
|
1177
|
+
updated = updated.with(next_due: stored.due_was) if %w[failed skipped].include?(outcome.status) && !stored.due_was.nil?
|
|
940
1178
|
file.entries[position] = updated
|
|
941
1179
|
end
|
|
942
1180
|
end
|
|
@@ -673,7 +673,7 @@ module Samagotchi
|
|
|
673
673
|
return false
|
|
674
674
|
end
|
|
675
675
|
unless result.accepted?
|
|
676
|
-
@info.puts("#{session.id} #{
|
|
676
|
+
@info.puts("#{session.id} #{result.refusal_text}")
|
|
677
677
|
return false
|
|
678
678
|
end
|
|
679
679
|
|
|
@@ -685,19 +685,6 @@ module Samagotchi
|
|
|
685
685
|
|
|
686
686
|
SENT_COMMAND = "sent as a session command"
|
|
687
687
|
|
|
688
|
-
# A remote's refusals of what this machine checks before sending (a
|
|
689
|
-
# parent's /continue yes, a loosening /parent-answers, images to a
|
|
690
|
-
# model that can't see them): its turn route's error codes.
|
|
691
|
-
REMOTE_REFUSALS = %w[parent_answers_refused parent_continue_refused no_vision].freeze
|
|
692
|
-
|
|
693
|
-
# How a delivery that didn't go in reads: refused (a worker older than
|
|
694
|
-
# queueing would steer it; a remote refused it as chi send refuses it
|
|
695
|
-
# here) or failed.
|
|
696
|
-
def refused_word(result)
|
|
697
|
-
refused = result.error == Delivery::QUEUE_UNAVAILABLE["error"] || REMOTE_REFUSALS.include?(result.error)
|
|
698
|
-
refused ? "refused" : "failed"
|
|
699
|
-
end
|
|
700
|
-
|
|
701
688
|
# chi send's client id, or chi answer's for a parent agent's /continue
|
|
702
689
|
# yes (the worker holds that to its guardrails.parent_answers).
|
|
703
690
|
def client_id_for(prompt) = ParentGate.parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
|
|
@@ -773,7 +760,7 @@ module Samagotchi
|
|
|
773
760
|
end
|
|
774
761
|
result = @access.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, delivery: delivery)
|
|
775
762
|
unless result.accepted?
|
|
776
|
-
@info.puts("#{short} #{
|
|
763
|
+
@info.puts("#{short} #{result.refusal_text}")
|
|
777
764
|
return false
|
|
778
765
|
end
|
|
779
766
|
|