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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -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
- SKIP_REASONS = %w[busy queued offer busy_child queued_child offer_child continued open_children].freeze
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, :created_at) do
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
- # A file written by a newer chi may carry keys this one does not know:
86
- # they are dropped rather than raising in Data#initialize.
87
- def self.from_h(hash)
88
- fields = hash.transform_keys(&:to_sym).select { |key| Entry.members.include?(key) }
98
+ def self.from_record(record)
99
+ fields = record.attributes
89
100
  recurrence = fields[:recurrence]
90
- new(**fields, recurrence: recurrence.is_a?(Hash) ? recurrence.transform_keys(&:to_sym) : recurrence)
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 { |list| list << entry }
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
- list = read
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
- due = list.select { |entry| entry.enabled && entry.due?(now) }
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
- return Tick.new(locked: false, outcomes: []) if due.empty?
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(list: updated) unless dry_run
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 { |entry| outcomes << fire_and_record(entry, index) }
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
- verdict = check(entry, index)
369
- if verdict
370
- kind, reason = verdict
371
- status = kind == :skip ? "skipped" : "failed"
372
- record_refusal(entry, status, reason)
373
- return Outcome.new(id: entry.id, title: entry.title, status: status, detail: reason, entry: entry)
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
- fire_and_record(advance(entry, @clock.call), index)
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 [] unless File.exist?(path)
684
+ return FORMAT.read({}, collection: "schedules") unless File.exist?(path)
526
685
 
527
- data = JSON.parse(File.read(path))
528
- Array(data["schedules"]).map { |hash| Entry.from_h(hash) }
529
- rescue JSON::ParserError => e
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(list:)
704
+ def write(file:)
537
705
  FileUtils.mkdir_p(@store_dir)
538
706
  FileUtils.mkdir_p(@state_dir)
539
- body = { "version" => VERSION, "updated_at" => Time.now.utc.iso8601, "schedules" => list.map(&:to_h) }
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
- list = read(strict: true)
550
- yield(list)
551
- write(list: list)
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 |list|
562
- position = list.index { |candidate| candidate.id == entry.id }
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
- list[position] = nil
567
- list.compact!
734
+ file.entries[position] = nil
735
+ file.entries.compact!
568
736
  else
569
- list[position] = entry.with(enabled: enabled, last_result: enabled ? "enabled" : "disabled")
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", firing_since: nil)
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, last_result: nil,
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, last_result: reason, enabled: failures < entry.max_failures)
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
- link = WorkerLink.resolve(dir, unlink: false, owner: owner)
988
+ presence = WorkerPresence.read(target, state_dir: @state_dir, owner: owner, link: :look)
989
+ link = presence.link
720
990
 
721
- if link.worker_owned?
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 link.live?
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
- link = SessionContinue.run("last:#{head}", prompt: entry.prompt, state_dir: @state_dir)
807
- Outcome.new(id: entry.id, title: entry.title, status: "fired", detail: "new link #{link.id[0, 8]}",
808
- entry: entry.with(head: link.id))
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: "fired", detail: "queued a turn", entry: entry.with(head: target))
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 |list|
859
- position = list.index { |candidate| candidate.id == entry.id }
860
- next if position.nil?
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
- stored = list[position]
863
- updated = if outcome.status == "fired"
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
- elsif outcome.status == "skipped"
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 != "fired" && !stored.due_was.nil?
880
- list[position] = updated
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