ace-assign 0.63.1 → 0.64.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +43 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  61. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  62. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  63. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  64. data/lib/ace/assign/version.rb +1 -1
  65. data/lib/ace/assign.rb +13 -0
  66. metadata +107 -8
@@ -0,0 +1,822 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require "fileutils"
7
+ require "open3"
8
+ require_relative "deployment"
9
+ require_relative "../molecules/execution_scope_lineage"
10
+ require_relative "execution_scope_observation"
11
+
12
+ module Ace
13
+ module Assign
14
+ module Authority
15
+ # Launch facts and release permission live in qjl authority_mutation data.
16
+ # Process handles/streams are observations, never a replacement ledger.
17
+ class LaunchLifecycle
18
+ MUTATIONS = {
19
+ "register_assignment" => %w[mapping_id assignment_id definition_bytes definition_digest expected_generation],
20
+ "reserve_attempt" => %w[mapping_id assignment_id scope worker_uid runtime base_head launcher_process_binding expected_generation],
21
+ "record_launch" => %w[mapping_id assignment_id attempt_id launch_ticket process_binding expected_generation],
22
+ "bind_process" => %w[mapping_id assignment_id attempt_id launch_ticket process_binding expected_generation],
23
+ "release_launch" => %w[mapping_id assignment_id attempt_id launch_ticket process_binding expected_generation],
24
+ "abort_launch" => %w[mapping_id assignment_id attempt_id launch_ticket failure_evidence failure_digest expected_generation]
25
+ }.freeze
26
+ OPERATIONS = (MUTATIONS.keys + %w[launch_preflight registration_status attempt_status inspect_launch observe_execution_scope close_execution_scope]).freeze
27
+ TERMINAL = %w[succeeded failed stopped].freeze
28
+
29
+ attr_reader :mutex, :journals, :exclusions
30
+ def initialize(deployment:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new, journals: nil, mutex: Mutex.new, exclusions: {}, scope_observer_factory: nil)
31
+ @deployment, @kernel = deployment, kernel
32
+ @journals = journals || {}
33
+ @exclusions = exclusions
34
+ @mutex = mutex
35
+ @observations = {}
36
+ @streams = {}
37
+ @slot_exclusions = {}
38
+ @scope_observers = {}
39
+ @scope_observer_factory = scope_observer_factory || ->(mapping_id) {
40
+ ExecutionScopeObservation.new(mapping_id: mapping_id, deployment: @deployment, kernel: @kernel)
41
+ }
42
+ @changed = ConditionVariable.new
43
+ end
44
+
45
+ def attach_result_owner(owner)
46
+ raise ArgumentError, "result status owner already attached" if @result_owner
47
+ @result_owner = owner
48
+ end
49
+
50
+ # Existing service owners call this while holding slot-before-assignment
51
+ # exclusion. It is a canonical seal gate, never a new effect journal.
52
+ def scope_open_for_effect!(events:, params:, map:)
53
+ chain = events.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
54
+ Molecules::ExecutionScopeLineage.new(events: chain, project_id: map.fetch("project_id"),
55
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
56
+ mapping_id: params.fetch("mapping_id")).require_launch_bound!
57
+ end
58
+
59
+ def dispatch(request:, peer:, role:)
60
+ operation, params = request.values_at("operation", "params")
61
+ if operation == "observe_execution_scope"
62
+ raise ArgumentError, "scope observation mutation ID must be null" unless request.fetch("mutation_id").nil?
63
+ return {data: observe_execution_scope!(params: params, peer: peer, role: role), replayed: false}
64
+ end
65
+ if operation == "close_execution_scope"
66
+ strict!(params, %w[mapping_id assignment_id attempt_id expected_generation])
67
+ return close_execution_scope!(params: params.merge("mutation_id" => request.fetch("mutation_id")), peer: peer, role: role)
68
+ end
69
+ if operation == "launch_preflight"
70
+ strict!(params, %w[mapping_id])
71
+ raise AttemptErrors::UnauthorizedIdentity, "preflight requires mapped launcher" unless role == :launcher
72
+ map = @deployment.mapping(params.fetch("mapping_id"))
73
+ return {data: {"project_id" => map.fetch("project_id"), "runtime" => "herdr", "supported" => true}, replayed: false}
74
+ end
75
+ if operation == "registration_status"
76
+ strict!(params, %w[mapping_id assignment_id])
77
+ raise AttemptErrors::UnauthorizedIdentity, "registration status requires mapped launcher" unless role == :launcher
78
+ map = @deployment.mapping(params.fetch("mapping_id"))
79
+ token!(params.fetch("assignment_id"))
80
+ journal = journal_for(map)
81
+ @mutex.synchronize do
82
+ registration = definition(journal, params.fetch("assignment_id"))
83
+ return {data: registration || {"assignment_id" => params.fetch("assignment_id"), "generation" => 0}, replayed: false}
84
+ end
85
+ end
86
+ if operation == "inspect_launch"
87
+ strict!(params, %w[mapping_id assignment_id attempt_id])
88
+ raise AttemptErrors::UnauthorizedIdentity, "inspection requires launcher or supervisor" unless %i[launcher supervisor].include?(role)
89
+ return {data: inspect_launch(params, peer: peer, role: role), replayed: false}
90
+ end
91
+ if operation == "attempt_status"
92
+ keys = %w[mapping_id assignment_id attempt_id]
93
+ keys += ["result_candidate_generation"] if @result_owner
94
+ strict!(params, keys)
95
+ if @result_owner
96
+ raise ArgumentError, "status mutation ID must be null" unless request.fetch("mutation_id").nil?
97
+ selector = params.fetch("result_candidate_generation")
98
+ generation!(selector, allow_nil: true)
99
+ end
100
+ return {data: status(params, peer: peer, role: role), replayed: false}
101
+ end
102
+ unless role == :launcher || (role == :supervisor && operation == "abort_launch")
103
+ raise AttemptErrors::UnauthorizedIdentity, "launch mutation requires mapped launcher or authorized recovery supervisor"
104
+ end
105
+ keys = MUTATIONS.fetch(operation) { raise ArgumentError, "unknown authority operation" }
106
+ strict!(params, keys)
107
+ token!(request.fetch("mutation_id"))
108
+ token!(params.fetch("assignment_id"))
109
+ generation!(params.fetch("expected_generation"), allow_nil: operation == "register_assignment")
110
+ map = @deployment.mapping(params.fetch("mapping_id"))
111
+ bounded_scope!(params.fetch("scope")) if operation == "reserve_attempt"
112
+ @kernel.live!(peer)
113
+ journal = journal_for(map)
114
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(params)))
115
+ with_exclusion(params, map, journal) do
116
+ if operation == "reserve_attempt"
117
+ existing = @mutex.synchronize { journal.mutation_result(request.fetch("mutation_id")) }
118
+ unless existing
119
+ # Retire the exact released predecessor before reserving a fresh
120
+ # generation. No native action occurs inside the journal CAS.
121
+ commit = journal.ref_value
122
+ retire_released_parent!(mapping_id: params.fetch("mapping_id"), journal: journal, commit: commit)
123
+ end
124
+ end
125
+ response = @mutex.synchronize do
126
+ replay = journal.mutation_result(request.fetch("mutation_id"))
127
+ if replay && operation == "abort_launch"
128
+ replay_events = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
129
+ scope_close_owner!(params, map, replay_events, peer, role)
130
+ end
131
+ attempt_id = replay&.fetch("attempt_id") || mutation_attempt_id(operation, params)
132
+ if operation == "release_launch" && !replay
133
+ deadline = wire.deadline(5)
134
+ until @streams.key?(attempt_id)
135
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ raise AttemptErrors::EvidenceUnavailable, "exact gate readiness is unavailable" if remaining <= 0
137
+ @changed.wait(@mutex, [remaining, 0.1].min)
138
+ end
139
+ end
140
+ reservation_handle = nil
141
+ begin
142
+ fresh = false
143
+ released = false
144
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: attempt_id,
145
+ mutation_id: request.fetch("mutation_id"), operation: operation, parameters_digest: digest,
146
+ expected_generation: operation == "reserve_attempt" ? 0 : (params.fetch("expected_generation") || 0), with_replay: true) do |events, commit, generation|
147
+ fresh = true
148
+ plan = case operation
149
+ when "register_assignment" then register(params, map, journal, commit, generation)
150
+ when "reserve_attempt" then reserve(params, map, journal, commit, generation, peer, attempt_id)
151
+ when "record_launch" then record(params, map, events, peer)
152
+ when "bind_process" then bind(params, map, events, peer)
153
+ when "release_launch"
154
+ released = true
155
+ release(params, map, events, peer)
156
+ when "abort_launch" then abort(params, map, events, peer, supervisor: role == :supervisor, journal: journal, commit: commit)
157
+ end
158
+ bounded_reply!(plan.fetch(:data), generation + 1)
159
+ # Validate the complete plan before opening a handle, but acquire
160
+ # it before returning any events for staging/CAS. Reuse across
161
+ # retries; a competing accepted replay never owns this handle.
162
+ reservation_handle ||= @kernel.pin(peer) if operation == "reserve_attempt"
163
+ plan
164
+ end
165
+ fresh = !result.fetch(:replayed)
166
+ result = result.fetch(:data)
167
+ if fresh && operation == "reserve_attempt"
168
+ @observations[result.fetch("attempt_id")] = {launcher: peer, launcher_handle: reservation_handle, deadline: wire.deadline(30), ticket: result.fetch("launch_ticket"), assignment_id: result.fetch("assignment_id")}
169
+ reservation_handle = nil
170
+ end
171
+ # No release in mutation replay. A crash here retains issued uncertainty.
172
+ if fresh && released
173
+ stream = @streams.fetch(result.fetch("attempt_id"))
174
+ permission = {"operation" => "release", "launch_ticket" => result.fetch("launch_ticket"),
175
+ "attempt_id" => result.fetch("attempt_id"), "assignment_id" => result.fetch("assignment_id"),
176
+ "generation" => result.fetch("generation"), "journal_commit" => result.fetch("journal_commit")}
177
+ begin
178
+ wire.write(stream.fetch(:socket), permission, deadline: wire.deadline(1))
179
+ rescue StandardError
180
+ # Durable permission cannot be undone or retransmitted after write loss.
181
+ nil
182
+ ensure
183
+ stream[:issued] = true
184
+ stream.fetch(:condition).broadcast
185
+ end
186
+ end
187
+ reap_terminal_observations(journal, params.fetch("assignment_id"))
188
+ materialize_definition(result, map, journal) if operation == "register_assignment"
189
+ {data: result, replayed: !fresh}
190
+ ensure
191
+ reservation_handle&.close
192
+ end
193
+ end
194
+ if operation == "reserve_attempt" && !response.fetch(:replayed)
195
+ begin
196
+ parent = provision_reserved_parent_held!(response.fetch(:data), map, journal)
197
+ admission = response.fetch(:data).slice("mapping_id", "assignment_id", "attempt_id", "launch_ticket").merge(
198
+ "mutation_id" => "scope-admission-#{Digest::SHA256.hexdigest(response.dig(:data, 'attempt_id'))[0, 48]}",
199
+ "expected_generation" => parent.dig(:data, "generation"))
200
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(admission.reject { |key, _| key == "mutation_id" })))
201
+ admit_native_service_held!(admission, map, journal, peer, role, digest)
202
+ rescue Ace::Runtime::RuntimeUnavailableError, AttemptErrors::EvidenceUnavailable
203
+ # Reservation is already canonical. A failed/lost parent job is
204
+ # held for exact inspection; replay must not activate it again.
205
+ # The immutable reservation reply remains its original outcome.
206
+ nil
207
+ end
208
+ end
209
+ if operation == "abort_launch" && response.dig(:data, "abort_observation", "kind") == "protected_scope_before_release"
210
+ resume_scope_release_held!(response, map, journal, peer, role)
211
+ end
212
+ response
213
+ end
214
+ end
215
+
216
+ def gate_ready(request:, peer:, socket:, deadline:)
217
+ params = request.fetch("params")
218
+ strict!(params, %w[mapping_id launch_ticket])
219
+ token!(params.fetch("launch_ticket"))
220
+ map = @deployment.mapping(params.fetch("mapping_id"))
221
+ admitted = nil
222
+ until admitted || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
223
+ @mutex.synchronize do
224
+ journal = journal_for(map)
225
+ @observations.each do |attempt, observation|
226
+ next unless observation[:ticket] == params["launch_ticket"]
227
+ events = journal.read_events(observation.fetch(:assignment_id))
228
+ state = states(events)[attempt]
229
+ next unless state && state["mapping_id"] == params["mapping_id"] && state["launch_ticket"] == params["launch_ticket"]
230
+ next unless %w[recorded bound].include?(state["phase"])
231
+ identity = state.fetch("process_binding").fetch("process_identity")
232
+ next unless @kernel.same?(peer, identity)
233
+ @kernel.live!(peer)
234
+ next unless observation[:child] && @kernel.same?(observation.fetch(:child), peer) && launcher_live?(observation)
235
+ raise AttemptErrors::Conflict, "gate stream already established" if observation[:gate_established]
236
+ condition = ConditionVariable.new
237
+ admitted = {socket: socket, condition: condition, issued: false, state: state}
238
+ observation[:gate_established] = true
239
+ @streams[attempt] = admitted
240
+ @changed.broadcast
241
+ wire.write(socket, {"status" => "ok", "data" => {"phase" => "ready"}}, deadline: deadline)
242
+ break
243
+ end
244
+ end
245
+ sleep 0.02 unless admitted
246
+ end
247
+ raise AttemptErrors::EvidenceUnavailable, "gate was not positively admitted" unless admitted
248
+ @mutex.synchronize do
249
+ until admitted[:issued]
250
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
251
+ break if remaining <= 0 || !launcher_live?(@observations[admitted.fetch(:state).fetch("attempt_id")])
252
+ admitted.fetch(:condition).wait(@mutex, [remaining, 0.1].min)
253
+ end
254
+ end
255
+ ensure
256
+ if admitted
257
+ @mutex.synchronize { @streams.delete(admitted.fetch(:state).fetch("attempt_id")) }
258
+ end
259
+ end
260
+
261
+ # Endcap calls this against its already locked canonical attempt chain.
262
+ # Only launch operations may establish origin; later business mutations
263
+ # cannot replace immutable native identity by echoing launch fields.
264
+ def origin(events, assignment_id:, attempt_id:, mapping_id:)
265
+ state = states(events)[attempt_id]
266
+ unless state && state["assignment_id"] == assignment_id && state["mapping_id"] == mapping_id
267
+ raise AttemptErrors::NotFound, "canonical protected launch origin is missing"
268
+ end
269
+ JSON.parse(JSON.generate(state))
270
+ end
271
+
272
+ # Source-owned consumers enter before taking the shared authority mutex.
273
+ # Registration is reread after exclusion; a changed task cannot borrow
274
+ # the lock acquired for a previous registration.
275
+ def with_assignment(params:, map:, exclusive: false)
276
+ id = params.fetch("assignment_id")
277
+ token!(id)
278
+ journal = journal_for(map)
279
+ initial = definition(journal, id)
280
+ raise AttemptErrors::NotFound, "canonical assignment registration is missing" unless initial
281
+ task = initial.fetch("task_id")
282
+ token!(task)
283
+ exclusion = exclusion_for(map, journal)
284
+ enter = proc do
285
+ @mutex.synchronize do
286
+ registration = definition(journal, id)
287
+ unless registration && registration.fetch("task_id") == task
288
+ raise AttemptErrors::Conflict, "assignment registration changed while entering lifecycle exclusion"
289
+ end
290
+ yield journal, JSON.parse(JSON.generate(registration))
291
+ end
292
+ end
293
+ with_slot(map) do
294
+ if exclusive
295
+ exclusion.with_exclusive(exclusion.task_key(task)) do
296
+ exclusion.with_exclusive(exclusion.assignment_key(id)) { enter.call }
297
+ end
298
+ else
299
+ exclusion.with_shared_multi([exclusion.task_key(task), exclusion.assignment_key(id)]) { enter.call }
300
+ end
301
+ end
302
+ end
303
+
304
+ # Shutdown calls this only after all admitted handlers have ended.
305
+ def close
306
+ @mutex.synchronize do
307
+ @observations.each_value { |observation| close_observation(observation) }
308
+ @observations.clear
309
+ end
310
+ end
311
+
312
+ private
313
+
314
+ def scope_observer_for(mapping_id)
315
+ @scope_observers[mapping_id] ||= @scope_observer_factory.call(mapping_id)
316
+ end
317
+
318
+ def bounded_scope!(scope)
319
+ unless scope.is_a?(String) && scope.bytesize.between?(1, 128) && scope.split(".", -1).length <= 16
320
+ raise ArgumentError, "assignment scope exceeds protected launch bounds"
321
+ end
322
+ Atoms::AssignmentScope.canonicalize(scope)
323
+ end
324
+
325
+ def bounded_reply!(data, generation)
326
+ envelope = {"status" => "ok", "data" => data.merge("generation" => generation, "journal_commit" => "0" * 64),
327
+ "transport" => {"replayed" => false}}
328
+ raise ArgumentError, "accepted launch reply exceeds transport bounds" if JSON.generate(envelope).bytesize + 1 > 16_384
329
+ end
330
+
331
+ def close_observation(observation)
332
+ %i[launcher_handle child_handle].each do |key|
333
+ observation[key]&.close
334
+ end
335
+ end
336
+
337
+ def reap_terminal_observations(journal, assignment)
338
+ events = journal.read_events(assignment)
339
+ @observations.keys.each do |attempt|
340
+ chain = events.select { |event| event["attempt_id"] == attempt }
341
+ next if chain.empty? || !terminal_events?(chain)
342
+ close_observation(@observations.delete(attempt))
343
+ @streams[attempt]&.fetch(:condition)&.broadcast
344
+ end
345
+ end
346
+
347
+ def exclusion_for(map, journal)
348
+ exclusion = @exclusions[map.fetch("project_id")] ||= begin
349
+ common, error, result = Open3.capture3("git", "-C", journal.repo_root,
350
+ "rev-parse", "--path-format=absolute", "--git-common-dir", stdin_data: "")
351
+ raise AttemptErrors::EvidenceUnavailable, "canonical lifecycle root is unavailable" unless result.success? && !common.strip.empty?
352
+ Molecules::LifecycleExclusion.new(root: File.join(common.strip, "ace", "lifecycle-exclusion"))
353
+ end
354
+ exclusion
355
+ end
356
+
357
+ def with_exclusion(params, map, journal)
358
+ exclusion = exclusion_for(map, journal)
359
+ registration = definition(journal, params.fetch("assignment_id"))
360
+ task = if params.key?("definition_bytes")
361
+ JSON.parse(params.fetch("definition_bytes")).fetch("task_id")
362
+ else
363
+ registration&.fetch("task_id")
364
+ end
365
+ token!(task)
366
+ keys = [exclusion.task_key(task), exclusion.assignment_key(params.fetch("assignment_id"))]
367
+ with_slot(map) { exclusion.with_shared_multi(keys) { yield } }
368
+ end
369
+
370
+ # Slot ownership spans assignment chains and survives authority restart
371
+ # in the existing canonical journal. The file lock only serializes a
372
+ # fresh read/CAS or bounded manager action; its contents convey no facts.
373
+ def with_slot(map)
374
+ service = @deployment.authority(map.fetch("authority_id"))
375
+ scope = map.fetch("execution_scope")
376
+ exclusion = @slot_exclusions[map.fetch("authority_id")] ||= Molecules::LifecycleExclusion.new(
377
+ root: File.join(service.fetch("state_root"), "execution-slot-exclusion"))
378
+ key = [object_id, map.fetch("authority_id"), scope.fetch("slot_id")]
379
+ held = Thread.current[:ace_assign_scope_exclusions] ||= {}
380
+ raise AttemptErrors::Conflict, "execution slot exclusion cannot be entered recursively" if held[key]
381
+ exclusion.with_exclusive(exclusion.slot_key(scope.fetch("slot_id"))) do
382
+ held[key] = true
383
+ begin
384
+ yield
385
+ ensure
386
+ held.delete(key)
387
+ end
388
+ end
389
+ end
390
+
391
+ def materialize_definition(result, map, journal)
392
+ project = @deployment.project(map.fetch("project_id"))
393
+ directory = File.join(project.fetch("assignment_root"), result.fetch("assignment_id"))
394
+ raise AttemptErrors::EvidenceUnavailable, "assignment cache is substituted" if File.symlink?(directory)
395
+ FileUtils.mkdir_p(directory, mode: 0o700)
396
+ stat = File.stat(directory)
397
+ unless stat.directory? && stat.uid == Process.uid && (stat.mode & 0o077).zero?
398
+ raise AttemptErrors::EvidenceUnavailable, "assignment cache is not owner-private"
399
+ end
400
+ bytes = journal.blob(result.fetch("definition_ref"), commit: result.fetch("journal_commit"))
401
+ unless Digest::SHA256.hexdigest(bytes) == result.fetch("definition_digest")
402
+ raise AttemptErrors::EvidenceUnavailable, "canonical assignment definition is corrupt"
403
+ end
404
+ temporary = File.join(directory, ".definition-#{SecureRandom.hex(16)}")
405
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
406
+ file.write(bytes)
407
+ file.flush
408
+ file.fsync
409
+ end
410
+ File.rename(temporary, File.join(directory, "assignment.yaml"))
411
+ ensure
412
+ File.unlink(temporary) if temporary && File.exist?(temporary)
413
+ end
414
+
415
+ def journal_for(map)
416
+ @journals[map.fetch("project_id")] ||= begin
417
+ project = @deployment.project(map.fetch("project_id"))
418
+ Molecules::EvidenceJournal.new(repo_root: project.fetch("journal_repository"),
419
+ checkout_root: project.fetch("evidence_checkout_root"), ref: project.fetch("evidence_git_ref"))
420
+ end
421
+ end
422
+
423
+ def mutation_attempt_id(operation, params)
424
+ return "definition-#{params.fetch('assignment_id')}" if operation == "register_assignment"
425
+ return "launch-#{SecureRandom.hex(12)}" if operation == "reserve_attempt"
426
+ token!(params.fetch("attempt_id"))
427
+ end
428
+
429
+ def register(params, map, journal, commit, generation)
430
+ bytes = params.fetch("definition_bytes")
431
+ unless bytes.is_a?(String) && bytes.bytesize <= 32_768 && params["definition_digest"].is_a?(String) &&
432
+ Digest::SHA256.hexdigest(bytes) == params["definition_digest"]
433
+ raise ArgumentError, "invalid assignment definition digest or size"
434
+ end
435
+ value = JSON.parse(bytes)
436
+ allowed = %w[session_id name description created_at updated_at source_config parent task_id project_id]
437
+ unless value.is_a?(Hash) && (value.keys - allowed).empty? &&
438
+ %w[session_id name created_at source_config task_id project_id].all? { |key| value[key].is_a?(String) && !value[key].empty? } &&
439
+ value["session_id"] == params["assignment_id"] && value["project_id"] == map["project_id"]
440
+ raise ArgumentError, "invalid managed assignment definition"
441
+ end
442
+ assignment = Models::Assignment.from_h(value)
443
+ raise ArgumentError, "definition is not managed" unless assignment.managed?
444
+ previous = definition(journal, params.fetch("assignment_id"), commit: commit)
445
+ if previous && previous["definition_digest"] != params["definition_digest"] && active_events?(journal.read_events(params.fetch("assignment_id")))
446
+ raise AttemptErrors::Conflict, "active assignment definition cannot change"
447
+ end
448
+ path = "execution/definitions/#{params.fetch('assignment_id')}-#{params.fetch('definition_digest')}.json"
449
+ {events: [], blobs: {path => bytes}, data: {"assignment_id" => params.fetch("assignment_id"),
450
+ "project_id" => map.fetch("project_id"), "mapping_id" => params.fetch("mapping_id"),
451
+ "phase" => "registered", "definition_ref" => path, "definition_digest" => params.fetch("definition_digest"),
452
+ "definition_generation" => previous && previous["definition_digest"] == params["definition_digest"] ? previous.fetch("definition_generation") : generation + 1, "task_id" => assignment.task_id}}
453
+ end
454
+
455
+ def reserve(params, map, journal, commit, generation, peer, attempt_id)
456
+ registration = definition(journal, params.fetch("assignment_id"), commit: commit)
457
+ raise AttemptErrors::NotFound, "assignment is unregistered" unless registration && registration["mapping_id"] == params["mapping_id"]
458
+ unless registration["definition_generation"] == params["expected_generation"] && params["worker_uid"] == map["worker_uid"] &&
459
+ params["runtime"] == "herdr" && @kernel.same?(params["launcher_process_binding"], peer)
460
+ raise AttemptErrors::UnauthorizedIdentity, "reservation identity differs"
461
+ end
462
+ scope = Atoms::AssignmentScope.canonicalize(params.fetch("scope"))
463
+ ensure_slot_available!(map, journal)
464
+ events = journal.read_events(params.fetch("assignment_id"))
465
+ events.group_by { |event| event["attempt_id"] }.each_value do |chain|
466
+ intent = chain.find { |event| event["type"] == "intent" }
467
+ next unless intent && !terminal_events?(chain)
468
+ old_scope = intent.fetch("payload").fetch("scope")
469
+ if old_scope == scope || old_scope.start_with?("#{scope}.") || scope.start_with?("#{old_scope}.")
470
+ raise AttemptErrors::Conflict, "scope is already owned"
471
+ end
472
+ end
473
+ head = params.fetch("base_head")
474
+ raise ArgumentError, "invalid base revision" unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
475
+ ticket = SecureRandom.hex(24)
476
+ payload = {"assignment_id" => params.fetch("assignment_id"), "scope" => scope,
477
+ "project_id" => map.fetch("project_id"), "task_id" => registration.fetch("task_id"),
478
+ "base_head" => head, "actor" => map.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr",
479
+ "launcher_identity" => peer, "launch_ticket" => ticket}
480
+ provisioning = {"slot_id" => map.fetch("execution_scope").fetch("slot_id"),
481
+ "deployment_digest" => Digest::SHA256.hexdigest(JSON.generate(canonical(map))),
482
+ "reservation_generation" => generation + 1}
483
+ {events: [{type: "intent", payload: payload}, {type: "scope_provisioning", payload: provisioning}], blobs: {}, data: payload.merge(
484
+ "attempt_id" => attempt_id, "phase" => "reserved", "mapping_id" => params.fetch("mapping_id"),
485
+ "reservation_generation" => generation + 1, "handshake_deadline" => (Time.now.utc + 30).iso8601(6))}
486
+ end
487
+
488
+ def ensure_slot_available!(map, journal, commit: journal.ref_value)
489
+ slot = map.fetch("execution_scope").fetch("slot_id")
490
+ journal.assignment_ids(commit: commit).each do |assignment_id|
491
+ journal.read_events(assignment_id, commit: commit).group_by { |event| event.fetch("attempt_id") }.each do |attempt_id, chain|
492
+ unless Models::EvidenceEvent.chain_valid?(chain)
493
+ raise AttemptErrors::EvidenceUnavailable, "slot reservation chain is corrupt"
494
+ end
495
+ reservations = chain.select do |event|
496
+ event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt"
497
+ end
498
+ next if reservations.empty?
499
+ raise AttemptErrors::EvidenceUnavailable, "attempt has conflicting reservations" unless reservations.size == 1
500
+ reservation = reservations.first.fetch("payload").fetch("data")
501
+ provisioning = chain.select { |event| event["type"] == "scope_provisioning" }
502
+ unless provisioning.size == 1
503
+ raise AttemptErrors::EvidenceUnavailable, "canonical slot provisioning identity is incomplete"
504
+ end
505
+ owner = provisioning.first.fetch("payload")
506
+ unless owner.is_a?(Hash) && owner.keys.sort == %w[deployment_digest reservation_generation slot_id] &&
507
+ owner["reservation_generation"] == reservation.fetch("reservation_generation") &&
508
+ owner["deployment_digest"].is_a?(String) && Molecules::ExecutionScopeLineage::DIGEST.match?(owner["deployment_digest"])
509
+ raise AttemptErrors::EvidenceUnavailable, "canonical slot provisioning identity differs"
510
+ end
511
+ prior_map = @deployment.mapping(reservation.fetch("mapping_id"))
512
+ unless prior_map.fetch("execution_scope").fetch("slot_id") == owner.fetch("slot_id") &&
513
+ prior_map.fetch("project_id") == reservation.fetch("project_id")
514
+ raise AttemptErrors::EvidenceUnavailable, "historical slot owner was removed or moved"
515
+ end
516
+ next unless owner.fetch("slot_id") == slot
517
+ lineage = Molecules::ExecutionScopeLineage.new(events: chain, project_id: map.fetch("project_id"),
518
+ assignment_id: assignment_id, attempt_id: attempt_id, mapping_id: reservation.fetch("mapping_id"))
519
+ unless lineage.binding.nil? || lineage.binding.fetch("deployment_digest") == owner.fetch("deployment_digest")
520
+ raise AttemptErrors::EvidenceUnavailable, "parent binding differs from original slot provisioning"
521
+ end
522
+ # Terminality alone never clears this slot: canonical closure is
523
+ # separately mandatory, including after the authority restarts.
524
+ unless terminal_events?(chain) && lineage.proof_id && scope_reservation_released?(chain, lineage, journal, commit)
525
+ raise AttemptErrors::Conflict, "execution slot has an unreleased canonical reservation"
526
+ end
527
+ scope_settlement_complete!(journal: journal, events: chain, params: {"mapping_id" => reservation.fetch("mapping_id"),
528
+ "assignment_id" => assignment_id, "attempt_id" => attempt_id}, map: prior_map, commit: commit)
529
+ end
530
+ end
531
+ true
532
+ rescue KeyError
533
+ raise AttemptErrors::EvidenceUnavailable, "canonical slot ownership is incomplete"
534
+ end
535
+
536
+ def record(params, map, events, peer)
537
+ state, observation = owned(params, events, peer)
538
+ raise AttemptErrors::Conflict, "launch cannot record another child" unless state["phase"] == "reserved"
539
+ binding = validate_binding!(params.fetch("process_binding"), map, mapping_id: params.fetch("mapping_id"), launch_ticket: params.fetch("launch_ticket"), assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), events: events)
540
+ child = binding.fetch("process_identity")
541
+ @kernel.live!(child)
542
+ if observation[:child_handle]
543
+ unless @kernel.same?(observation.fetch(:child), child)
544
+ raise AttemptErrors::Conflict, "record retry differs from observed child"
545
+ end
546
+ else
547
+ observation[:child_handle] = @kernel.pin(child)
548
+ observation[:child] = child
549
+ end
550
+ {events: [], blobs: {}, data: state.merge("phase" => "recorded", "process_binding" => binding)}
551
+ end
552
+
553
+ def bind(params, map, events, peer)
554
+ state, = owned(params, events, peer)
555
+ unless state["phase"] == "recorded" && state["process_binding"] == params["process_binding"]
556
+ raise AttemptErrors::Conflict, "binding differs from recorded original child"
557
+ end
558
+ validate_binding!(params.fetch("process_binding"), map, mapping_id: params.fetch("mapping_id"), launch_ticket: params.fetch("launch_ticket"), assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), events: events)
559
+ @kernel.live!(params.fetch("process_binding").fetch("process_identity"))
560
+ payload = {"actor" => map.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr",
561
+ "pid" => params.dig("process_binding", "process_identity", "pid"),
562
+ "process_identity" => params.dig("process_binding", "process_identity"), "runtime_binding" => params.fetch("process_binding")}
563
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
564
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
565
+ child = {"scope_generation" => lineage.binding.fetch("scope_generation"),
566
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "native_binding_event_id" => lineage.native_event.fetch("digest"),
567
+ "original_process_binding" => params.fetch("process_binding")}
568
+ {events: [{type: "scope_child_bound", payload: child}, {type: "process_start", payload: payload}], blobs: {}, data: state.merge("phase" => "bound")}
569
+ end
570
+
571
+ def release(params, map, events, peer)
572
+ state, observation = owned(params, events, peer)
573
+ unless state["phase"] == "bound" && state["process_binding"] == params["process_binding"] && @streams.key?(state.fetch("attempt_id"))
574
+ raise AttemptErrors::Conflict, "exact live bound gate is unavailable"
575
+ end
576
+ validate_binding!(params.fetch("process_binding"), map, mapping_id: params.fetch("mapping_id"), launch_ticket: params.fetch("launch_ticket"), assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), events: events)
577
+ original = scope_open_for_effect!(events: events, params: params, map: map)
578
+ raise AttemptErrors::Conflict, "canonical original child differs" unless original == params.fetch("process_binding")
579
+ @kernel.live!(observation.fetch(:child))
580
+ raise AttemptErrors::Conflict, "launcher has exited" unless launcher_live?(observation)
581
+ {events: [], blobs: {}, data: state.merge("phase" => "issued", "execution" => "potentially_executed")}
582
+ end
583
+
584
+ def abort(params, map, events, peer, supervisor: false, journal:, commit:)
585
+ evidence = params.fetch("failure_evidence")
586
+ unless evidence.is_a?(String) && evidence.bytesize <= 16_384 && Digest::SHA256.hexdigest(evidence) == params["failure_digest"]
587
+ raise ArgumentError, "invalid bounded failure evidence"
588
+ end
589
+ scope_failure = begin
590
+ JSON.parse(evidence)
591
+ rescue JSON::ParserError
592
+ nil
593
+ end
594
+ if scope_failure.is_a?(Hash) && scope_failure["kind"] == "protected_scope_before_release"
595
+ return scope_abort_plan(params, map, events, peer, journal: journal, commit: commit, supervisor: supervisor)
596
+ end
597
+ state, observation = owned(params, events, peer, require_launcher_live: false, supervisor: supervisor)
598
+ issued = events.any? do |event|
599
+ event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch"
600
+ end
601
+ positive = %w[recorded bound uncertain].include?(state["phase"]) && !issued &&
602
+ state["execution"] != "potentially_executed" && observation[:child_handle] && observation[:child] &&
603
+ state.dig("process_binding", "process_identity") == observation[:child] &&
604
+ @kernel.exited?(observation.fetch(:child_handle))
605
+ target = positive ? "failed" : "uncertain"
606
+ proof = if positive
607
+ {"process_identity" => observation.fetch(:child), "exit_observed_at" => Time.now.utc.iso8601(6),
608
+ "bootstrap_sha256" => map.fetch("bootstrap_sha256"), "server_identity" => map.dig("native", "server_identity"),
609
+ "release" => "not_issued", "yama" => 2, "capability_sets" => "empty", "no_new_privs" => 1}
610
+ end
611
+ evidence_ref = "evidence/imports/launch-failure-#{state.fetch('attempt_id')}-#{params.fetch('failure_digest')}"
612
+ reason = positive ? "protected_gate_exited_before_release" : "protected_launch_requires_positive_termination_proof"
613
+ lifecycle_event = if positive && state["phase"] == "uncertain"
614
+ {type: "reconciliation", payload: {"resolution" => "failed", "reason" => reason,
615
+ "failure_digest" => params.fetch("failure_digest"), "abort_observation" => proof}}
616
+ else
617
+ {type: "transition", payload: {"from" => state["phase"] == "uncertain" ? "uncertain" :
618
+ (%w[bound issued].include?(state["phase"]) ? "running" : "reserved"), "to" => target, "reason" => reason}}
619
+ end
620
+ {events: [lifecycle_event],
621
+ blobs: {evidence_ref => evidence}, data: state.merge("phase" => target, "failure_digest" => params.fetch("failure_digest"),
622
+ "failure_ref" => evidence_ref, "abort_observation" => proof,
623
+ "required_action" => positive ? nil : "supervisor_inspect_exact_child_and_release_uncertainty")}
624
+ end
625
+
626
+ def owned(params, events, peer, require_launcher_live: true, supervisor: false)
627
+ state = states(events)[params.fetch("attempt_id")]
628
+ unless state && state["mapping_id"] == params["mapping_id"] && state["launch_ticket"] == params["launch_ticket"] &&
629
+ (supervisor || @kernel.same?(state["launcher_identity"], peer))
630
+ raise AttemptErrors::UnauthorizedIdentity, "launch is not owned by this exact launcher"
631
+ end
632
+ observation = @observations[params.fetch("attempt_id")]
633
+ raise AttemptErrors::EvidenceUnavailable, "exact launch handles require supervisor recovery" unless observation
634
+ if require_launcher_live && observation.fetch(:deadline) <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
635
+ raise AttemptErrors::EvidenceUnavailable, "protected launch handshake expired; supervisor inspection required"
636
+ end
637
+ if require_launcher_live && !launcher_live?(observation)
638
+ raise AttemptErrors::Conflict, "launcher has exited"
639
+ end
640
+ [state, observation]
641
+ end
642
+
643
+ def validate_binding!(binding, map, mapping_id:, launch_ticket:, assignment_id:, attempt_id:, events:)
644
+ strict!(binding, %w[runtime session pane terminal_id process_identity shell_identity native_origin])
645
+ raise ArgumentError, "native runtime differs" unless binding["runtime"] == "herdr" && binding["shell_identity"] == binding["process_identity"]
646
+ identity = binding.fetch("process_identity")
647
+ strict!(identity, %w[pid uid gid groups started_at host parent_pid])
648
+ origin = binding.fetch("native_origin")
649
+ strict!(origin, %w[workspace tab pane server_identity socket_identity command cwd])
650
+ unless origin["command"] == [map.fetch("bootstrap"), mapping_id, launch_ticket] && origin["cwd"] == map.fetch("worker_cwd") &&
651
+ binding["terminal_id"].is_a?(String) && !binding["terminal_id"].empty? && binding["session"].is_a?(String) && binding["pane"].is_a?(String)
652
+ raise AttemptErrors::UnauthorizedIdentity, "native origin command or terminal differs"
653
+ end
654
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
655
+ assignment_id: assignment_id, attempt_id: attempt_id, mapping_id: mapping_id)
656
+ lineage.require_open!
657
+ native = lineage.native_event&.fetch("payload")
658
+ raise AttemptErrors::EvidenceUnavailable, "original canonical native stage is missing" unless native
659
+ server = native.fetch("server_identity")
660
+ unless identity.is_a?(Hash) && identity["uid"] == map["worker_uid"] && identity["gid"] == map["worker_gid"] &&
661
+ identity["groups"] == map["worker_groups"] && identity["parent_pid"] == server["pid"] &&
662
+ binding.dig("native_origin", "server_identity") == server &&
663
+ binding.dig("native_origin", "socket_identity") == native.fetch("socket_identity") &&
664
+ binding.dig("native_origin", "workspace") == map.dig("native", "workspace_id") &&
665
+ binding.dig("native_origin", "workspace") == binding["session"] && binding.dig("native_origin", "pane") == binding["pane"]
666
+ raise AttemptErrors::UnauthorizedIdentity, "original native child lineage differs"
667
+ end
668
+ @kernel.live!(server)
669
+ binding
670
+ end
671
+
672
+ def launcher_live?(observation)
673
+ return false unless observation && observation[:launcher_handle] && !@kernel.exited?(observation.fetch(:launcher_handle))
674
+ @kernel.live!(observation.fetch(:launcher))
675
+ true
676
+ rescue StandardError
677
+ false
678
+ end
679
+
680
+ def definition(journal, id, commit: journal.ref_value)
681
+ journal.read_events(id).reverse.filter_map do |event|
682
+ next unless event["type"] == "authority_mutation" && event.dig("payload", "operation") == "register_assignment"
683
+ event.dig("payload", "data")
684
+ end.first
685
+ end
686
+
687
+ def states(events)
688
+ events.each_with_object({}) do |event, result|
689
+ next unless event["type"] == "authority_mutation" && MUTATIONS.key?(event.dig("payload", "operation"))
690
+ data = event.dig("payload", "data")
691
+ next unless data.is_a?(Hash) && data["launch_ticket"]
692
+ result[event.fetch("attempt_id")] = data
693
+ end
694
+ end
695
+
696
+ def inspect_launch(params, peer:, role:)
697
+ token!(params.fetch("assignment_id"))
698
+ token!(params.fetch("attempt_id"))
699
+ map = @deployment.mapping(params.fetch("mapping_id"))
700
+ journal = journal_for(map)
701
+ @kernel.live!(peer)
702
+ with_exclusion(params, map, journal) do
703
+ @mutex.synchronize do
704
+ commit = journal.ref_value
705
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
706
+ state = origin(events, assignment_id: params.fetch("assignment_id"),
707
+ attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
708
+ if role == :launcher && !@kernel.same?(state.fetch("launcher_identity"), peer)
709
+ raise AttemptErrors::UnauthorizedIdentity, "launch belongs to another launcher incarnation"
710
+ end
711
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
712
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
713
+ state = state.merge("generation" => journal.authority_generation(events), "journal_commit" => commit)
714
+ unless state["process_binding"]
715
+ return state.merge("native_binding" => lineage.native_event&.fetch("payload"),
716
+ "scope_binding_event_id" => lineage.binding_event&.fetch("digest"),
717
+ "scope_failure_evidence" => lineage.proof_event && {"kind" => "protected_scope_before_release",
718
+ "scope_generation" => lineage.binding.fetch("scope_generation"),
719
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"),
720
+ "seal_event_id" => lineage.seal_event.fetch("digest"), "proof_id" => lineage.proof_id},
721
+ "required_action" => lineage.binding ? "close_scope_before_release" : "inspect_exact_scope")
722
+ end
723
+ identity = state.fetch("process_binding") { raise AttemptErrors::EvidenceUnavailable, "no recorded original child; inspection cannot infer absence" }.fetch("process_identity")
724
+ observation = @observations[params.fetch("attempt_id")]
725
+ if observation && observation[:child_handle] && observation[:child] == identity && @kernel.exited?(observation.fetch(:child_handle))
726
+ return state.merge("journal_commit" => journal.ref_value,
727
+ "termination_observation" => {"status" => "exited", "process_identity" => identity},
728
+ "required_action" => "request_abort_using_retained_exact_exit_proof")
729
+ end
730
+ native = lineage.native_event&.fetch("payload")
731
+ raise AttemptErrors::EvidenceUnavailable, "original canonical native stage is missing" unless native
732
+ @kernel.live!(native.fetch("server_identity"))
733
+ @kernel.live!(identity)
734
+ unless observation
735
+ child_handle = @kernel.pin(identity)
736
+ @observations[params.fetch("attempt_id")] = {child: identity, child_handle: child_handle,
737
+ launcher: state.fetch("launcher_identity"), launcher_handle: nil, deadline: 0}
738
+ end
739
+ state.merge("journal_commit" => journal.ref_value,
740
+ "termination_observation" => {"status" => "alive", "process_identity" => identity},
741
+ "required_action" => "close_original_native_child_then_request_abort")
742
+ end
743
+ end
744
+ end
745
+
746
+ def status(params, peer:, role:)
747
+ map = @deployment.mapping(params.fetch("mapping_id"))
748
+ token!(params.fetch("assignment_id")); token!(params.fetch("attempt_id"))
749
+ journal = journal_for(map)
750
+ @mutex.synchronize do
751
+ commit = journal.ref_value
752
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit)
753
+ state = states(events)[params.fetch("attempt_id")]
754
+ raise AttemptErrors::NotFound, "launch not found" unless state && state["mapping_id"] == params["mapping_id"]
755
+ if role == :launcher && !@kernel.same?(state.fetch("launcher_identity"), peer)
756
+ raise AttemptErrors::UnauthorizedIdentity, "launch belongs to another launcher incarnation"
757
+ end
758
+ projection = state.slice("assignment_id", "attempt_id", "phase", "scope", "launch_ticket", "reservation_generation", "generation", "execution", "required_action", "handshake_deadline")
759
+ if %i[launcher supervisor].include?(role)
760
+ projection.merge!(state.slice("process_binding", "launcher_identity", "mapping_id"))
761
+ end
762
+ observation = @observations[params.fetch("attempt_id")]
763
+ expired = !%w[issued failed].include?(state["phase"]) && state["handshake_deadline"] && Time.iso8601(state.fetch("handshake_deadline")) <= Time.now.utc
764
+ child_exited = observation && observation[:child_handle] &&
765
+ observation[:child] == state.dig("process_binding", "process_identity") && @kernel.exited?(observation.fetch(:child_handle))
766
+ if !TERMINAL.include?(state["phase"]) && (!observation || !launcher_live?(observation) || expired || child_exited)
767
+ projection.merge!("phase" => "uncertain", "required_action" => "supervisor_inspect_exact_child_and_release_uncertainty")
768
+ end
769
+ if @result_owner
770
+ chain = events.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
771
+ projection.merge!(@result_owner.result_status(journal: journal, events: chain, params: params,
772
+ map: map, peer: peer, role: role, commit: commit))
773
+ end
774
+ projection.merge("journal_commit" => commit)
775
+ end
776
+ end
777
+
778
+ def active_events?(events)
779
+ events.group_by { |event| event["attempt_id"] }.values.any? { |chain| chain.any? { |event| event["type"] == "intent" } && !terminal_events?(chain) }
780
+ end
781
+ def terminal_events?(events)
782
+ latest = events.reverse.filter_map do |event|
783
+ case event["type"]
784
+ when "process_start" then "running"
785
+ when "transition" then event.dig("payload", "to")
786
+ when "receipt_accepted" then event.dig("payload", "receipt", "verdict")
787
+ when "reconciliation" then event.dig("payload", "resolution")
788
+ end
789
+ end.first
790
+ TERMINAL.include?(latest)
791
+ end
792
+ def strict!(value, keys)
793
+ raise ArgumentError, "authority fields differ" unless value.is_a?(Hash) && value.keys.sort == keys.sort
794
+ end
795
+ def token!(value)
796
+ raise ArgumentError, "invalid public identifier" unless value.is_a?(String) && Deployment::TOKEN.match?(value)
797
+ value
798
+ end
799
+ def generation!(value, allow_nil: false)
800
+ return if allow_nil && value.nil?
801
+ raise ArgumentError, "invalid journal generation" unless value.is_a?(Integer) && value >= 0
802
+ end
803
+ def canonical(value)
804
+ case value
805
+ when Hash then value.sort.to_h.transform_values { |item| canonical(item) }
806
+ when Array then value.map { |item| canonical(item) }
807
+ else value
808
+ end
809
+ end
810
+ def wire
811
+ Ace::Runtime::Molecules::ProtectedSocket
812
+ end
813
+ end
814
+ end
815
+ end
816
+ end
817
+
818
+ require_relative "launch_scope_abort"
819
+ require_relative "launch_scope_admission"
820
+ require_relative "launch_scope_close"
821
+ require_relative "launch_scope_release"
822
+ require_relative "launch_scope_parent"