ace-assign 0.70.0 → 0.73.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/auto-merge.step.yml +8 -8
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/config.yml +0 -13
- data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
- data/CHANGELOG.md +3 -2099
- data/README.md +58 -31
- data/docs/exit-codes.md +36 -44
- data/docs/getting-started.md +9 -4
- data/docs/usage.md +129 -522
- data/handbook/skills/as-assign-drive/SKILL.md +1 -1
- data/handbook/workflow-instructions/assign/drive.wf.md +47 -118
- data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
- data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
- data/lib/ace/assign/cli/commands/create.rb +1 -8
- data/lib/ace/assign/cli/commands/fail.rb +0 -6
- data/lib/ace/assign/cli/commands/finish.rb +2 -23
- data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
- data/lib/ace/assign/cli/commands/select.rb +0 -1
- data/lib/ace/assign/cli/commands/start.rb +0 -2
- data/lib/ace/assign/cli/commands/status.rb +1 -31
- data/lib/ace/assign/cli/commands/step.rb +1 -3
- data/lib/ace/assign/cli.rb +1 -85
- data/lib/ace/assign/errors.rb +8 -80
- data/lib/ace/assign/models/assignment.rb +5 -28
- data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
- data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
- data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +0 -6
- metadata +4 -161
- data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
- data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
- data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
- data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
- data/docs/protected-authority.md +0 -282
- data/exe/ace-scope-ready +0 -55
- data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
- data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
- data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
- data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
- data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
- data/lib/ace/assign/authority/assignment_definition.rb +0 -35
- data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
- data/lib/ace/assign/authority/campaign_execution.rb +0 -119
- data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
- data/lib/ace/assign/authority/candidate_transfer.rb +0 -327
- data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
- data/lib/ace/assign/authority/client.rb +0 -257
- data/lib/ace/assign/authority/deployment.rb +0 -627
- data/lib/ace/assign/authority/deployment_history.rb +0 -146
- data/lib/ace/assign/authority/endcap.rb +0 -455
- data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
- data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
- data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
- data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
- data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
- data/lib/ace/assign/authority/endcap_publication.rb +0 -207
- data/lib/ace/assign/authority/endcap_results.rb +0 -398
- data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
- data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
- data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
- data/lib/ace/assign/authority/endcap_services.rb +0 -598
- data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
- data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
- data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
- data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
- data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
- data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
- data/lib/ace/assign/authority/launch_driver.rb +0 -393
- data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
- data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
- data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
- data/lib/ace/assign/authority/launch_review.rb +0 -91
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
- data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
- data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
- data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
- data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
- data/lib/ace/assign/authority/launch_steering.rb +0 -353
- data/lib/ace/assign/authority/launch_stop.rb +0 -254
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
- data/lib/ace/assign/authority/ordinary_client.rb +0 -70
- data/lib/ace/assign/authority/ordinary_completion.rb +0 -94
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -191
- data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
- data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
- data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
- data/lib/ace/assign/authority/ordinary_results.rb +0 -196
- data/lib/ace/assign/authority/ordinary_server.rb +0 -75
- data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
- data/lib/ace/assign/authority/posix_acl.rb +0 -59
- data/lib/ace/assign/authority/prepared_input.rb +0 -130
- data/lib/ace/assign/authority/prepared_queue.rb +0 -185
- data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
- data/lib/ace/assign/authority/prepared_work.rb +0 -304
- data/lib/ace/assign/authority/prepared_worker.rb +0 -69
- data/lib/ace/assign/authority/private_directory.rb +0 -33
- data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
- data/lib/ace/assign/authority/readiness_hook.rb +0 -120
- data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
- data/lib/ace/assign/authority/router.rb +0 -81
- data/lib/ace/assign/authority/server.rb +0 -131
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
- data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
- data/lib/ace/assign/authority/service_evidence.rb +0 -286
- data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
- data/lib/ace/assign/authority/socket_server.rb +0 -255
- data/lib/ace/assign/authority/task_context_entry.rb +0 -32
- data/lib/ace/assign/authority/transfer_codec.rb +0 -187
- data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
- data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
- data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
- data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
- data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
- data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
- data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
- data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
- data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
- data/lib/ace/assign/cli/commands/delivery.rb +0 -107
- data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
- data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
- data/lib/ace/assign/cli/commands/resume.rb +0 -38
- data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
- data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
- data/lib/ace/assign/models/attempt.rb +0 -178
- data/lib/ace/assign/models/attempt_binding.rb +0 -116
- data/lib/ace/assign/models/evidence_event.rb +0 -86
- data/lib/ace/assign/models/execution_receipt.rb +0 -139
- data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
- data/lib/ace/assign/molecules/attempt_store.rb +0 -201
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
- data/lib/ace/assign/molecules/canonical_evidence.rb +0 -153
- data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
- data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
- data/lib/ace/assign/molecules/evidence_journal.rb +0 -1912
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
- data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
- data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
- data/lib/ace/assign/molecules/journal_mutation.rb +0 -273
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
- data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
- data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
- data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
- data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
- data/lib/ace/assign/molecules/receipt_verifier.rb +0 -314
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
- data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1337
- data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
- data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
|
@@ -1,1337 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
require "json"
|
|
5
|
-
require "open3"
|
|
6
|
-
require "pathname"
|
|
7
|
-
require "ace/herdr/errors"
|
|
8
|
-
require "ace/herdr/models/delivery_record"
|
|
9
|
-
require "ace/herdr/molecules/delivery_record_store"
|
|
10
|
-
require_relative "../molecules/canonical_attempt_state"
|
|
11
|
-
|
|
12
|
-
module Ace
|
|
13
|
-
module Assign
|
|
14
|
-
module Organisms
|
|
15
|
-
# Authoritative coordinator for assignment attempts.
|
|
16
|
-
#
|
|
17
|
-
# The coordinator is the only authority allowed to reserve and start
|
|
18
|
-
# attempts, accept authoritative receipts, transition attempt state,
|
|
19
|
-
# invalidate stale candidate evidence, and write accepted journal
|
|
20
|
-
# events. It composes the attempt store (local ownership), the evidence
|
|
21
|
-
# journal (managed Git-ref evidence), the identity resolver (trusted
|
|
22
|
-
# boundary), and the receipt verifier (acceptance rules).
|
|
23
|
-
#
|
|
24
|
-
# Guarantee: only one active attempt owns an assignment + subtree;
|
|
25
|
-
# `base_head` is captured exactly once at start; `candidate_head` is
|
|
26
|
-
# pinned only when candidate-bound evidence is accepted and any change
|
|
27
|
-
# to the candidate revision invalidates prior candidate authorization;
|
|
28
|
-
# `journal_commit` is tracked separately from both.
|
|
29
|
-
class AttemptCoordinator
|
|
30
|
-
SERVICE_RECEIPT_FIELDS = %w[assignment_id attempt_id candidate_head evidence executor_uid
|
|
31
|
-
input_digest operation outcome project_id request_id target transport].freeze
|
|
32
|
-
SERVICE_BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
|
|
33
|
-
input_digest target candidate_head executor_uid transport].freeze
|
|
34
|
-
# @param cache_base [String, nil] Assignment cache base
|
|
35
|
-
# @param repo_root [String, nil] Candidate repository root (default: project root)
|
|
36
|
-
# @param journal [Molecules::EvidenceJournal, nil] Evidence journal (default built per repo)
|
|
37
|
-
# @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
|
|
38
|
-
# @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
|
|
39
|
-
def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
|
|
40
|
-
lifecycle_exclusion: nil, recorded_binding_observer: nil)
|
|
41
|
-
unless recorded_binding_observer.nil? || recorded_binding_observer.respond_to?(:call)
|
|
42
|
-
raise ArgumentError, "recorded native binding observer must be callable"
|
|
43
|
-
end
|
|
44
|
-
@manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
45
|
-
@store = @manager.attempt_store
|
|
46
|
-
@repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
47
|
-
@journal = journal
|
|
48
|
-
@identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
|
|
49
|
-
@verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
|
|
50
|
-
@lifecycle_exclusion = lifecycle_exclusion
|
|
51
|
-
@recorded_binding_observer = recorded_binding_observer
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
attr_reader :store
|
|
55
|
-
|
|
56
|
-
# Pure producer plan. The protected lifecycle authenticates the original
|
|
57
|
-
# proof and both exhaustive owner projections anew at each CAS prefix.
|
|
58
|
-
# No caller receipt, cache state, native wait or store mutation enters it.
|
|
59
|
-
def self.stopped_transition_plan(events:, selection:, service_evidence:, commit:)
|
|
60
|
-
Atoms::AttemptStateMachine.proof_stopped_transition!(Molecules::CanonicalAttemptState.derive(events))
|
|
61
|
-
payload = Molecules::CanonicalAttemptState.stopped_payload!(events: events, selection: selection,
|
|
62
|
-
service_evidence: service_evidence, commit: commit)
|
|
63
|
-
{events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# The source-selected result owner authenticates imported bytes, candidate,
|
|
67
|
-
# closure and independent settlements at the same CAS prefix.
|
|
68
|
-
def self.finished_transition_plan(events:, receipt:, authority:)
|
|
69
|
-
raise ArgumentError, "unknown result authority" unless %i[ordinary protected].include?(authority)
|
|
70
|
-
state = Molecules::CanonicalAttemptState.derive(events)
|
|
71
|
-
unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
|
|
72
|
-
raise AttemptErrors::Conflict, "Result finish requires a running attempt"
|
|
73
|
-
end
|
|
74
|
-
Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
|
|
75
|
-
{events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
|
|
76
|
-
{type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "#{authority}_result_accepted"}}], blobs: {}}
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
|
|
80
|
-
return ["restart-required", "terminal_attempt"] if %w[succeeded failed stopped].include?(state)
|
|
81
|
-
reason = if effects_pending
|
|
82
|
-
"unresolved_effect"
|
|
83
|
-
elsif state == "uncertain"
|
|
84
|
-
"attempt_uncertain"
|
|
85
|
-
elsif !bound
|
|
86
|
-
"launch_unbound"
|
|
87
|
-
elsif !checkpoint
|
|
88
|
-
"checkpoint_unavailable"
|
|
89
|
-
elsif !live
|
|
90
|
-
"scope_unverifiable"
|
|
91
|
-
end
|
|
92
|
-
reason ? ["reconcile-required", reason] : ["adopt", "live_owner"]
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def self.protected_recovery_plan(events:, decision:, reason:, observation:)
|
|
96
|
-
state = Molecules::CanonicalAttemptState.derive(events)
|
|
97
|
-
if %w[succeeded failed stopped].include?(state)
|
|
98
|
-
raise AttemptErrors::Conflict, "Terminal recovery is read-only"
|
|
99
|
-
end
|
|
100
|
-
planned = [{type: "recovery_observation", payload: {"decision" => decision,
|
|
101
|
-
"recovery_reason" => reason, "observation" => observation}}]
|
|
102
|
-
if state == "running" && decision != "adopt"
|
|
103
|
-
Atoms::AttemptStateMachine.transition!(state, "uncertain")
|
|
104
|
-
planned << {type: "transition", payload: {"from" => state, "to" => "uncertain", "reason" => reason}}
|
|
105
|
-
state = "uncertain"
|
|
106
|
-
end
|
|
107
|
-
{events: planned, blobs: {}, state: state}
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Read-only exact native reverse binding from accepted history. A PID
|
|
111
|
-
# supplied by a trusted boundary must be kernel-derived; no command
|
|
112
|
-
# flag, environment address or local cache grants this authority.
|
|
113
|
-
# Missing/dead/changed owners remain a classified refusal.
|
|
114
|
-
def runtime_binding(attempt_id:, caller_pid: nil)
|
|
115
|
-
attempt = recover_managed_attempt(nil, attempt_id)
|
|
116
|
-
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found in accepted history" unless attempt
|
|
117
|
-
unless attempt.managed? && attempt.active?
|
|
118
|
-
raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
|
|
119
|
-
end
|
|
120
|
-
binding = reconciler.recorded_binding(attempt)
|
|
121
|
-
unless binding && %w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
|
|
122
|
-
raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
|
|
123
|
-
end
|
|
124
|
-
unless caller_pid.nil? || (caller_pid.is_a?(Integer) && caller_pid.positive?)
|
|
125
|
-
raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
|
|
126
|
-
end
|
|
127
|
-
observe_runtime_binding!(attempt, binding, caller_pid)
|
|
128
|
-
latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
|
|
129
|
-
unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
|
|
130
|
-
raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
|
|
131
|
-
end
|
|
132
|
-
binding
|
|
133
|
-
rescue Ace::Runtime::Error => e
|
|
134
|
-
raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# An installed consumer selects its own bounded native reader. It must
|
|
138
|
-
# never fall back to the service process's ambient runtime on failure.
|
|
139
|
-
def observe_runtime_binding!(attempt, binding, caller_pid)
|
|
140
|
-
if @recorded_binding_observer
|
|
141
|
-
actual = @recorded_binding_observer.call(binding: binding, caller_pid: caller_pid)
|
|
142
|
-
unless actual == binding
|
|
143
|
-
raise AttemptErrors::UnauthorizedIdentity, "Installed native owner observation differs"
|
|
144
|
-
end
|
|
145
|
-
return
|
|
146
|
-
end
|
|
147
|
-
observation = reconciler.observation(attempt)
|
|
148
|
-
unless observation["liveness"] == "live" && binding["process_identity"] == observation["identity"]
|
|
149
|
-
raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
|
|
150
|
-
end
|
|
151
|
-
return if caller_pid.nil?
|
|
152
|
-
caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
|
|
153
|
-
unless caller == binding
|
|
154
|
-
raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
private :observe_runtime_binding!
|
|
158
|
-
|
|
159
|
-
# Claim an external service effect before dispatch. Only a managed,
|
|
160
|
-
# active attempt for the exact project and candidate may own it. The
|
|
161
|
-
# guard re-runs inside the journal lock per CAS attempt, so a
|
|
162
|
-
# terminal event landing between validation and commit still blocks
|
|
163
|
-
# the claim.
|
|
164
|
-
def claim_service_request(binding)
|
|
165
|
-
attempt = service_attempt(binding)
|
|
166
|
-
head = candidate_head!
|
|
167
|
-
valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
|
|
168
|
-
unless valid_head
|
|
169
|
-
raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
|
|
170
|
-
end
|
|
171
|
-
require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
|
|
172
|
-
# Dispatching claims record uncertain directly: the effect may
|
|
173
|
-
# happen as soon as the claim lands, and a crash before the
|
|
174
|
-
# executor response must leave uncertainty, not a stranded
|
|
175
|
-
# accepted state that a retry would return without dispatching.
|
|
176
|
-
journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def proposal_authorization(reference, binding)
|
|
180
|
-
journal_for.proposal_authorize!(reference, binding)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Rechecked against the authoritative ref for every locked claim
|
|
184
|
-
# attempt: the attempt must still be journal-active and the exact
|
|
185
|
-
# authorization must still be unconsumed.
|
|
186
|
-
def claim_guard(binding)
|
|
187
|
-
derived = journal_for.derived_attempts(binding.fetch("assignment_id"))
|
|
188
|
-
.find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
|
|
189
|
-
unless derived&.active?
|
|
190
|
-
raise AttemptErrors::ReceiptRejected, "Attempt is no longer active in the authoritative journal"
|
|
191
|
-
end
|
|
192
|
-
journal_for.authorization_conflict(binding)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def service_attempt(binding)
|
|
196
|
-
# An effect can never acquire authority from a requester cache, even
|
|
197
|
-
# when the assignment definition is missing from this consumer.
|
|
198
|
-
attempt = journal_for.derived_attempts(binding.fetch("assignment_id"))
|
|
199
|
-
.find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
|
|
200
|
-
raise AttemptErrors::NotFound, "Attempt not found" unless attempt
|
|
201
|
-
unless attempt.managed? && attempt.active? &&
|
|
202
|
-
attempt.binding.assignment_id == binding["assignment_id"] &&
|
|
203
|
-
attempt.binding.project_id == binding["project_id"]
|
|
204
|
-
raise AttemptErrors::ReceiptRejected, "Service request does not match an active managed attempt"
|
|
205
|
-
end
|
|
206
|
-
attempt
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# The authoritative journal wins for managed assignments, and a
|
|
210
|
-
# managed service claim REQUIRES the journal-derived attempt: a lost
|
|
211
|
-
# or unavailable evidence ref fails closed instead of trusting a
|
|
212
|
-
# local cache record that the journal cannot corroborate.
|
|
213
|
-
def authoritative_attempt(binding)
|
|
214
|
-
assignment_id = binding.fetch("assignment_id")
|
|
215
|
-
assignment = @manager.load(assignment_id)
|
|
216
|
-
if assignment&.managed?
|
|
217
|
-
journal_for.derived_attempts(assignment_id)
|
|
218
|
-
.find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
|
|
219
|
-
else
|
|
220
|
-
@store.find(binding.fetch("attempt_id")) ||
|
|
221
|
-
recover_managed_attempt(assignment_id, binding.fetch("attempt_id"))
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# The exact-authority check behind with_verified_attempt: the
|
|
226
|
-
# journal-derived managed attempt must be live (reserved/running —
|
|
227
|
-
# uncertainty is unproven liveness and never grants external
|
|
228
|
-
# authority) and bound to the exact assignment, project, and
|
|
229
|
-
# requesting actor.
|
|
230
|
-
def verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
|
|
231
|
-
attempt = journal_for.derived_attempts(assignment_id)
|
|
232
|
-
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
233
|
-
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
234
|
-
|
|
235
|
-
unless attempt.managed? && %w[reserved running].include?(attempt.state) &&
|
|
236
|
-
attempt.binding.assignment_id == assignment_id &&
|
|
237
|
-
attempt.binding.project_id == project_id
|
|
238
|
-
raise AttemptErrors::ReceiptRejected,
|
|
239
|
-
"Authority request does not match an active managed attempt"
|
|
240
|
-
end
|
|
241
|
-
unless attempt.binding.actor == requester
|
|
242
|
-
raise AttemptErrors::UnauthorizedIdentity,
|
|
243
|
-
"Requester #{requester.inspect} does not own attempt '#{attempt_id}'"
|
|
244
|
-
end
|
|
245
|
-
attempt
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def reject_service_request(binding, reason:)
|
|
249
|
-
service_attempt(binding)
|
|
250
|
-
journal_for.reject_service_request(binding, reason: reason)
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def service_request_status(request_id)
|
|
254
|
-
journal_for.service_request(request_id)
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def transition_service_request(request_id, state:, receipt: nil)
|
|
258
|
-
if %w[succeeded failed].include?(state)
|
|
259
|
-
request = journal_for.service_request(request_id)
|
|
260
|
-
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
|
|
261
|
-
validate_service_receipt!(request, state, receipt)
|
|
262
|
-
elsif receipt
|
|
263
|
-
raise AttemptErrors::ReceiptRejected, "Non-terminal service transition cannot carry a receipt"
|
|
264
|
-
end
|
|
265
|
-
journal_for.transition_service_request(request_id, state: state, receipt: receipt, validated: true)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# Settle a failed effect as "no effect occurred". The settlement is
|
|
269
|
-
# evidence-validated like a terminal receipt — the executor must
|
|
270
|
-
# attest this request with outcome:failed and attributable evidence
|
|
271
|
-
# — and the original failure event stays in the journal history.
|
|
272
|
-
def reconcile_service_failure(request_id, receipt:)
|
|
273
|
-
request = journal_for.service_request(request_id)
|
|
274
|
-
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
|
|
275
|
-
unless %w[failed rejected uncertain].include?(request["state"]) &&
|
|
276
|
-
(request["state"] != "rejected" || request["consumed"] != false)
|
|
277
|
-
raise AttemptErrors::InvalidState,
|
|
278
|
-
"Only dispatched (failed, uncertain, or rejected) service requests can be reconciled"
|
|
279
|
-
end
|
|
280
|
-
settle_no_effect(request_id, request, receipt)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
# Settle an uncertain claim whose handler was never invoked (a
|
|
284
|
-
# pre-dispatch refusal observed by the trusted local service). Same
|
|
285
|
-
# evidence rules as a failure settlement: a distinct, executor-owned
|
|
286
|
-
# no-effect attestation bound to this request.
|
|
287
|
-
def reconcile_service_no_effect(request_id, receipt:)
|
|
288
|
-
request = journal_for.service_request(request_id)
|
|
289
|
-
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
|
|
290
|
-
unless request["state"] == "uncertain"
|
|
291
|
-
raise AttemptErrors::InvalidState, "Only uncertain service requests can settle as no-effect"
|
|
292
|
-
end
|
|
293
|
-
settle_no_effect(request_id, request, receipt)
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
def settle_no_effect(request_id, request, receipt)
|
|
297
|
-
# The settlement must be a distinct, later executor attestation:
|
|
298
|
-
# replaying the already-recorded failure receipt proves nothing
|
|
299
|
-
# about whether the effect took place.
|
|
300
|
-
if request["receipt"] && Atoms::EvidenceDigest.digest(receipt) ==
|
|
301
|
-
Atoms::EvidenceDigest.digest(request["receipt"])
|
|
302
|
-
raise AttemptErrors::ReceiptRejected,
|
|
303
|
-
"Service failure settlement requires a new attestation, not the recorded receipt"
|
|
304
|
-
end
|
|
305
|
-
validate_service_receipt!(request, "failed", receipt, require_no_effect: true)
|
|
306
|
-
journal_for.transition_service_request(request_id, state: "failed-settled",
|
|
307
|
-
receipt: receipt, validated: true)
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
# ---- ordinary fixed merge primitive (spec 8x3.t.xz9.4) -------------
|
|
311
|
-
#
|
|
312
|
-
# The shared creation/resolution/read-only-status primitive the
|
|
313
|
-
# ordinary receiver composes. Every durable write and read for
|
|
314
|
-
# ordinary fixed merge requests lives in the canonical evidence
|
|
315
|
-
# journal — this API never creates a receiver-local record.
|
|
316
|
-
|
|
317
|
-
# Admit one ordinary fixed merge request. The opaque reference is
|
|
318
|
-
# created here and returned only through the durable record; callers
|
|
319
|
-
# receive the canonical record with its journal commit, so an
|
|
320
|
-
# acknowledgment is never issued before durability. An identical
|
|
321
|
-
# resubmission replays the original record; any different field
|
|
322
|
-
# under the same request ID, or a reused authorization, is a
|
|
323
|
-
# conflict/refusal — never a second effect.
|
|
324
|
-
def admit_fixed_merge_request(submission)
|
|
325
|
-
record = stringified(submission).merge("state" => "pending",
|
|
326
|
-
"reference" => Molecules::FixedEffectReference.create)
|
|
327
|
-
# The guard raises the distinct error itself, so journal conflicts
|
|
328
|
-
# never need message-string discrimination: replay/binding conflicts
|
|
329
|
-
# stay Conflict, a consumed authorization is AuthorizationConflict.
|
|
330
|
-
guard = lambda do
|
|
331
|
-
conflict = journal_for.fixed_authorization_conflict(record)
|
|
332
|
-
if conflict
|
|
333
|
-
raise AttemptErrors::AuthorizationConflict,
|
|
334
|
-
"Authorization reference is not currently valid"
|
|
335
|
-
end
|
|
336
|
-
nil
|
|
337
|
-
end
|
|
338
|
-
journal_for.admit_fixed_merge_request(record, guard: guard)
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Begin the fixed effect (pending -> uncertain). The single CAS
|
|
342
|
-
# transition is the at-most-one-merge proof: a restarted receiver
|
|
343
|
-
# refuses an already-started claim and must not dispatch again.
|
|
344
|
-
def begin_fixed_merge_effect(request_id)
|
|
345
|
-
journal_for.begin_fixed_merge_request(request_id)
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
# Settle the fixed effect with the exact terminal receipt. The
|
|
349
|
-
# journal validates the closed receipt schema and its binding to the
|
|
350
|
-
# admitted record before the transition lands.
|
|
351
|
-
def settle_fixed_merge_effect(request_id, terminal_status:, receipt:, attestation: nil)
|
|
352
|
-
journal_for.settle_fixed_merge_request(request_id, terminal_status: terminal_status,
|
|
353
|
-
receipt: receipt, attestation: attestation)
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
# Read-only canonical record lookup by request ID: lets the producer
|
|
357
|
-
# recognize an exact replay (and return the original acknowledgment)
|
|
358
|
-
# or a conflicting reuse without any receiver-local state.
|
|
359
|
-
def fixed_merge_record(request_id)
|
|
360
|
-
journal_for.fixed_merge_request(request_id)
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
# Read-only listing of every canonical fixed merge record: lets the
|
|
364
|
-
# receiver restart reconciliation find its own still-pending records
|
|
365
|
-
# by binding. Read-only; performs no dispatch and owns no state.
|
|
366
|
-
def fixed_merge_records
|
|
367
|
-
journal_for.fixed_merge_request_records
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
# Read-only status: resolve the opaque reference against the
|
|
371
|
-
# canonical record and verify every selector. Performs no dispatch,
|
|
372
|
-
# retry, authorization refresh or selector repair. Returns the
|
|
373
|
-
# frozen canonical record, or nil when the reference or any selector
|
|
374
|
-
# does not match — the caller must answer a mismatch identically
|
|
375
|
-
# without revealing whether another project holds the reference.
|
|
376
|
-
def fixed_merge_status(reference:, request_id:, project_id:, assignment_id:, attempt_id:,
|
|
377
|
-
queue_entry:, input_digest:)
|
|
378
|
-
record = journal_for.fixed_merge_request(request_id)
|
|
379
|
-
return nil unless record
|
|
380
|
-
matches = reference == record["reference"] && input_digest == record["input_digest"] &&
|
|
381
|
-
[project_id, assignment_id, attempt_id, queue_entry].zip(
|
|
382
|
-
%w[project_id assignment_id attempt_id queue_entry].map { |key| record[key] }
|
|
383
|
-
).all? { |supplied, canonical| supplied == canonical }
|
|
384
|
-
matches ? freeze_canonical(record) : nil
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
def freeze_canonical(record)
|
|
388
|
-
JSON.parse(JSON.generate(record)).freeze
|
|
389
|
-
end
|
|
390
|
-
private :freeze_canonical
|
|
391
|
-
|
|
392
|
-
def stringified(submission)
|
|
393
|
-
unless submission.is_a?(Hash)
|
|
394
|
-
raise ArgumentError, "fixed merge submission must be a mapping"
|
|
395
|
-
end
|
|
396
|
-
submission.each_with_object({}) do |(key, value), result|
|
|
397
|
-
result[key.to_s] = value
|
|
398
|
-
end
|
|
399
|
-
end
|
|
400
|
-
private :stringified
|
|
401
|
-
|
|
402
|
-
def validate_service_receipt!(request, state, receipt, require_no_effect: false)
|
|
403
|
-
unless receipt.is_a?(Hash) && receipt.keys.sort == SERVICE_RECEIPT_FIELDS.sort
|
|
404
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt has invalid fields"
|
|
405
|
-
end
|
|
406
|
-
SERVICE_BINDING_FIELDS.each do |key|
|
|
407
|
-
unless receipt[key] == request[key]
|
|
408
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt does not match #{key}"
|
|
409
|
-
end
|
|
410
|
-
end
|
|
411
|
-
unless receipt["outcome"] == state && receipt["executor_uid"].is_a?(Integer) &&
|
|
412
|
-
receipt["executor_uid"] >= 0 && valid_service_evidence?(receipt["evidence"])
|
|
413
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt has invalid executor or evidence"
|
|
414
|
-
end
|
|
415
|
-
unless receipt["executor_uid"] == request["executor_uid"]
|
|
416
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt executor does not match the claimed executor"
|
|
417
|
-
end
|
|
418
|
-
# Local transport runs the executor in the submitting process, so
|
|
419
|
-
# the submitter must be the configured executor identity itself.
|
|
420
|
-
if request["transport"] == "local" && Process.uid != request["executor_uid"]
|
|
421
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt submitter is not the configured executor"
|
|
422
|
-
end
|
|
423
|
-
verify_service_evidence!(receipt["evidence"], request, state, require_no_effect: require_no_effect)
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def valid_service_evidence?(evidence)
|
|
427
|
-
evidence.is_a?(Array) && !evidence.empty? && evidence.all? do |item|
|
|
428
|
-
item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
|
|
429
|
-
item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
|
|
430
|
-
item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
431
|
-
end
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
# Evidence must live inside the candidate repository (symlinks
|
|
435
|
-
# resolved), be owned by — and not writable by anyone but — the
|
|
436
|
-
# claimed executor identity, postdate the claim, and carry a
|
|
437
|
-
# structured attestation naming the claimed request, input digest,
|
|
438
|
-
# and attested outcome. All checks read one open handle, so a swap
|
|
439
|
-
# on a caller-writable path cannot mix files between checks.
|
|
440
|
-
def verify_service_evidence!(evidence, request, state, require_no_effect: false)
|
|
441
|
-
if @journal&.evidence_mode == :protected
|
|
442
|
-
receipt = request.slice(*SERVICE_BINDING_FIELDS).merge("outcome" => state, "evidence" => evidence)
|
|
443
|
-
@journal.validate_terminal_receipt!(request, require_no_effect ? "failed-settled" : state, receipt)
|
|
444
|
-
return true
|
|
445
|
-
end
|
|
446
|
-
repo_root = File.realpath(@repo_root)
|
|
447
|
-
claimed_at = parse_claimed_at(request)
|
|
448
|
-
evidence.each do |item|
|
|
449
|
-
ref = item["ref"]
|
|
450
|
-
unless Pathname.new(ref).relative?
|
|
451
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence must be repository-relative: #{ref}"
|
|
452
|
-
end
|
|
453
|
-
path = File.expand_path(ref, repo_root)
|
|
454
|
-
unless path.start_with?(repo_root + File::SEPARATOR)
|
|
455
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
|
|
456
|
-
end
|
|
457
|
-
begin
|
|
458
|
-
file = File.open(path, "rb")
|
|
459
|
-
rescue Errno::ENOENT
|
|
460
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence is unavailable: #{ref}"
|
|
461
|
-
end
|
|
462
|
-
begin
|
|
463
|
-
real = File.realpath(path)
|
|
464
|
-
unless real.start_with?(repo_root + File::SEPARATOR)
|
|
465
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
|
|
466
|
-
end
|
|
467
|
-
stat = file.stat
|
|
468
|
-
# The open handle must still be the file at the validated
|
|
469
|
-
# location: a swap between resolution and open fails here.
|
|
470
|
-
opened = File.stat(real)
|
|
471
|
-
unless opened.dev == stat.dev && opened.ino == stat.ino
|
|
472
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence changed during verification: #{ref}"
|
|
473
|
-
end
|
|
474
|
-
unless stat.uid == request["executor_uid"] && (stat.mode & 0o022).zero?
|
|
475
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence is not executor-owned: #{ref}"
|
|
476
|
-
end
|
|
477
|
-
unless claimed_at.nil? || stat.mtime >= claimed_at
|
|
478
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the claim: #{ref}"
|
|
479
|
-
end
|
|
480
|
-
failed_at = parse_failed_at(request)
|
|
481
|
-
unless failed_at.nil? || stat.mtime >= failed_at
|
|
482
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the failure: #{ref}"
|
|
483
|
-
end
|
|
484
|
-
content = file.read
|
|
485
|
-
unless attestation_line(content, request, state, require_no_effect: require_no_effect)
|
|
486
|
-
raise AttemptErrors::ReceiptRejected,
|
|
487
|
-
"Service receipt evidence does not bind the claimed request: #{ref}"
|
|
488
|
-
end
|
|
489
|
-
unless Digest::SHA256.hexdigest(content) == item["sha256"]
|
|
490
|
-
raise AttemptErrors::ReceiptRejected, "Service receipt evidence digest mismatch: #{ref}"
|
|
491
|
-
end
|
|
492
|
-
ensure
|
|
493
|
-
file.close
|
|
494
|
-
end
|
|
495
|
-
end
|
|
496
|
-
end
|
|
497
|
-
|
|
498
|
-
# The evidence artifact must carry a structured attestation line
|
|
499
|
-
# naming the claimed request, input digest, and attested outcome
|
|
500
|
-
# exactly: an unrelated executor-owned file cannot attest this
|
|
501
|
-
# effect, prose cannot substitute for the attested outcome, and its
|
|
502
|
-
# result cannot be recorded under another terminal state.
|
|
503
|
-
def attestation_line(content, request, state, require_no_effect: false)
|
|
504
|
-
attestation = /^ace-service-attestation request:#{Regexp.escape(request.fetch("request_id"))} \
|
|
505
|
-
input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(\S+))?$/
|
|
506
|
-
line = content.scan(attestation).first
|
|
507
|
-
return false unless line
|
|
508
|
-
return false if require_no_effect && line[2] != "true"
|
|
509
|
-
%w[succeeded failed].include?(state) ? line.first == state : true
|
|
510
|
-
end
|
|
511
|
-
|
|
512
|
-
def parse_claimed_at(request)
|
|
513
|
-
return nil if request["claimed_at"].nil?
|
|
514
|
-
Time.iso8601(request["claimed_at"])
|
|
515
|
-
rescue ArgumentError
|
|
516
|
-
raise AttemptErrors::ReceiptRejected, "Service request has an invalid claim timestamp"
|
|
517
|
-
end
|
|
518
|
-
|
|
519
|
-
def parse_failed_at(request)
|
|
520
|
-
return nil if request["failed_at"].nil?
|
|
521
|
-
Time.iso8601(request["failed_at"])
|
|
522
|
-
rescue ArgumentError
|
|
523
|
-
raise AttemptErrors::ReceiptRejected, "Service request has an invalid failure timestamp"
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
# Read-only eligibility check for dry-run: applies the same review
|
|
527
|
-
# evidence and candidate-head requirements as the claim without
|
|
528
|
-
# reserving anything.
|
|
529
|
-
def request_eligible!(binding)
|
|
530
|
-
attempt = service_attempt(binding)
|
|
531
|
-
head = candidate_head!
|
|
532
|
-
valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
|
|
533
|
-
unless valid_head
|
|
534
|
-
raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
|
|
535
|
-
end
|
|
536
|
-
require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
|
|
537
|
-
true
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
# Hold verified authority over one managed attempt for the duration
|
|
541
|
-
# of a single external transition (the HITL consume/deliver
|
|
542
|
-
# critical sections). The shared exclusion side is held across the
|
|
543
|
-
# yielded transition, so a terminal commit (finish/reconcile hold
|
|
544
|
-
# the exclusive side) or a prune cannot land between the liveness
|
|
545
|
-
# check and the caller's commit: stale, ended or replaced attempts
|
|
546
|
-
# cannot acquire new authority. Authority is attribution over
|
|
547
|
-
# journal facts and the requesting identity — never a process id,
|
|
548
|
-
# runtime marker, or caller-supplied name.
|
|
549
|
-
#
|
|
550
|
-
# @param assignment_id [String] Owning assignment ID
|
|
551
|
-
# @param attempt_id [String] Attempt ID
|
|
552
|
-
# @param project_id [String] Project the attempt must be bound to
|
|
553
|
-
# @param requester [String] Authenticated requester (execution-
|
|
554
|
-
# boundary identity of the caller, matched against binding actor)
|
|
555
|
-
# @yield [attempt] the verified managed attempt
|
|
556
|
-
# @raise [AttemptErrors::NotFound, AttemptErrors::ReceiptRejected,
|
|
557
|
-
# AttemptErrors::UnauthorizedIdentity] on any authority doubt
|
|
558
|
-
def with_verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
|
|
559
|
-
assignment_id = assignment_id.to_s
|
|
560
|
-
attempt_id = attempt_id.to_s
|
|
561
|
-
lifecycle_exclusion.with_shared(lifecycle_exclusion.assignment_key(assignment_id)) do
|
|
562
|
-
attempt = verified_attempt(
|
|
563
|
-
assignment_id: assignment_id, attempt_id: attempt_id,
|
|
564
|
-
project_id: project_id.to_s, requester: requester.to_s
|
|
565
|
-
)
|
|
566
|
-
yield attempt
|
|
567
|
-
end
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Start a scoped attempt for an assignment.
|
|
571
|
-
#
|
|
572
|
-
# A repeated identical start returns the existing active attempt; a
|
|
573
|
-
# conflicting or scope-overlapping binding raises
|
|
574
|
-
# {AttemptErrors::Conflict} without ever launching a second writer.
|
|
575
|
-
# Active attempts are resolved from the local store AND, for managed
|
|
576
|
-
# assignments, from the authoritative journal, so a lost local record
|
|
577
|
-
# cannot admit a competing writer.
|
|
578
|
-
#
|
|
579
|
-
# @param assignment_id [String] Assignment ID
|
|
580
|
-
# @param step [String] Step/subtree scope
|
|
581
|
-
# @param project_id [String] Project the attempt executes in
|
|
582
|
-
# @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
|
|
583
|
-
# @return [Models::Attempt] Active attempt (reused, recovered, or newly reserved)
|
|
584
|
-
def start(assignment_id:, step:, project_id:, identity: nil)
|
|
585
|
-
assignment = @manager.load(assignment_id)
|
|
586
|
-
raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment
|
|
587
|
-
|
|
588
|
-
scope = Atoms::AssignmentScope.canonicalize(step)
|
|
589
|
-
project = project_id.to_s.strip
|
|
590
|
-
raise Error, "attempt start requires --project ID" if project.empty?
|
|
591
|
-
|
|
592
|
-
identity ||= @identity_resolver.resolve
|
|
593
|
-
base_head = candidate_head!
|
|
594
|
-
|
|
595
|
-
# Serialize writer registration against prune: prune holds the
|
|
596
|
-
# exclusive exclusion from its final evidence reads through removal.
|
|
597
|
-
# A worktree-backed task identity shares the same protocol (task
|
|
598
|
-
# key first, then assignment key — the one consistent global
|
|
599
|
-
# order), so a worktree prune and an attempt start cannot interleave.
|
|
600
|
-
start_keys = []
|
|
601
|
-
unless assignment.task_id.to_s.strip.empty?
|
|
602
|
-
start_keys << lifecycle_exclusion.task_key(assignment.task_id)
|
|
603
|
-
end
|
|
604
|
-
start_keys << lifecycle_exclusion.assignment_key(assignment_id)
|
|
605
|
-
lifecycle_exclusion.with_shared_multi(start_keys) do
|
|
606
|
-
@store.with_lock(assignment_id) do
|
|
607
|
-
journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
|
|
608
|
-
|
|
609
|
-
existing = journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) } ||
|
|
610
|
-
@store.active(assignment_id, scope)
|
|
611
|
-
if existing
|
|
612
|
-
if existing.state == "reserved"
|
|
613
|
-
raise AttemptErrors::Conflict,
|
|
614
|
-
"Unresolved reservation #{existing.attempt_id} owns #{assignment_id}@#{scope}; " \
|
|
615
|
-
"protected launch reconciliation needs positive no-execution/no-surviving-writer proof before abort"
|
|
616
|
-
end
|
|
617
|
-
if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
|
|
618
|
-
existing.binding.actor == identity.actor && existing.binding.role == identity.role
|
|
619
|
-
return existing
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
raise AttemptErrors::Conflict,
|
|
623
|
-
"Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
|
|
624
|
-
"(project #{existing.binding.project_id}); refusing to launch a second writer"
|
|
625
|
-
end
|
|
626
|
-
|
|
627
|
-
active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
|
|
628
|
-
active_scopes.concat(journal_actives.map(&:binding))
|
|
629
|
-
blocker = active_scopes.find { |binding| Atoms::AssignmentScope.overlap?(binding.scope, scope) }
|
|
630
|
-
if blocker
|
|
631
|
-
raise AttemptErrors::Conflict,
|
|
632
|
-
"Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
|
|
633
|
-
"refusing overlapping writers"
|
|
634
|
-
end
|
|
635
|
-
|
|
636
|
-
attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
|
|
637
|
-
events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
|
|
638
|
-
attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
|
|
639
|
-
if attempt.managed?
|
|
640
|
-
commit = journal_for.append(
|
|
641
|
-
assignment_id: assignment_id,
|
|
642
|
-
attempt_id: attempt.attempt_id,
|
|
643
|
-
events: events
|
|
644
|
-
)
|
|
645
|
-
attempt = attempt.with(journal_commit: commit)
|
|
646
|
-
attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
|
|
647
|
-
else
|
|
648
|
-
attempt = attempt.with(events: events)
|
|
649
|
-
end
|
|
650
|
-
|
|
651
|
-
@store.save(attempt)
|
|
652
|
-
@store.claim(assignment_id, scope, attempt)
|
|
653
|
-
attempt
|
|
654
|
-
end
|
|
655
|
-
end
|
|
656
|
-
end
|
|
657
|
-
|
|
658
|
-
# Accept a receipt for an attempt and transition it.
|
|
659
|
-
#
|
|
660
|
-
# @param attempt_id [String] Attempt ID
|
|
661
|
-
# @param receipt_path [String] Path to the receipt JSON file
|
|
662
|
-
# @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
|
|
663
|
-
# @return [Models::Attempt] Updated attempt
|
|
664
|
-
def finish(attempt_id:, receipt_path:, identity: nil)
|
|
665
|
-
data = read_receipt_file(receipt_path)
|
|
666
|
-
assignment_id = data["assignment_id"].to_s
|
|
667
|
-
|
|
668
|
-
# Terminal commits hold the exclusive exclusion so an external
|
|
669
|
-
# liveness reader (with_verified_attempt holds the shared side
|
|
670
|
-
# across its whole transition) can never observe the attempt as
|
|
671
|
-
# active after the terminal event landed.
|
|
672
|
-
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
|
|
673
|
-
# Serialize load-validate-accept so concurrent finishes observe one
|
|
674
|
-
# consistent state and cannot persist contradictory terminal outcomes.
|
|
675
|
-
@store.with_lock(assignment_id) do
|
|
676
|
-
attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
|
|
677
|
-
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
678
|
-
|
|
679
|
-
derived = ensure_journal_consistent!(attempt) if attempt.managed?
|
|
680
|
-
attempt = attempt.with(state: derived.state) if derived
|
|
681
|
-
if attempt.state == "reserved"
|
|
682
|
-
raise AttemptErrors::EvidenceUnavailable,
|
|
683
|
-
"Reservation #{attempt_id} is unbound; protected launch reconciliation requires positive " \
|
|
684
|
-
"no-execution/no-surviving-writer proof before abort"
|
|
685
|
-
end
|
|
686
|
-
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
|
|
687
|
-
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
|
|
688
|
-
|
|
689
|
-
identity ||= @identity_resolver.resolve
|
|
690
|
-
live_head = candidate_head!
|
|
691
|
-
|
|
692
|
-
attempt = invalidate_stale_candidate(attempt, live_head)
|
|
693
|
-
|
|
694
|
-
receipt = @verifier.verify!(
|
|
695
|
-
data,
|
|
696
|
-
attempt: attempt,
|
|
697
|
-
identity: identity,
|
|
698
|
-
live_head: live_head,
|
|
699
|
-
repo_root: @repo_root
|
|
700
|
-
)
|
|
701
|
-
if @verifier.external_effect?(receipt.operation)
|
|
702
|
-
unless attempt.managed?
|
|
703
|
-
raise AttemptErrors::InvalidState,
|
|
704
|
-
"Taskless attempts cannot record external effects without managed evidence"
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
require_review_evidence(attempt, live_head)
|
|
708
|
-
end
|
|
709
|
-
|
|
710
|
-
accept(attempt, receipt, live_head)
|
|
711
|
-
end
|
|
712
|
-
end
|
|
713
|
-
end
|
|
714
|
-
|
|
715
|
-
# Active-attempt projection for status surfaces.
|
|
716
|
-
#
|
|
717
|
-
# @param assignment_id [String] Assignment ID
|
|
718
|
-
# @return [Hash, nil] Projection fields or nil when no attempt exists
|
|
719
|
-
def status(assignment_id)
|
|
720
|
-
attempts = @store.list(assignment_id)
|
|
721
|
-
attempt = attempts.find(&:active?) || attempts.first
|
|
722
|
-
return nil unless attempt
|
|
723
|
-
|
|
724
|
-
attempt.projection
|
|
725
|
-
end
|
|
726
|
-
|
|
727
|
-
# Reconstruct recovery from accepted history. Local caches and pane
|
|
728
|
-
# titles never override journal authority. This method has no writes.
|
|
729
|
-
def recovery_snapshot(assignment_id)
|
|
730
|
-
assignment = @manager.load(assignment_id)
|
|
731
|
-
managed = assignment&.managed? || journal_for.assignment_ids.include?(assignment_id)
|
|
732
|
-
attempts = managed ? journal_for.derived_attempts(assignment_id) : @store.list(assignment_id)
|
|
733
|
-
raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment || attempts.any?
|
|
734
|
-
|
|
735
|
-
requests = managed ? journal_for.service_requests(assignment_id) : []
|
|
736
|
-
unresolved = requests.reject do |request|
|
|
737
|
-
%w[succeeded failed-settled].include?(request["state"]) ||
|
|
738
|
-
request["state"] == "rejected" && request["consumed"] == false
|
|
739
|
-
end
|
|
740
|
-
queue = assignment && Molecules::QueueScanner.new.scan(assignment.steps_dir, assignment: assignment)
|
|
741
|
-
inbox_records = recovery_inboxes(attempts)
|
|
742
|
-
recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
|
|
743
|
-
observation = reconciler.observation(attempt)
|
|
744
|
-
blocked = unresolved.any? { |request| request["attempt_id"] == attempt.attempt_id }
|
|
745
|
-
unstarted = !reconciler.process_started?(attempt)
|
|
746
|
-
decision, reason = if blocked || attempt.uncertain?
|
|
747
|
-
["reconcile-required", blocked ? "unresolved external effect" :
|
|
748
|
-
"attempt remains uncertain"]
|
|
749
|
-
elsif attempt.terminal?
|
|
750
|
-
["restart-required", "attempt ended; restart requires a new attributable attempt"]
|
|
751
|
-
elsif unstarted
|
|
752
|
-
["reconcile-required", "launch binding unavailable; positive no-execution/no-surviving-writer proof required before abort"]
|
|
753
|
-
elsif !assignment || observation["liveness"] != "live"
|
|
754
|
-
["reconcile-required", (!assignment) ? "assignment checkpoint unavailable" : observation["reason"]]
|
|
755
|
-
else
|
|
756
|
-
["adopt", "verified owner survives; preserve current attempt"]
|
|
757
|
-
end
|
|
758
|
-
receipt_history = reconciler.events(attempt).select { |event| event["type"] == "receipt_accepted" }
|
|
759
|
-
.filter_map { |event| event.dig("payload", "receipt") }
|
|
760
|
-
accepted_effects = receipt_history.select { |receipt| @verifier.external_effect?(receipt["operation"]) }
|
|
761
|
-
.map { |receipt| receipt.slice("operation", "head", "digest", "verdict") }
|
|
762
|
-
attempt.projection.merge("decision" => decision, "recovery_reason" => reason,
|
|
763
|
-
"accepted_effects" => accepted_effects,
|
|
764
|
-
"accepted_receipt_digests" => receipt_history.map { |receipt| receipt["digest"] },
|
|
765
|
-
"liveness" => observation["liveness"], "last_verified_observation" =>
|
|
766
|
-
((observation["liveness"] == "live") ? observation : last_verified_observation(attempt)),
|
|
767
|
-
"observation" => observation)
|
|
768
|
-
end
|
|
769
|
-
decision = if recovered.empty? || recovered.any? { |item| item["decision"] == "reconcile-required" }
|
|
770
|
-
"reconcile-required"
|
|
771
|
-
elsif recovered.any? { |item| item["decision"] == "adopt" }
|
|
772
|
-
"adopt"
|
|
773
|
-
else
|
|
774
|
-
"restart-required"
|
|
775
|
-
end
|
|
776
|
-
{"assignment_id" => assignment_id, "task_id" => assignment&.task_id,
|
|
777
|
-
"latest_attempt_id" => recovered.last&.dig("attempt_id"),
|
|
778
|
-
"decision" => decision, "liveness" => (decision == "adopt") ? "live" : "unknown",
|
|
779
|
-
"last_verified_observation" => recovered.filter_map { |item| item["last_verified_observation"] }
|
|
780
|
-
.max_by { |item| item["observed_at"].to_s },
|
|
781
|
-
"recovery_reason" => recovered.empty? ? "accepted attempt evidence unavailable" :
|
|
782
|
-
recovered.find { |item| item["decision"] == decision }&.dig("recovery_reason"),
|
|
783
|
-
"goal" => assignment&.description || assignment&.name,
|
|
784
|
-
"checkpoint" => queue && {"state" => queue.assignment_state.to_s,
|
|
785
|
-
"active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
|
|
786
|
-
"attempts" => recovered, "inbox_events" => inbox_records,
|
|
787
|
-
"pending_hitl" => queue ? queue.steps.filter_map do |step|
|
|
788
|
-
id = step.stall_reason.to_s[/\AHITL:\s*([a-zA-Z0-9_.-]+)/, 1]
|
|
789
|
-
{"event_id" => id, "scope" => step.number, "rebind" => false} if id
|
|
790
|
-
end : [], "unresolved_effects" => unresolved.map do |request|
|
|
791
|
-
request.slice("request_id", "attempt_id", "operation", "state", "candidate_head")
|
|
792
|
-
end}
|
|
793
|
-
end
|
|
794
|
-
|
|
795
|
-
def resume(assignment_id:, dry_run: false)
|
|
796
|
-
return recovery_snapshot(assignment_id) if dry_run
|
|
797
|
-
|
|
798
|
-
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
|
|
799
|
-
@store.with_lock(assignment_id) do
|
|
800
|
-
snapshot = recovery_snapshot(assignment_id)
|
|
801
|
-
snapshot.fetch("attempts").each do |projection|
|
|
802
|
-
attempt = recover_managed_attempt(assignment_id, projection["attempt_id"]) ||
|
|
803
|
-
@store.load(assignment_id, projection["attempt_id"])
|
|
804
|
-
next unless attempt && !attempt.terminal?
|
|
805
|
-
|
|
806
|
-
event = Models::EvidenceEvent.build(type: "recovery_observation", attempt_id: attempt.attempt_id,
|
|
807
|
-
payload: projection.slice("decision", "recovery_reason", "observation"),
|
|
808
|
-
previous_digest: last_event_digest(attempt))
|
|
809
|
-
attempt = append_events(attempt, [event])
|
|
810
|
-
if attempt.state == "running" && projection["decision"] != "adopt"
|
|
811
|
-
target = (projection["decision"] == "restart-required") ? "stopped" : "uncertain"
|
|
812
|
-
attempt = append_events(attempt, [transition_event(attempt, target, projection["recovery_reason"])])
|
|
813
|
-
attempt = attempt.transition(target)
|
|
814
|
-
@store.release(assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
|
|
815
|
-
end
|
|
816
|
-
@store.save(attempt)
|
|
817
|
-
end
|
|
818
|
-
recovery_snapshot(assignment_id)
|
|
819
|
-
end
|
|
820
|
-
end
|
|
821
|
-
end
|
|
822
|
-
|
|
823
|
-
# Inbox delivery is transport evidence, never business-effect proof.
|
|
824
|
-
# Herdr owns signature, pinned key, generation and native identity
|
|
825
|
-
# verification; this consumer records only its verified references.
|
|
826
|
-
|
|
827
|
-
# Register a transport reference in the existing attempt journal so
|
|
828
|
-
# a missing delivery record cannot be mistaken for no pending effect.
|
|
829
|
-
def bind_inbox(attempt_id:, event_id:, inbox:, identity: nil)
|
|
830
|
-
identity ||= @identity_resolver.resolve
|
|
831
|
-
attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
832
|
-
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
833
|
-
|
|
834
|
-
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
|
|
835
|
-
@store.with_lock(attempt.binding.assignment_id) do
|
|
836
|
-
attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
|
|
837
|
-
@store.load(attempt.binding.assignment_id, attempt_id)
|
|
838
|
-
unless attempt.active? && attempt.binding.actor == identity.actor
|
|
839
|
-
raise AttemptErrors::UnauthorizedIdentity, "Inbox registration must belong to the active attempt owner"
|
|
840
|
-
end
|
|
841
|
-
record = inbox.status(event: event_id)
|
|
842
|
-
unless record["attempt_id"] == attempt_id
|
|
843
|
-
raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
|
|
844
|
-
end
|
|
845
|
-
payload = record.slice("event_id", "attempt_id", "payload_sha256")
|
|
846
|
-
existing = reconciler.events(attempt).find do |event|
|
|
847
|
-
event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
|
|
848
|
-
end
|
|
849
|
-
if existing && existing["payload"] != payload
|
|
850
|
-
raise AttemptErrors::ReceiptRejected, "Inbox binding changed"
|
|
851
|
-
end
|
|
852
|
-
unless existing
|
|
853
|
-
event = Models::EvidenceEvent.build(type: "inbox_binding", attempt_id: attempt_id,
|
|
854
|
-
payload: payload, previous_digest: last_event_digest(attempt))
|
|
855
|
-
@store.save(append_events(attempt, [event]))
|
|
856
|
-
end
|
|
857
|
-
payload
|
|
858
|
-
end
|
|
859
|
-
end
|
|
860
|
-
end
|
|
861
|
-
|
|
862
|
-
# Read accepted check evidence without transitions, locks, cache writes
|
|
863
|
-
# or audit checkout creation. Managed history comes from the Git ref.
|
|
864
|
-
def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
|
|
865
|
-
unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
|
|
866
|
-
raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
|
|
867
|
-
end
|
|
868
|
-
if !historical_head.nil? && (!historical_head.is_a?(String) ||
|
|
869
|
-
!historical_head.match?(/\A[0-9a-f]{40,64}\z/))
|
|
870
|
-
raise AttemptErrors::ReceiptRejected, "Historical evidence requires an exact recorded head"
|
|
871
|
-
end
|
|
872
|
-
unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
|
|
873
|
-
receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
|
|
874
|
-
raise AttemptErrors::ReceiptRejected, "Invalid attempt ID or receipt digest"
|
|
875
|
-
end
|
|
876
|
-
attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
877
|
-
raise AttemptErrors::NotFound, "Attempt #{attempt_id} not found" unless attempt
|
|
878
|
-
raise AttemptErrors::ReceiptRejected, "Execution evidence requires managed immutable journal history" unless attempt.managed?
|
|
879
|
-
attempt = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
|
|
880
|
-
raise AttemptErrors::ReceiptRejected, "No succeeded accepted attempt" unless attempt&.state == "succeeded"
|
|
881
|
-
accepted = journal_for.accepted_receipts(attempt.binding.assignment_id)
|
|
882
|
-
data = accepted.find { |r| r["attempt_id"] == attempt_id && r["digest"] == receipt_digest }
|
|
883
|
-
raise AttemptErrors::ReceiptRejected, "Receipt was not accepted by coordinator" unless data
|
|
884
|
-
if kind == "check" && (%w[review review-collect].include?(data["operation"]) || @verifier.external_effect?(data["operation"]))
|
|
885
|
-
raise AttemptErrors::ReceiptRejected, "Review or external-effect operations cannot be check evidence"
|
|
886
|
-
end
|
|
887
|
-
expected_operation = case kind
|
|
888
|
-
when "review-collection" then "review-collect"
|
|
889
|
-
when "review-approval" then "review"
|
|
890
|
-
else (check_name == "tests") ? "test" : check_name
|
|
891
|
-
end
|
|
892
|
-
unless data["campaign"].nil? && data["operation"] == expected_operation
|
|
893
|
-
raise AttemptErrors::ReceiptRejected, "Accepted receipt operation does not prove #{kind}: #{expected_operation}"
|
|
894
|
-
end
|
|
895
|
-
required_check = (kind == "review-collection") ? "review-execution" : check_name
|
|
896
|
-
has_required_check = Array(data["checks"]).any? { |check| check["name"] == required_check && check["verdict"] == "passed" }
|
|
897
|
-
unless kind == "review-approval" || has_required_check
|
|
898
|
-
raise AttemptErrors::ReceiptRejected, "Accepted receipt does not prove the required check #{required_check}"
|
|
899
|
-
end
|
|
900
|
-
receipt = Models::ExecutionReceipt.from_h(data)
|
|
901
|
-
unless receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
|
|
902
|
-
raise AttemptErrors::ReceiptRejected, "Accepted receipt digest is corrupt"
|
|
903
|
-
end
|
|
904
|
-
evidence_head = historical_head || candidate_head!
|
|
905
|
-
unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
|
|
906
|
-
raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
|
|
907
|
-
end
|
|
908
|
-
@verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root,
|
|
909
|
-
historical: !historical_head.nil?)
|
|
910
|
-
{"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
|
|
911
|
-
"historical" => !historical_head.nil?,
|
|
912
|
-
"operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
|
|
913
|
-
"artifacts" => data["artifacts"], "assignment_id" => data["assignment_id"],
|
|
914
|
-
"project_id" => data["project_id"], "scope" => data["scope"],
|
|
915
|
-
"evidence_git_ref" => attempt.binding.evidence_git_ref, "journal_commit" => attempt.journal_commit}
|
|
916
|
-
end
|
|
917
|
-
|
|
918
|
-
# Reconcile an interrupted attempt.
|
|
919
|
-
#
|
|
920
|
-
# Running attempts are classified conservatively (stopped before
|
|
921
|
-
# process start, uncertain when an effect cannot be proven either
|
|
922
|
-
# way, still running only for verifiably live processes). Uncertain
|
|
923
|
-
# attempts resolve only against a verified receipt attributed to the
|
|
924
|
-
# recorded execution boundary. Reconciliation never replays merge,
|
|
925
|
-
# publish, or deploy effects.
|
|
926
|
-
#
|
|
927
|
-
# @param attempt_id [String] Attempt ID
|
|
928
|
-
# @param receipt_path [String, nil] Receipt JSON for resolving uncertainty
|
|
929
|
-
# @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
|
|
930
|
-
# @return [Models::Attempt] Updated attempt
|
|
931
|
-
def reconcile(attempt_id:, receipt_path: nil, identity: nil)
|
|
932
|
-
probe = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
933
|
-
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless probe
|
|
934
|
-
|
|
935
|
-
# Terminal commits hold the exclusive exclusion (see finish).
|
|
936
|
-
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(probe.binding.assignment_id)) do
|
|
937
|
-
@store.with_lock(probe.binding.assignment_id) do
|
|
938
|
-
attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
|
|
939
|
-
recover_managed_attempt(probe.binding.assignment_id, attempt_id)
|
|
940
|
-
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
941
|
-
|
|
942
|
-
derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
|
|
943
|
-
# The journal is authoritative, including an unbound reservation
|
|
944
|
-
# that a stale local running projection must not promote.
|
|
945
|
-
attempt = attempt.with(state: derived.state) if derived
|
|
946
|
-
|
|
947
|
-
if attempt.terminal?
|
|
948
|
-
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
|
|
949
|
-
end
|
|
950
|
-
|
|
951
|
-
if attempt.state == "reserved"
|
|
952
|
-
raise AttemptErrors::EvidenceUnavailable,
|
|
953
|
-
"Reservation #{attempt_id} requires protected launch reconciliation with positive no-execution/" \
|
|
954
|
-
"no-surviving-writer proof before abort; missing process_start is not proof"
|
|
955
|
-
end
|
|
956
|
-
return classify_running(attempt) if attempt.state == "running"
|
|
957
|
-
|
|
958
|
-
identity ||= @identity_resolver.resolve
|
|
959
|
-
resolve_uncertain(attempt, receipt_path, identity)
|
|
960
|
-
end
|
|
961
|
-
end
|
|
962
|
-
end
|
|
963
|
-
|
|
964
|
-
private
|
|
965
|
-
|
|
966
|
-
def last_verified_observation(attempt)
|
|
967
|
-
event = reconciler.events(attempt).reverse.find do |candidate|
|
|
968
|
-
candidate["type"] == "recovery_observation" && candidate.dig("payload", "observation", "liveness") == "live"
|
|
969
|
-
end
|
|
970
|
-
event&.dig("payload", "observation")
|
|
971
|
-
end
|
|
972
|
-
|
|
973
|
-
def recovery_inboxes(attempts)
|
|
974
|
-
# Ordinary local recovery selects Herdr's configuration only here;
|
|
975
|
-
# protected launch admission must not initialize that broad entry.
|
|
976
|
-
require "ace/herdr"
|
|
977
|
-
root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
|
|
978
|
-
entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
|
|
979
|
-
registrations = attempts.flat_map do |attempt|
|
|
980
|
-
reconciler.events(attempt).select { |event| event["type"] == "inbox_binding" }
|
|
981
|
-
.map { |event| [attempt, event["payload"]] }
|
|
982
|
-
end
|
|
983
|
-
records = registrations.map do |attempt, payload|
|
|
984
|
-
record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
|
|
985
|
-
attributed = record && record.inbox && record.event_id == payload["event_id"] &&
|
|
986
|
-
record.inbox["attempt_id"] == attempt.attempt_id && payload["attempt_id"] == attempt.attempt_id &&
|
|
987
|
-
record.answer_digest == payload["payload_sha256"]
|
|
988
|
-
state = attributed ? record.state : "unknown"
|
|
989
|
-
payload.slice("event_id", "attempt_id").merge("state" => state)
|
|
990
|
-
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
991
|
-
payload.slice("event_id", "attempt_id").merge("state" => "unknown")
|
|
992
|
-
end
|
|
993
|
-
entries.each do |entry|
|
|
994
|
-
next if records.any? { |record| record["event_id"] == entry[:event_id] }
|
|
995
|
-
record = entry[:record]
|
|
996
|
-
next if record && (!record.inbox || !attempts.any? { |attempt| attempt.attempt_id == record.inbox["attempt_id"] })
|
|
997
|
-
records << {"event_id" => entry[:event_id], "state" => "unknown"}
|
|
998
|
-
end
|
|
999
|
-
records
|
|
1000
|
-
end
|
|
1001
|
-
|
|
1002
|
-
# Attempt IDs must be unique per assignment AND globally: records,
|
|
1003
|
-
# journal events, and reconciliation lookups key on them. Allocation
|
|
1004
|
-
# runs under the assignment lock, avoids journal-known IDs, and
|
|
1005
|
-
# claims the ID atomically in the cross-assignment registry.
|
|
1006
|
-
def unique_attempt_id(assignment_id, taken_ids = [])
|
|
1007
|
-
base = Ace::B36ts.now
|
|
1008
|
-
suffix = 0
|
|
1009
|
-
100.times do
|
|
1010
|
-
candidate = suffix.zero? ? base : "#{base}#{suffix.to_s(36)}"
|
|
1011
|
-
suffix += 1
|
|
1012
|
-
next if taken_ids.include?(candidate)
|
|
1013
|
-
next unless @store.load(assignment_id, candidate).nil?
|
|
1014
|
-
return candidate if @store.reserve_id(candidate)
|
|
1015
|
-
end
|
|
1016
|
-
|
|
1017
|
-
raise Error, "Failed to generate a unique attempt ID for #{assignment_id} after 100 attempts"
|
|
1018
|
-
end
|
|
1019
|
-
|
|
1020
|
-
# Active attempts derived from the authoritative journal, used when
|
|
1021
|
-
# the local record may be missing (lost or wiped cache). Derivation
|
|
1022
|
-
# lives in the journal (owner of attempt-state interpretation).
|
|
1023
|
-
def active_journal_attempts(assignment)
|
|
1024
|
-
journal_for.active_attempts(assignment.id)
|
|
1025
|
-
end
|
|
1026
|
-
|
|
1027
|
-
# Recover a managed attempt from the journal when the local record is
|
|
1028
|
-
# missing. With an assignment ID only that journal is consulted;
|
|
1029
|
-
# without one, assignment IDs are discovered from the evidence ref
|
|
1030
|
-
# itself, so a fully lost cache still reconciles.
|
|
1031
|
-
def recover_managed_attempt(assignment_id, attempt_id)
|
|
1032
|
-
if assignment_id
|
|
1033
|
-
assignment = @manager.load(assignment_id)
|
|
1034
|
-
# A surviving taskless record never consults the journal; a lost
|
|
1035
|
-
# record falls through to journal evidence (only managed attempts
|
|
1036
|
-
# are journaled).
|
|
1037
|
-
return nil if assignment && !assignment.managed?
|
|
1038
|
-
|
|
1039
|
-
return journal_for.derived_attempts(assignment_id)
|
|
1040
|
-
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
1041
|
-
end
|
|
1042
|
-
|
|
1043
|
-
journal_for.assignment_ids.each do |journal_assignment_id|
|
|
1044
|
-
attempt = journal_for.derived_attempts(journal_assignment_id)
|
|
1045
|
-
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
1046
|
-
return attempt if attempt
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
nil
|
|
1050
|
-
end
|
|
1051
|
-
|
|
1052
|
-
# The journal is authoritative for managed attempts: a crash after a
|
|
1053
|
-
# journaled terminal/uncertain event but before the local save must
|
|
1054
|
-
# not admit contradictory transitions.
|
|
1055
|
-
def ensure_journal_consistent!(attempt, allow_uncertain: false)
|
|
1056
|
-
derived = journal_for.derived_attempts(attempt.binding.assignment_id)
|
|
1057
|
-
.find { |candidate| candidate.attempt_id == attempt.attempt_id }
|
|
1058
|
-
return if derived.nil?
|
|
1059
|
-
|
|
1060
|
-
journal_state = derived.state
|
|
1061
|
-
if %w[succeeded failed stopped].include?(journal_state)
|
|
1062
|
-
raise AttemptErrors::InvalidState,
|
|
1063
|
-
"Journal shows attempt #{attempt.attempt_id} is #{journal_state}; accepted history is immutable"
|
|
1064
|
-
end
|
|
1065
|
-
return derived if allow_uncertain || journal_state != "uncertain" || attempt.uncertain?
|
|
1066
|
-
|
|
1067
|
-
raise AttemptErrors::InvalidState,
|
|
1068
|
-
"Journal shows attempt #{attempt.attempt_id} is uncertain; reconcile before finishing"
|
|
1069
|
-
end
|
|
1070
|
-
|
|
1071
|
-
# After appending our start events, revalidate ownership: a
|
|
1072
|
-
# coordinator with a separate cache directory may have claimed the
|
|
1073
|
-
# same scope between our read and our append (journal CAS replay
|
|
1074
|
-
# merges both). The loser records a stopped transition and refuses.
|
|
1075
|
-
def yield_lost_ownership_race(attempt, assignment_id, scope)
|
|
1076
|
-
rival = journal_for.active_attempts(assignment_id).find do |other|
|
|
1077
|
-
other.attempt_id != attempt.attempt_id && Atoms::AssignmentScope.overlap?(other.binding.scope, scope)
|
|
1078
|
-
end
|
|
1079
|
-
return attempt if rival.nil?
|
|
1080
|
-
|
|
1081
|
-
stopped = append_events(attempt, [transition_event(attempt, "stopped", "lost ownership race to #{rival.attempt_id}")])
|
|
1082
|
-
stopped = stopped.transition("stopped")
|
|
1083
|
-
@store.save(stopped)
|
|
1084
|
-
raise AttemptErrors::Conflict,
|
|
1085
|
-
"Active attempt #{rival.attempt_id} owns overlapping scope #{rival.binding.scope} on #{assignment_id}; " \
|
|
1086
|
-
"our start #{attempt.attempt_id} was recorded stopped without executing"
|
|
1087
|
-
end
|
|
1088
|
-
|
|
1089
|
-
# Conservative classification of a running attempt after interruption.
|
|
1090
|
-
def classify_running(attempt)
|
|
1091
|
-
case reconciler.classify(attempt)
|
|
1092
|
-
when :live
|
|
1093
|
-
raise AttemptErrors::InvalidState,
|
|
1094
|
-
"Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
|
|
1095
|
-
else
|
|
1096
|
-
attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
|
|
1097
|
-
attempt = attempt.transition("uncertain")
|
|
1098
|
-
@store.save(attempt)
|
|
1099
|
-
attempt
|
|
1100
|
-
end
|
|
1101
|
-
end
|
|
1102
|
-
|
|
1103
|
-
# Resolve an uncertain attempt against a verified, boundary-attributed
|
|
1104
|
-
# receipt. Never resolves by assumption.
|
|
1105
|
-
def resolve_uncertain(attempt, receipt_path, identity)
|
|
1106
|
-
unless attempt.uncertain?
|
|
1107
|
-
raise AttemptErrors::InvalidState,
|
|
1108
|
-
"Attempt #{attempt.attempt_id} is #{attempt.state}; reconciliation targets uncertain attempts"
|
|
1109
|
-
end
|
|
1110
|
-
unless receipt_path
|
|
1111
|
-
raise AttemptErrors::InvalidState,
|
|
1112
|
-
"Reconciliation requires --receipt FILE; uncertain attempts are never resolved by assumption"
|
|
1113
|
-
end
|
|
1114
|
-
|
|
1115
|
-
data = read_receipt_file(receipt_path)
|
|
1116
|
-
live_head = candidate_head!
|
|
1117
|
-
attempt = invalidate_stale_candidate(attempt, live_head)
|
|
1118
|
-
|
|
1119
|
-
recorded = reconciler.recorded_runtime(attempt)
|
|
1120
|
-
unless data.dig("producer", "runtime") == recorded
|
|
1121
|
-
raise AttemptErrors::ReceiptRejected,
|
|
1122
|
-
"Receipt runtime #{data.dig("producer", "runtime").inspect} does not match the recorded " \
|
|
1123
|
-
"execution boundary #{recorded.inspect}"
|
|
1124
|
-
end
|
|
1125
|
-
|
|
1126
|
-
receipt = @verifier.verify!(
|
|
1127
|
-
data,
|
|
1128
|
-
attempt: attempt,
|
|
1129
|
-
identity: identity,
|
|
1130
|
-
live_head: live_head,
|
|
1131
|
-
repo_root: @repo_root
|
|
1132
|
-
)
|
|
1133
|
-
if @verifier.external_effect?(receipt.operation)
|
|
1134
|
-
unless attempt.managed?
|
|
1135
|
-
raise AttemptErrors::InvalidState,
|
|
1136
|
-
"Taskless attempts cannot record external effects without managed evidence"
|
|
1137
|
-
end
|
|
1138
|
-
|
|
1139
|
-
require_review_evidence(attempt, live_head)
|
|
1140
|
-
end
|
|
1141
|
-
|
|
1142
|
-
reconciliation = Models::EvidenceEvent.build(
|
|
1143
|
-
type: "reconciliation",
|
|
1144
|
-
attempt_id: attempt.attempt_id,
|
|
1145
|
-
payload: {"resolution" => receipt.verdict, "receipt_digest" => receipt.digest},
|
|
1146
|
-
previous_digest: last_event_digest(attempt)
|
|
1147
|
-
)
|
|
1148
|
-
accept(attempt, receipt, live_head, [reconciliation])
|
|
1149
|
-
end
|
|
1150
|
-
|
|
1151
|
-
def transition_event(attempt, to, reason)
|
|
1152
|
-
Models::EvidenceEvent.build(
|
|
1153
|
-
type: "transition",
|
|
1154
|
-
attempt_id: attempt.attempt_id,
|
|
1155
|
-
payload: {"from" => attempt.state, "to" => to, "reason" => reason},
|
|
1156
|
-
previous_digest: last_event_digest(attempt)
|
|
1157
|
-
)
|
|
1158
|
-
end
|
|
1159
|
-
|
|
1160
|
-
def reconciler
|
|
1161
|
-
@reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
|
|
1162
|
-
end
|
|
1163
|
-
|
|
1164
|
-
def reserve(assignment, scope, project, identity, base_head, attempt_id)
|
|
1165
|
-
binding = Models::AttemptBinding.new(
|
|
1166
|
-
attempt_id: attempt_id,
|
|
1167
|
-
assignment_id: assignment.id,
|
|
1168
|
-
scope: scope,
|
|
1169
|
-
project_id: project,
|
|
1170
|
-
task_id: assignment.task_id,
|
|
1171
|
-
actor: identity.actor,
|
|
1172
|
-
role: identity.role,
|
|
1173
|
-
runtime: identity.runtime,
|
|
1174
|
-
base_head: base_head,
|
|
1175
|
-
evidence_git_ref: assignment.managed? ? journal_for.ref : nil,
|
|
1176
|
-
created_at: Time.now.utc
|
|
1177
|
-
)
|
|
1178
|
-
Models::Attempt.new(binding: binding, state: "reserved").transition("running")
|
|
1179
|
-
end
|
|
1180
|
-
|
|
1181
|
-
def start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
|
|
1182
|
-
intent = Models::EvidenceEvent.build(
|
|
1183
|
-
type: "intent",
|
|
1184
|
-
attempt_id: attempt_id,
|
|
1185
|
-
payload: {
|
|
1186
|
-
"assignment_id" => assignment_id,
|
|
1187
|
-
"scope" => scope,
|
|
1188
|
-
"project_id" => project,
|
|
1189
|
-
"task_id" => assignment.task_id,
|
|
1190
|
-
"base_head" => base_head
|
|
1191
|
-
}
|
|
1192
|
-
)
|
|
1193
|
-
process_start = Models::EvidenceEvent.build(
|
|
1194
|
-
type: "process_start",
|
|
1195
|
-
attempt_id: attempt_id,
|
|
1196
|
-
payload: {
|
|
1197
|
-
"actor" => identity.actor,
|
|
1198
|
-
"role" => identity.role,
|
|
1199
|
-
"runtime" => identity.runtime,
|
|
1200
|
-
"pid" => identity.process_pid,
|
|
1201
|
-
"process_identity" => Ace::Runtime::Molecules::ProcessIdentity.new.capture(identity.process_pid),
|
|
1202
|
-
"runtime_binding" => identity.runtime_binding
|
|
1203
|
-
},
|
|
1204
|
-
previous_digest: intent["digest"]
|
|
1205
|
-
)
|
|
1206
|
-
[intent, process_start]
|
|
1207
|
-
end
|
|
1208
|
-
|
|
1209
|
-
# A changed candidate SHA invalidates prior candidate evidence and
|
|
1210
|
-
# authorizations; the change itself is journaled before anything else.
|
|
1211
|
-
def invalidate_stale_candidate(attempt, live_head)
|
|
1212
|
-
return attempt if attempt.candidate_head.nil? || attempt.candidate_head == live_head
|
|
1213
|
-
|
|
1214
|
-
event = Models::EvidenceEvent.build(
|
|
1215
|
-
type: "candidate_invalidated",
|
|
1216
|
-
attempt_id: attempt.attempt_id,
|
|
1217
|
-
payload: {"previous_candidate_head" => attempt.candidate_head, "current_head" => live_head}
|
|
1218
|
-
)
|
|
1219
|
-
attempt = append_events(attempt, [event])
|
|
1220
|
-
attempt = attempt.with(candidate_head: nil, accepted_receipts: [])
|
|
1221
|
-
@store.save(attempt)
|
|
1222
|
-
attempt
|
|
1223
|
-
end
|
|
1224
|
-
|
|
1225
|
-
def accept(attempt, receipt, live_head, pre_events = [])
|
|
1226
|
-
# Reject illegal terminal outcomes before accepting any receipt.
|
|
1227
|
-
if %w[succeeded failed].include?(receipt.verdict)
|
|
1228
|
-
Atoms::AttemptStateMachine.transition!(attempt.state, receipt.verdict)
|
|
1229
|
-
end
|
|
1230
|
-
previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
|
|
1231
|
-
event = Models::EvidenceEvent.build(
|
|
1232
|
-
type: "receipt_accepted",
|
|
1233
|
-
attempt_id: attempt.attempt_id,
|
|
1234
|
-
payload: {"receipt" => receipt.to_h},
|
|
1235
|
-
previous_digest: previous
|
|
1236
|
-
)
|
|
1237
|
-
attempt = append_events(attempt, pre_events + [event])
|
|
1238
|
-
|
|
1239
|
-
receipts = attempt.accepted_receipts + [receipt.to_h]
|
|
1240
|
-
effects = attempt.effects
|
|
1241
|
-
if @verifier.external_effect?(receipt.operation)
|
|
1242
|
-
effects += [{"operation" => receipt.operation, "receipt_digest" => receipt.digest}]
|
|
1243
|
-
end
|
|
1244
|
-
|
|
1245
|
-
attempt = attempt.with(
|
|
1246
|
-
candidate_head: attempt.candidate_head || receipt.head,
|
|
1247
|
-
accepted_receipts: receipts,
|
|
1248
|
-
effects: effects,
|
|
1249
|
-
state: attempt.state,
|
|
1250
|
-
updated_at: Time.now.utc
|
|
1251
|
-
)
|
|
1252
|
-
attempt = attempt.transition(receipt.verdict) if %w[succeeded failed].include?(receipt.verdict)
|
|
1253
|
-
|
|
1254
|
-
@store.save(attempt)
|
|
1255
|
-
@store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
|
|
1256
|
-
|
|
1257
|
-
attempt
|
|
1258
|
-
end
|
|
1259
|
-
|
|
1260
|
-
def require_review_evidence(attempt, live_head)
|
|
1261
|
-
receipts = if attempt.managed?
|
|
1262
|
-
journal_for.read_events(attempt.binding.assignment_id)
|
|
1263
|
-
.select { |event| event["type"] == "receipt_accepted" }
|
|
1264
|
-
.map { |event| event.dig("payload", "receipt") }
|
|
1265
|
-
.compact
|
|
1266
|
-
else
|
|
1267
|
-
attempt.accepted_receipts
|
|
1268
|
-
end
|
|
1269
|
-
approved = receipts.any? do |candidate|
|
|
1270
|
-
candidate["operation"] == Molecules::ReceiptVerifier::REVIEW_OPERATION &&
|
|
1271
|
-
candidate["verdict"] == "succeeded" &&
|
|
1272
|
-
candidate["head"] == live_head &&
|
|
1273
|
-
candidate.dig("review", "verdict") == Molecules::ReceiptVerifier::APPROVED_VERDICT
|
|
1274
|
-
end
|
|
1275
|
-
return if approved
|
|
1276
|
-
|
|
1277
|
-
raise AttemptErrors::ReceiptRejected,
|
|
1278
|
-
"External effect requires an executed independent review receipt for candidate #{live_head}"
|
|
1279
|
-
end
|
|
1280
|
-
|
|
1281
|
-
def read_receipt_file(path)
|
|
1282
|
-
raise AttemptErrors::ReceiptRejected, "Receipt file not found: #{path}" unless File.exist?(path.to_s)
|
|
1283
|
-
|
|
1284
|
-
data = JSON.parse(File.read(path))
|
|
1285
|
-
raise AttemptErrors::ReceiptRejected, "Receipt must be a JSON object" unless data.is_a?(Hash)
|
|
1286
|
-
|
|
1287
|
-
data
|
|
1288
|
-
rescue JSON::ParserError => e
|
|
1289
|
-
raise AttemptErrors::ReceiptRejected, "Receipt file is not valid JSON: #{e.message}"
|
|
1290
|
-
end
|
|
1291
|
-
|
|
1292
|
-
def append_events(attempt, events)
|
|
1293
|
-
if attempt.managed?
|
|
1294
|
-
commit = journal_for.append(
|
|
1295
|
-
assignment_id: attempt.binding.assignment_id,
|
|
1296
|
-
attempt_id: attempt.attempt_id,
|
|
1297
|
-
events: events
|
|
1298
|
-
)
|
|
1299
|
-
attempt.with(journal_commit: commit)
|
|
1300
|
-
else
|
|
1301
|
-
attempt.with(events: attempt.events + events)
|
|
1302
|
-
end
|
|
1303
|
-
end
|
|
1304
|
-
|
|
1305
|
-
# Digest chain continuation: managed attempts chain through the
|
|
1306
|
-
# authoritative journal; taskless attempts chain through the local
|
|
1307
|
-
# event trail.
|
|
1308
|
-
def last_event_digest(attempt)
|
|
1309
|
-
if attempt.managed?
|
|
1310
|
-
journal_for.read_events(attempt.binding.assignment_id).last&.dig("digest")
|
|
1311
|
-
else
|
|
1312
|
-
attempt.events.last&.dig("digest")
|
|
1313
|
-
end
|
|
1314
|
-
end
|
|
1315
|
-
|
|
1316
|
-
def journal_for
|
|
1317
|
-
@journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
|
|
1318
|
-
@journal
|
|
1319
|
-
end
|
|
1320
|
-
|
|
1321
|
-
# Prune/writer exclusion shared with overseer prune (durable, outside
|
|
1322
|
-
# every deletion target).
|
|
1323
|
-
def lifecycle_exclusion
|
|
1324
|
-
@lifecycle_exclusion ||= Molecules::LifecycleExclusion.new(repo_root: @repo_root)
|
|
1325
|
-
end
|
|
1326
|
-
|
|
1327
|
-
def candidate_head!
|
|
1328
|
-
out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
|
|
1329
|
-
head = out.to_s.strip
|
|
1330
|
-
return head if status.success? && !head.empty?
|
|
1331
|
-
|
|
1332
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot resolve candidate HEAD in #{@repo_root}"
|
|
1333
|
-
end
|
|
1334
|
-
end
|
|
1335
|
-
end
|
|
1336
|
-
end
|
|
1337
|
-
end
|