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
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_inbox"
5
+ require_relative "archive_store"
6
+ require_relative "worker_presence"
7
+
8
+ module Samagotchi
9
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
10
+ # files unloaded until a delivery starts a worker.
11
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
12
+
13
+ # The session inbox's one delivery path (SessionInbox.deliver): which link
14
+ # a delivery goes to (Head), whether it is refused, its write (the kind's
15
+ # own writer, unchanged), one read of the worker's presence after it, and
16
+ # what that means for a worker (WakePolicy). Loaded on first use, from
17
+ # session_inbox.rb's autoloads: the bundle test kit's load budget
18
+ # (spec/samagotchi/testing/sandbox_spec.rb) stays as it was.
19
+ module SessionInbox
20
+ # Where a delivery for +asked+ goes: +id+, reached +via+ :pinned (the
21
+ # kind never follows a chain), :same (followed, nothing to follow) or
22
+ # :head (a later link of +asked+'s chain).
23
+ Resolved = Data.define(:id, :asked, :via)
24
+
25
+ # A delivery's outcome.
26
+ # @!attribute kind [Symbol] the WakePolicy row
27
+ # @!attribute id [String] the session whose worker the outcome is about
28
+ # (a ring's file stays with +asked+: WakePolicy#resolves :reader)
29
+ # @!attribute asked [String] the id the caller named
30
+ # @!attribute via [Symbol] Resolved#via
31
+ # @!attribute status [Symbol] :refused (nothing written), :queued (a
32
+ # worker sees it: one runs, is starting or was started) or :waits (no
33
+ # worker comes: it waits for the next start)
34
+ # @!attribute wake [Symbol] WakePolicy#decide's outcome, :woken for a
35
+ # start made, or the refusal (:gone, :owned: a one-turn chi run's,
36
+ # :scratch)
37
+ # @!attribute queued [Integer, nil] notes waiting (a note kind's), nil else
38
+ Delivered = Data.define(:kind, :id, :asked, :via, :status, :wake, :queued) do
39
+ def initialize(kind:, id:, asked:, via:, status:, wake:, queued: nil) = super
40
+
41
+ def refused? = status == :refused
42
+ end
43
+
44
+ # What a delivery of one kind may do to a session's worker: one frozen
45
+ # row per kind (ROWS), decided on WorkerPresence's predicates alone, in
46
+ # one order for every kind (#decide).
47
+ # @!attribute kind [Symbol] :send, :note, :push, :ring, :fresh_verdict or
48
+ # :leave (WorkerBoot's respawn, not a delivery)
49
+ # @!attribute resolves [Symbol] which link it goes to (Head.resolve):
50
+ # :pinned the id named; :head the chain's newest link from it; :reader
51
+ # the link that reads the named one's file (a ring: the file stays with
52
+ # the parent parent.json names, ChildReports reads its predecessor's
53
+ # rings, so the wake goes one hop at most)
54
+ # @!attribute starts [Boolean] it may start a worker
55
+ # @!attribute unstops [Boolean] it starts a stopped session (a resume
56
+ # clears the stop): send only
57
+ # @!attribute starts_archived [Boolean] it starts an archived link
58
+ # (input from a person un-archives it): send only
59
+ # @!attribute refuse_tui [Boolean] a one-turn chi run's session refuses it
60
+ # @!attribute refuse_scratch [Boolean] a chi scratch session refuses it
61
+ WakePolicy = Data.define(:kind, :resolves, :starts, :unstops, :starts_archived, :refuse_tui, :refuse_scratch) do
62
+ # @return [WakePolicy]
63
+ # @raise [ArgumentError] no such kind
64
+ def self.for(kind) = WakePolicy::ROWS.fetch(kind) { raise ArgumentError, "no inbox kind #{kind.inspect}" }
65
+
66
+ # Why the delivery is refused before anything is written, else nil:
67
+ # :gone (no session), :owned (a one-turn chi run's), :scratch.
68
+ # @param load [#call, nil] the session, read only when the scratch
69
+ # flag decides (nil: not a scratch session)
70
+ # @return [Symbol, nil]
71
+ def refusal(presence, load: nil)
72
+ return :gone unless presence.exists?
73
+ return :owned if refuse_tui && presence.tui_owned?
74
+
75
+ :scratch if refuse_scratch && scratch?(load)
76
+ end
77
+
78
+ # What the delivery means for the session's worker, in this order:
79
+ # :gone, :owned (a one-turn chi run's), :live (a worker holds it: it
80
+ # sees the delivery), :stopped (the stop marker: the only "stopped",
81
+ # the file's status never decides), :archived, :starting (a worker is
82
+ # being started: it reads the store on its first pass), :none (the
83
+ # kind never starts one), :off (+wake+ false: none was asked),
84
+ # :scratch (its terminal's alone; the file is read last, only here),
85
+ # :start (start one).
86
+ # @param wake [Boolean] the delivery asks for a worker (a ring in wake
87
+ # mode, a push with wake)
88
+ # @return [Symbol]
89
+ def decide(presence, wake: true, load: nil)
90
+ return :gone unless presence.exists?
91
+ return :owned if presence.tui_owned?
92
+ return :live if presence.owned?
93
+ return :stopped if presence.stop_marked? && !unstops
94
+ return :archived if presence.archived? && !starts_archived
95
+ # A stop cancels a start's hold-off for a kind that clears it
96
+ # (WorkerPresence#start_pending?).
97
+ return :starting if unstops ? presence.start_pending? : presence.starting?
98
+ return :none unless starts
99
+ return :off unless wake
100
+ return :scratch if scratch?(load)
101
+
102
+ :start
103
+ end
104
+
105
+ private def scratch?(load) = load ? load.call.scratch == true : false
106
+ end
107
+
108
+ WakePolicy::ROWS = {
109
+ send: WakePolicy.new(kind: :send, resolves: :pinned, starts: true, unstops: true, starts_archived: true,
110
+ refuse_tui: true, refuse_scratch: true),
111
+ note: WakePolicy.new(kind: :note, resolves: :head, starts: false, unstops: false, starts_archived: false,
112
+ refuse_tui: true, refuse_scratch: true),
113
+ push: WakePolicy.new(kind: :push, resolves: :pinned, starts: true, unstops: false, starts_archived: false,
114
+ refuse_tui: false, refuse_scratch: false),
115
+ ring: WakePolicy.new(kind: :ring, resolves: :reader, starts: true, unstops: false, starts_archived: false,
116
+ refuse_tui: false, refuse_scratch: false),
117
+ fresh_verdict: WakePolicy.new(kind: :fresh_verdict, resolves: :pinned, starts: false, unstops: false,
118
+ starts_archived: false, refuse_tui: false, refuse_scratch: false),
119
+ # Not a delivery: a worker's own leave starting its successor for
120
+ # what waits (input, rings) or a restart (WorkerBoot, after the
121
+ # lock's release). A stop wins; an archived session's input still
122
+ # starts it, as a send's would have.
123
+ leave: WakePolicy.new(kind: :leave, resolves: :pinned, starts: true, unstops: false, starts_archived: true,
124
+ refuse_tui: false, refuse_scratch: false)
125
+ }.freeze
126
+
127
+ # Which link of a chain a delivery goes to.
128
+ module Head
129
+ module_function
130
+
131
+ # +id+ for a pinned kind, or for an id not archived (the cheap check
132
+ # first: only an archived id is walked, with one Session.list).
133
+ # Else the walk forward over archived links that have a successor:
134
+ # to the first link not archived, or the last (a loop a hand-edited
135
+ # file could make ends where it started); a :reader kind goes one hop
136
+ # at most.
137
+ # @return [Resolved]
138
+ def resolve(kind, id, state_dir:)
139
+ how = WakePolicy.for(kind).resolves
140
+ return Resolved.new(id: id, asked: id, via: :pinned) if how == :pinned
141
+
142
+ same = Resolved.new(id: id, asked: id, via: :same)
143
+ return same unless archived?(id, state_dir)
144
+
145
+ nexts = successors(state_dir)
146
+ seen = [id]
147
+ while (following = nexts[seen.last]) && !seen.include?(following)
148
+ seen << following
149
+ break if how == :reader || !archived?(following, state_dir)
150
+ end
151
+ seen.size == 1 ? same : Resolved.new(id: seen.last, asked: id, via: :head)
152
+ end
153
+
154
+ def archived?(id, state_dir)
155
+ Session.valid_id?(id) && ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
156
+ end
157
+
158
+ # id → the session that continues it (SessionChain.next_of's: the
159
+ # earliest one, archived ones too).
160
+ def successors(state_dir)
161
+ Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
162
+ .each_with_object({}) { |s, nexts| nexts[s.continues] ||= s.id if s.continues }
163
+ end
164
+ end
165
+
166
+ # SessionInbox.deliver and SessionInbox.wake.
167
+ module Dispatch
168
+ # What a start each kind makes is logged as (SessionManager.start_for_inbox).
169
+ START_EVENTS = { ring: :delegate_woke_parent, push: :context_woke_session }.freeze
170
+
171
+ module_function
172
+
173
+ # Deliver one +kind+ of thing to session +target+, in this order:
174
+ # resolve the link (Head.resolve), refuse (WakePolicy#refusal: before
175
+ # any write), write (the block: the kind's own writer, given the id
176
+ # it writes under), read the presence once, then decide and apply
177
+ # (#wake).
178
+ # @param wake [Boolean] the delivery may ask for a worker; the block's
179
+ # value says whether it does (truthy: asks)
180
+ # @param pin [Boolean] write under +target+ itself, whatever the kind's
181
+ # row resolves (a writer whose text is +target+'s own: an
182
+ # /objectives list built from its conversation)
183
+ # @param resolved [Resolved, nil] the link, as the caller resolved it
184
+ # already (Head.resolve for this kind): not walked again
185
+ # @param owner [#call, nil] (id) the owner lock's holder (a stand-in
186
+ # manager's session_owner); nil: SessionManager.session_owner
187
+ # @param start [#call, nil] (id) starts a worker, returns :woken (or
188
+ # why not); nil: SessionManager.start_for_inbox
189
+ # @yieldparam id [String] the session it is written under
190
+ # @yieldreturn [Boolean] whether it asks for a worker
191
+ # @return [Delivered]
192
+ def deliver(kind, target, state_dir: nil, wake: true, pin: false, resolved: nil, owner: nil, start: nil)
193
+ sd = state_dir || Session.default_state_dir
194
+ policy = WakePolicy.for(kind)
195
+ resolved = Resolved.new(id: target, asked: target, via: :pinned) if pin
196
+ resolved ||= Head.resolve(kind, target, state_dir: sd)
197
+ at = policy.resolves == :reader ? resolved.asked : resolved.id
198
+ if (why = refusal(policy, at, sd, owner))
199
+ return Delivered.new(kind: kind, id: resolved.id, asked: resolved.asked, via: resolved.via, status: :refused,
200
+ wake: why)
201
+ end
202
+
203
+ asks = yield(at)
204
+ apply(policy, resolved, sd, wake: wake && asks ? true : false, owner: owner, start: start)
205
+ end
206
+
207
+ # The wake half of #deliver alone, for a delivery already on disk (a
208
+ # failed continue's previous link with rings that came meanwhile).
209
+ # @return [Delivered]
210
+ def wake(kind, target, state_dir: nil, owner: nil, start: nil)
211
+ sd = state_dir || Session.default_state_dir
212
+ apply(WakePolicy.for(kind), Head.resolve(kind, target, state_dir: sd), sd, wake: true, owner: owner,
213
+ start: start)
214
+ end
215
+
216
+ def refusal(policy, id, state_dir, owner)
217
+ return :gone unless Session.exist?(id, state_dir: state_dir)
218
+ return nil unless policy.refuse_tui || policy.refuse_scratch
219
+
220
+ policy.refusal(presence(id, state_dir, owner), load: -> { Session.load(id, state_dir: state_dir) })
221
+ end
222
+
223
+ def apply(policy, resolved, state_dir, wake:, owner:, start:)
224
+ id = resolved.id
225
+ outcome = policy.decide(presence(id, state_dir, owner), wake: wake, load: -> { Session.load(id, state_dir: state_dir) })
226
+ if outcome == :start
227
+ outcome = if start
228
+ start.call(id)
229
+ else
230
+ SessionManager.start_for_inbox(id, state_dir: state_dir,
231
+ event: START_EVENTS.fetch(policy.kind))
232
+ end
233
+ end
234
+ status = %i[live woken starting].include?(outcome) ? :queued : :waits
235
+ Delivered.new(kind: policy.kind, id: id, asked: resolved.asked, via: resolved.via, status: status,
236
+ wake: outcome, queued: notes_waiting(policy, status, id, state_dir))
237
+ end
238
+
239
+ # A note's count of the notes waiting for a start (0 when a worker
240
+ # sees them); nil for the other kinds.
241
+ def notes_waiting(policy, status, id, state_dir)
242
+ return nil unless policy.kind == :note
243
+ return 0 unless status == :waits
244
+
245
+ SessionInbox.find_new_note_files(Session.session_dir(id, state_dir: state_dir)).size
246
+ end
247
+
248
+ def presence(id, state_dir, owner)
249
+ reader = owner ? -> { owner.call(id) } : -> { SessionManager.session_owner(id, state_dir: state_dir) }
250
+ WorkerPresence.read(id, state_dir: state_dir, owner: reader)
251
+ end
252
+ end
253
+ end
254
+ end
@@ -37,6 +37,17 @@ module Samagotchi
37
37
 
