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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +7 -3
  4. data/docs/cli.md +6 -4
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +12 -3
  7. data/docs/internals/bridge.md +21 -6
  8. data/docs/internals/bundles.md +24 -5
  9. data/docs/internals/objectives.md +8 -2
  10. data/docs/internals/remote-sessions.md +2 -1
  11. data/docs/memory.md +4 -2
  12. data/docs/plugins.md +13 -4
  13. data/docs/releasing.md +7 -4
  14. data/docs/scheduling.md +37 -5
  15. data/docs/sessions.md +14 -11
  16. data/docs/testing.md +9 -1
  17. data/lib/samagotchi/bridge/card_store.rb +31 -3
  18. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  19. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  20. data/lib/samagotchi/bridge.rb +92 -42
  21. data/lib/samagotchi/bridge_client.rb +9 -6
  22. data/lib/samagotchi/broadcast_command.rb +24 -8
  23. data/lib/samagotchi/bundle_command.rb +56 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/child_reports.rb +29 -23
  26. data/lib/samagotchi/client_id.rb +2 -0
  27. data/lib/samagotchi/compat.rb +32 -0
  28. data/lib/samagotchi/context_candidates_log.rb +10 -1
  29. data/lib/samagotchi/context_command.rb +1 -1
  30. data/lib/samagotchi/context_move.rb +6 -1
  31. data/lib/samagotchi/engine/events.rb +12 -0
  32. data/lib/samagotchi/engine/plugins.rb +1 -1
  33. data/lib/samagotchi/engine/steering.rb +2 -2
  34. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/targets.rb +25 -6
  37. data/lib/samagotchi/live_versions.rb +7 -2
  38. data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
  39. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  40. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  41. data/lib/samagotchi/memory_bundle/installer.rb +79 -12
  42. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  44. data/lib/samagotchi/memory_bundle/status.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
  47. data/lib/samagotchi/memory_orphans.rb +40 -9
  48. data/lib/samagotchi/note_command.rb +9 -1
  49. data/lib/samagotchi/note_delivery.rb +34 -24
  50. data/lib/samagotchi/note_move.rb +205 -0
  51. data/lib/samagotchi/objectives_command.rb +3 -1
  52. data/lib/samagotchi/owner_lock.rb +7 -1
  53. data/lib/samagotchi/project_init.rb +3 -3
  54. data/lib/samagotchi/review_start.rb +5 -3
  55. data/lib/samagotchi/schedule_command.rb +12 -0
  56. data/lib/samagotchi/schedules.rb +286 -48
  57. data/lib/samagotchi/send_command.rb +2 -15
  58. data/lib/samagotchi/session.rb +42 -5
  59. data/lib/samagotchi/session_access/local.rb +45 -17
  60. data/lib/samagotchi/session_access/remote.rb +11 -4
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  62. data/lib/samagotchi/session_access.rb +16 -3
  63. data/lib/samagotchi/session_continue.rb +155 -21
  64. data/lib/samagotchi/session_delete_command.rb +3 -1
  65. data/lib/samagotchi/session_inbox.rb +41 -1
  66. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  67. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  68. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  69. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  70. data/lib/samagotchi/session_manager/listing.rb +4 -1
  71. data/lib/samagotchi/session_manager/removal.rb +130 -26
  72. data/lib/samagotchi/session_manager/spawn.rb +164 -104
  73. data/lib/samagotchi/session_manager.rb +1 -0
  74. data/lib/samagotchi/session_retention.rb +53 -23
  75. data/lib/samagotchi/session_setup.rb +27 -7
  76. data/lib/samagotchi/starting_marker.rb +93 -24
  77. data/lib/samagotchi/state_format.rb +18 -7
  78. data/lib/samagotchi/system_one/runs.rb +6 -1
  79. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  80. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  81. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  82. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  83. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  84. data/lib/samagotchi/terminal_ui.rb +2 -3
  85. data/lib/samagotchi/tools/delegate.rb +9 -4
  86. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  87. data/lib/samagotchi/tools/send_note.rb +10 -4
  88. data/lib/samagotchi/turn_state.rb +108 -4
  89. data/lib/samagotchi/update_command.rb +7 -2
  90. data/lib/samagotchi/verify_fresh.rb +27 -9
  91. data/lib/samagotchi/version.rb +1 -1
  92. data/lib/samagotchi/web/app.rb +1 -1
  93. data/lib/samagotchi/web/public/app.js +2 -7
  94. data/lib/samagotchi/web/public/chains.js +4 -4
  95. data/lib/samagotchi/web/public/data.js +18 -2
  96. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  97. data/lib/samagotchi/web/public/session_fields.js +3 -2
  98. data/lib/samagotchi/web/public/session_state.js +2 -0
  99. data/lib/samagotchi/web/public/update_notice.js +2 -2
  100. data/lib/samagotchi/web/session_access_routes.rb +12 -8
  101. data/lib/samagotchi/web/session_control.rb +9 -3
  102. data/lib/samagotchi/web/session_create.rb +8 -4
  103. data/lib/samagotchi/web/session_hub.rb +50 -5
  104. data/lib/samagotchi/web/session_summary.rb +5 -2
  105. data/lib/samagotchi/worker.rb +45 -7
  106. data/lib/samagotchi/worker_boot.rb +22 -11
  107. data/lib/samagotchi/worker_inbound.rb +41 -8
  108. data/lib/samagotchi/worker_link.rb +4 -1
  109. data/lib/samagotchi/worker_presence.rb +106 -0
  110. data/lib/samagotchi/worker_sidecar.rb +3 -4
  111. metadata +5 -1
@@ -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
- 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
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, :failures, :skips, :enabled, :created_at, :extra) do
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
- 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) }
344
404
  .sort_by { |entry| [entry.next_due.to_s, entry.id] }
345
- 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
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 { |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) }
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
- verdict = check(entry, index)
408
- if verdict
409
- kind, reason = verdict
410
- status = kind == :skip ? "skipped" : "failed"
411
- record_refusal(entry, status, reason)
412
- return Outcome.new(id: entry.id, title: entry.title, status: status, detail: reason, entry: entry)
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
- 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)
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", 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)
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, last_result: nil,
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, 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
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
- 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
768
990
 
769
- if link.worker_owned?
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 link.live?
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
- Outcome.new(id: entry.id, title: entry.title, status: "fired", detail: "new link #{link.id[0, 8]}",
863
- entry: entry.with(head: link.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))
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: "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))
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
- next if position.nil?
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
- stored = file.entries[position]
923
- 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"
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
- elsif outcome.status == "skipped"
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 != "fired" && !stored.due_was.nil?
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} #{refused_word(result)}: #{result.detail || "could not queue it"}")
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} #{refused_word(result)}: #{result.detail || "could not queue it"}")
763
+ @info.puts("#{short} #{result.refusal_text}")
777
764
  return false
778
765
  end
779
766