ace-assign 0.63.0 → 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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +51 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +119 -6
|
@@ -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"
|