38
38
  def detail = ack["detail"]
39
39
 
40
+ # How a delivery that did not go in reads, as one line: the word for it
41
+ # (refused: a worker older than queueing would steer the running turn,
42
+ # or a remote refused it as chi send refuses it here; failed: it simply
43
+ # did not get in) and its reason. chi send prints it, and the delegate
44
+ # tool's follow-up says it after "Error: session <id8>".
45
+ # @return [String]
46
+ def refusal_text
47
+ refused = error == Delivery::QUEUE_UNAVAILABLE["error"] || DeliverResult::REMOTE_REFUSALS.include?(error)
48
+ "#{refused ? "refused" : "failed"}: #{detail || "could not queue it"}"
49
+ end
50
+
40
51
  # What the worker applied (a Bridge ack only): the delivery asked for,
41
52
  # or next_step for a --queue that found no turn running; nil from the
42
53
  # input file or a worker older than delivery.
@@ -49,6 +60,13 @@ module Samagotchi
49
60
  def cut_after = ack["cut_after"]
50
61
  end
51
62
 
63
+ # A remote's refusals of what this machine checks before sending (a
64
+ # parent's /continue yes, a loosening /parent-answers, images to a
65
+ # model that can't see them): its turn route's error codes. Named on
66
+ # DeliverResult, not inside its Data.define block, where a constant
67
+ # would land on SessionManager.
68
+ DeliverResult::REMOTE_REFUSALS = %w[parent_answers_refused parent_continue_refused no_vision].freeze
69
+
52
70
  # The unclaimed input files +author+ wrote.
53
71
  PARTICIPANT_QUEUE_LOCK = "participants.lock"
54
72
 
@@ -118,7 +136,10 @@ module Samagotchi
118
136
  link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
119
137
  link.live? ? link.client(session_id) : nil
120
138
  end
121
- manager.resume_session(session_id, state_dir: state_dir)
139
+ # Its refusals (no session, a one-turn run's, a scratch one) are
140
+ # resume_session's to raise, in their one wording.
141
+ start_for_send(manager, session_id, state_dir, before: true)
142
+ enqueued_id = SessionInbox.new_enqueued_id
122
143
  if (client = bridge.call)
123
144
  if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
124
145
  return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
@@ -130,7 +151,7 @@ module Samagotchi
130
151
  return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
131
152
 
132
153
  begin
133
- options = { prompt: prompt, client_id: client_id }
154
+ options = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id }
134
155
  options[:images] = images unless images.empty?
135
156
  # An old worker ignores the field: it cuts anyway (its own default).
136
157
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
@@ -164,12 +185,12 @@ module Samagotchi
164
185
  # still holding the session with its Bridge gone (exiting) may be an
165
186
  # older one, which would run it without the review boundary.
166
187
  # (A one-turn chi run holding it is refused as for anyone, below.)
167
- if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
188
+ if Steer.participant_author?(author) && (holder = delivery_presence(manager, session_id, state_dir)).owned? &&
189
+ !holder.tui_owned?
168
190
  return DeliverResult.new(status: :timeout, via: :file,
169
191
  ack: { "error" => "worker_timeout",
170
192
  "detail" => "the session's worker is exiting, so the message was not sent; send it again" })
171
193
  end
172
- enqueued_id = SecureRandom.uuid
173
194
  input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
174
195
  input[:images] = images unless images.empty?
175
196
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
@@ -189,21 +210,51 @@ module Samagotchi
189
210
  end
190
211
  return DeliverResult.new(status: :failed) unless path
191
212
 
192
- owner = delivery_owner(manager, session_id, state_dir)
193
213
  # A TUI that took the session between the resume and the write never
194
214
  # reads input files; a later worker would replay this one.
195
- if owner&.tui?
196
- FileUtils.rm_f(path) if path.is_a?(String)
197
- raise OwnedByTUI, session_id
198
- end
199
215
  # A worker that idle-exited since the resume never reads it either:
200
216
  # wake a new one. (The exiting worker also looks for input it left;
201
217
  # one the first resume spawned that is still starting is left be.)
202
- manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
218
+ start_for_send(manager, session_id, state_dir, before: false) do
219
+ FileUtils.rm_f(path) if path.is_a?(String)
220
+ raise OwnedByTUI, session_id
221
+ end
203
222
  DeliverResult.new(status: :accepted, via: :file,
204
223
  ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
205
224
  end
206
225
 
226
+ # deliver_turn's two liveness checks, on the session inbox's send row
227
+ # (SessionInbox::WakePolicy: send un-stops and starts an archived
228
+ # link; it is pinned to the id it names): resume_session unless a
229
+ # worker holds the session or one is being started. Its refusals (no
230
+ # session, a one-turn run's, a scratch one) are resume_session's to
231
+ # raise, in their one wording: +before+ the write, a session it would
232
+ # not resume is checked for them (WakePolicy#refusal: the file read
233
+ # only then, resume_session reads it otherwise); after it, a one-turn
234
+ # run that took the session since runs the block, and a worker that
235
+ # is leaving (OwnerLock::Owner#leaving?: its Bridge closed, the lock
236
+ # held for its last work) has its input served by its own respawn
237
+ # (WorkerBoot's leave): a stop that landed before this send is
238
+ # cleared, as resume_session clears it (a send un-stops); one that
239
+ # lands after it still wins.
240
+ private def start_for_send(manager, session_id, state_dir, before:)
241
+ presence = delivery_presence(manager, session_id, state_dir)
242
+ policy = SessionInbox::WakePolicy.for(:send)
243
+ outcome = policy.decide(presence)
244
+ yield if outcome == :owned && block_given?
245
+ if !before && outcome == :live && presence.stop_marked? && presence.owner&.leaving?
246
+ Session.clear_stopped(session_id, state_dir: state_dir || Session.default_state_dir)
247
+ Log.info(:worker, "send_unstopped_leaving", sid: session_id[0, 8])
248
+ end
249
+ if %i[live starting].include?(outcome)
250
+ return unless before
251
+
252
+ load = -> { Session.load(session_id, state_dir: state_dir || Session.default_state_dir) }
253
+ return unless policy.refusal(presence, load: load)
254
+ end
255
+ manager.resume_session(session_id, state_dir: state_dir)
256
+ end
257
+
207
258
  # Whether the worker +client+ talks to names +feature+: the features of
208
259
  # the sidecar the client was made from (the Live link WorkerLink.await
209
260
  # found), not a second read of bridge.json.
@@ -244,8 +295,11 @@ module Samagotchi
244
295
  "detail" => "the session's worker did not answer, so the message was not sent" })
245
296
  end
246
297
 
247
- private def delivery_owner(manager, session_id, state_dir)
248
- Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
298
+ # The session's WorkerPresence, its owner read through +manager+ (a
299
+ # stand-in's #session_owner is optional: then none).
300
+ private def delivery_presence(manager, session_id, state_dir)
301
+ owner = Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
302
+ WorkerPresence.read(session_id, state_dir: state_dir, owner: owner)
249
303
  end
250
304
  end
251
305
 
@@ -3,6 +3,7 @@
3
3
  require_relative "../analytics_file"
4
4
  require_relative "../session_inbox"
5
5
  require_relative "../worker_link"
6
+ require_relative "../starting_marker"
6
7
 
7
8
  module Samagotchi
8
9
  class SessionManager
@@ -12,7 +13,9 @@ module Samagotchi
12
13
  # nothing writes or reads it now, but a folder from before still has one
13
14
  # and is still empty. A turn's drawing (Bridge::TurnDrawings::FILE)
14
15
  # exists only after a turn that kept messages: never a skeleton file.
15
- EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME].freeze
16
+ # The starting marker is one too: a start left one behind; a fresh one
17
+ # keeps the session from the deleters itself.
18
+ EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME, StartingMarker::FILE].freeze
16
19
  EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
17
20
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
18
21
 
@@ -94,7 +97,9 @@ module Samagotchi
94
97
  elsif name == "cards.json"
95
98
  cards_empty?(path)
96
99
  else
97
- EMPTY_SKELETON_FILES.include?(name)
100
+ # A starting claim's temp files a kill left (StartingMarker.claim:
101
+ # starting.<pid>.<hex>, starting.old.<pid>.<hex>) are the marker's.
102
+ EMPTY_SKELETON_FILES.include?(name) || name.start_with?("#{StartingMarker::FILE}.")
98
103
  end
99
104
  end
100
105
  end
@@ -220,7 +220,8 @@ module Samagotchi
220
220
  # A stop drops the reminders: a restart's hand-off no successor took
221
221
  # yet must not bring them back on the next resume.
222
222
  ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
223
- pid = owner&.pid.to_i
223
+ # A deleter holding the lock (chi web, the CLI) is no worker to stop.
224
+ pid = owner&.deleting? ? 0 : owner&.pid.to_i
224
225
  begin
225
226
  Process.kill("TERM", pid) if pid && pid > 0
226
227
  rescue Errno::ESRCH
@@ -11,7 +11,9 @@ module Samagotchi
11
11
  # +include_archived+: archived sessions too (Session#archived says which).
12
12
  def list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
13
13
  include_archived: false)
14
- Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
14
+ sd = state_dir || Session.default_state_dir
15
+ SessionContinue.repair_interrupted(state_dir: sd)
16
+ Session.list(state_dir: sd, sort: sort, order: order, limit: limit,
15
17
  offset: offset, project_root: project_root, include_archived: include_archived)
16
18
  end
17
19
 
@@ -34,6 +36,7 @@ module Samagotchi
34
36
  def session_rows(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
35
37
  project_root: nil, include_archived: false, sort: nil, order: nil)
36
38
  sd = state_dir || Session.default_state_dir
39
+ SessionContinue.repair_interrupted(state_dir: sd)
37
40
  root = cwd && folder_path(cwd)
38
41
  roots = {}
39
42
  # One registry per listing, built only when a row has a saved context