ace-assign 0.69.0 → 0.71.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/presets/deliver-release.yml +11 -8
- data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/CHANGELOG.md +27 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
- data/lib/ace/assign/authority/ordinary_client.rb +70 -0
- data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
- data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
- data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +206 -0
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +9 -4
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/router.rb +11 -6
- data/lib/ace/assign/authority/server.rb +11 -180
- data/lib/ace/assign/authority/socket_server.rb +255 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
- data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +39 -10
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/herdr/molecules/bounded_process"
|
|
4
|
+
require_relative "evidence_journal"
|
|
5
|
+
require_relative "canonical_read_commands"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
# Read the installed owner's immutable Git objects without opening its
|
|
11
|
+
# private writer lock/cache, copying the repository, or contacting it.
|
|
12
|
+
# Source selection is supplied only by verified installation policy.
|
|
13
|
+
class InstalledJournalRead
|
|
14
|
+
include CanonicalReadCommands
|
|
15
|
+
ENVIRONMENT = {"PATH" => "/usr/bin:/bin", "LC_ALL" => "C",
|
|
16
|
+
"GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_SYSTEM" => "/dev/null",
|
|
17
|
+
"GIT_CONFIG_GLOBAL" => "/dev/null", "GIT_TERMINAL_PROMPT" => "0",
|
|
18
|
+
"GIT_ALLOW_PROTOCOL" => "", "GIT_NO_REPLACE_OBJECTS" => "1",
|
|
19
|
+
"GIT_NO_LAZY_FETCH" => "1", "GIT_OPTIONAL_LOCKS" => "0"}.freeze
|
|
20
|
+
ENTRY_LIMIT = 100_000
|
|
21
|
+
|
|
22
|
+
def initialize(repo_root:, ref:, owner_uid:)
|
|
23
|
+
unless repo_root.is_a?(String) && repo_root.start_with?("/") &&
|
|
24
|
+
!repo_root.include?("\0") && File.expand_path(repo_root) == repo_root &&
|
|
25
|
+
ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z}) &&
|
|
26
|
+
owner_uid.is_a?(Integer) && owner_uid.positive?
|
|
27
|
+
raise ArgumentError, "installed journal selection is malformed"
|
|
28
|
+
end
|
|
29
|
+
@repo_root, @ref, @owner_uid = repo_root.dup.freeze, ref.dup.freeze, owner_uid
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with(deadline:)
|
|
34
|
+
refuse!("read_busy") unless @mutex.try_lock
|
|
35
|
+
begin
|
|
36
|
+
@deadline = deadline
|
|
37
|
+
remaining
|
|
38
|
+
verify_source!
|
|
39
|
+
@view = File.join(@repo_root, ".git")
|
|
40
|
+
@commit = run!(["rev-parse", "--verify", @ref], limit: 128).stdout.strip
|
|
41
|
+
refuse!("ref_unavailable") unless @commit.match?(OID)
|
|
42
|
+
result = yield self
|
|
43
|
+
verify_source!
|
|
44
|
+
result
|
|
45
|
+
ensure
|
|
46
|
+
@view = @commit = @deadline = nil
|
|
47
|
+
@mutex.unlock
|
|
48
|
+
end
|
|
49
|
+
rescue SystemCallError, IOError, Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError
|
|
50
|
+
refuse!("source_unavailable")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def refuse!(reason)
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "installed journal read unavailable: #{reason}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def remaining
|
|
60
|
+
refuse!("deadline") unless @deadline.is_a?(Numeric) && @deadline.finite?
|
|
61
|
+
value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
|
+
refuse!("deadline") unless value.positive?
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_source!
|
|
67
|
+
# Caller-writable ancestors can replace even a correctly owned leaf.
|
|
68
|
+
current = File.join(@repo_root, ".git")
|
|
69
|
+
loop do
|
|
70
|
+
stat = File.lstat(current)
|
|
71
|
+
unless stat.directory? && !stat.symlink? && [0, @owner_uid].include?(stat.uid) &&
|
|
72
|
+
(stat.mode & 0o022).zero?
|
|
73
|
+
refuse!("storage_owner")
|
|
74
|
+
end
|
|
75
|
+
if [@repo_root, File.join(@repo_root, ".git")].include?(current) && stat.uid != @owner_uid
|
|
76
|
+
refuse!("repository_owner")
|
|
77
|
+
end
|
|
78
|
+
break if current == "/"
|
|
79
|
+
current = File.dirname(current)
|
|
80
|
+
end
|
|
81
|
+
%w[commondir shallow reftable info/grafts objects/info/alternates objects/info/http-alternates].each do |name|
|
|
82
|
+
path = File.join(@repo_root, ".git", name)
|
|
83
|
+
refuse!("redirected_storage") if File.exist?(path) || File.symlink?(path)
|
|
84
|
+
end
|
|
85
|
+
@entries = 0
|
|
86
|
+
%w[objects refs config HEAD packed-refs].each do |name|
|
|
87
|
+
path = File.join(@repo_root, ".git", name)
|
|
88
|
+
next if %w[HEAD packed-refs].include?(name) && !File.exist?(path) && !File.symlink?(path)
|
|
89
|
+
verify_tree!(path)
|
|
90
|
+
end
|
|
91
|
+
verify_config!
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_tree!(path)
|
|
95
|
+
remaining
|
|
96
|
+
@entries += 1
|
|
97
|
+
refuse!("storage_inventory_limit") if @entries > ENTRY_LIMIT
|
|
98
|
+
stat = File.lstat(path)
|
|
99
|
+
unless !stat.symlink? && (stat.file? || stat.directory?) &&
|
|
100
|
+
[0, @owner_uid].include?(stat.uid) && (stat.mode & 0o022).zero?
|
|
101
|
+
refuse!("storage_owner")
|
|
102
|
+
end
|
|
103
|
+
Dir.each_child(path) { |name| verify_tree!(File.join(path, name)) } if stat.directory?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def verify_config!
|
|
107
|
+
bytes = File.open(File.join(@repo_root, ".git/config"), File::RDONLY | File::NOFOLLOW) { |io| io.read(65_537) }
|
|
108
|
+
refuse!("config_limit") if bytes.bytesize > 65_536
|
|
109
|
+
result = Herdr::Molecules::BoundedProcess.call(
|
|
110
|
+
["/usr/bin/git", "config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"],
|
|
111
|
+
environment: ENVIRONMENT, chdir: "/", stdin_data: bytes,
|
|
112
|
+
timeout_s: remaining, output_limit: 65_536)
|
|
113
|
+
refuse!("config_unavailable") unless result.status.success? && !result.oversized
|
|
114
|
+
result.stdout.split("\0").each do |entry|
|
|
115
|
+
key, value = entry.split("\n", 2)
|
|
116
|
+
if key.start_with?("include.", "includeif.", "extensions.") || key == "core.worktree" ||
|
|
117
|
+
key == "core.repositoryformatversion" && value != "0"
|
|
118
|
+
refuse!("redirected_configuration")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def read_result(argv, **options)
|
|
124
|
+
run!(argv, **options, allow_failure: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run!(argv, stdin_data: "", limit:, allow_failure: false)
|
|
128
|
+
# All ref-based reads in one observation use the same immutable tip.
|
|
129
|
+
selected = argv.map { |arg| arg == @ref && @commit ? @commit : arg }
|
|
130
|
+
result = Herdr::Molecules::BoundedProcess.call([
|
|
131
|
+
"/usr/bin/git", "--git-dir=#{@view}", "--no-pager",
|
|
132
|
+
"-c", "safe.directory=#{@repo_root}", "-c", "core.hooksPath=/dev/null",
|
|
133
|
+
"-c", "core.fsmonitor=false", "-c", "protocol.allow=never", *selected],
|
|
134
|
+
environment: ENVIRONMENT, chdir: @repo_root, stdin_data: stdin_data,
|
|
135
|
+
timeout_s: remaining, output_limit: [limit, BYTE_LIMIT].min, stderr_limit: 65_536)
|
|
136
|
+
refuse!("resource_limit") if result.oversized
|
|
137
|
+
refuse!("git_unavailable") unless allow_failure || result.status.success?
|
|
138
|
+
result
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "ace/herdr/molecules/bounded_process"
|
|
6
6
|
require_relative "journal_prompt_mutation"
|
|
7
|
+
require_relative "../atoms/candidate_limits"
|
|
7
8
|
require_relative "journal_input_inhibition"
|
|
8
9
|
|
|
9
10
|
module Ace
|
|
@@ -34,7 +35,7 @@ module Ace
|
|
|
34
35
|
# returns events [{type:, payload:}], immutable blobs, and public data.
|
|
35
36
|
# Only the authority calls this internal journal API; wire requests
|
|
36
37
|
# cannot select blob paths, event types, or accepted response data.
|
|
37
|
-
def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil)
|
|
38
|
+
def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil, deadline: nil, replay_authorizer: nil)
|
|
38
39
|
unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
|
|
39
40
|
(generation_mode == :recorded_completion && %w[complete_service complete_no_effect].include?(operation) && expected_generation.nil?) ||
|
|
40
41
|
(generation_mode == :prompt_completion && operation == "prompt_attempt" && expected_generation.nil? && prompt_completion.is_a?(Hash)) ||
|
|
@@ -71,7 +72,8 @@ module Ace
|
|
|
71
72
|
unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
|
|
72
73
|
raise ArgumentError, "invalid mutation parameter digest"
|
|
73
74
|
end
|
|
74
|
-
|
|
75
|
+
raise ArgumentError, "replay authorizer must be source owned" if replay_authorizer && !replay_authorizer.respond_to?(:call)
|
|
76
|
+
with_lock(deadline: deadline) do
|
|
75
77
|
self.class::CAS_ATTEMPTS.times do
|
|
76
78
|
old = ref_value
|
|
77
79
|
ensure_checkout!
|
|
@@ -96,6 +98,7 @@ module Ace
|
|
|
96
98
|
raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
|
|
97
99
|
end
|
|
98
100
|
result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
|
|
101
|
+
replay_authorizer&.call
|
|
99
102
|
return with_replay ? {data: result, replayed: true} : result
|
|
100
103
|
end
|
|
101
104
|
current = read_events(assignment_id, commit: old).select { |event| event["attempt_id"] == attempt_id }
|
|
@@ -190,7 +193,7 @@ module Ace
|
|
|
190
193
|
def bounded_blob(path, commit:, max_bytes:)
|
|
191
194
|
validate_blob_path!(path)
|
|
192
195
|
unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) &&
|
|
193
|
-
max_bytes.is_a?(Integer) && max_bytes.between?(1,
|
|
196
|
+
max_bytes.is_a?(Integer) && max_bytes.between?(1, Atoms::CandidateLimits::BYTES)
|
|
194
197
|
raise AttemptErrors::EvidenceUnavailable, "Canonical prepared blob selector differs"
|
|
195
198
|
end
|
|
196
199
|
return @read_boundary.blob(path, commit: commit, max_bytes: max_bytes) if @read_boundary
|
|
@@ -215,7 +218,7 @@ module Ace
|
|
|
215
218
|
end
|
|
216
219
|
|
|
217
220
|
def validate_blob_path!(path)
|
|
218
|
-
unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
|
|
221
|
+
unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|execution/merge-evidence|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
|
|
219
222
|
!%w[. ..].include?(File.basename(path))
|
|
220
223
|
raise ArgumentError, "invalid canonical blob reference"
|
|
221
224
|
end
|
|
@@ -214,14 +214,25 @@ module Ace
|
|
|
214
214
|
# given order, released in reverse — callers must use one consistent
|
|
215
215
|
# global order, task before assignment, to avoid deadlock). Fails
|
|
216
216
|
# closed when any identity was pruned.
|
|
217
|
-
def with_shared_multi(keys, reset_removed: false)
|
|
217
|
+
def with_shared_multi(keys, reset_removed: false, deadline: nil)
|
|
218
218
|
ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
|
|
219
219
|
raise ArgumentError, "exclusion keys are required" if ordered.empty?
|
|
220
220
|
|
|
221
221
|
locks = []
|
|
222
222
|
begin
|
|
223
|
+
maintenance_deadline!(deadline) if deadline
|
|
223
224
|
ordered.each { |key| locks << open_lock(key) }
|
|
224
|
-
locks.each
|
|
225
|
+
locks.each do |lock|
|
|
226
|
+
if deadline
|
|
227
|
+
until lock.flock(File::LOCK_SH | File::LOCK_NB)
|
|
228
|
+
maintenance_deadline!(deadline)
|
|
229
|
+
sleep 0.01
|
|
230
|
+
end
|
|
231
|
+
maintenance_deadline!(deadline)
|
|
232
|
+
else
|
|
233
|
+
lock.flock(File::LOCK_SH)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
225
236
|
ordered.each do |key|
|
|
226
237
|
if removed?(key)
|
|
227
238
|
unless reset_removed
|
|
@@ -11,7 +11,7 @@ module Ace
|
|
|
11
11
|
PROPOSAL_REFERENCE = /\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/
|
|
12
12
|
PROPOSAL_BINDING = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid].freeze
|
|
13
13
|
|
|
14
|
-
PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision]).freeze
|
|
14
|
+
PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision silence_delegated]).freeze
|
|
15
15
|
|
|
16
16
|
def proposal_history(id)
|
|
17
17
|
assignment_ids.flat_map do |assignment|
|
|
@@ -110,8 +110,7 @@ module Ace
|
|
|
110
110
|
valid &&= updated["revision_operation_id"].nil? && updated["expected_source_revision"].nil?
|
|
111
111
|
end
|
|
112
112
|
unresolved = previous && proposal_claims(previous["revision_id"]).any? do |claim|
|
|
113
|
-
claim["state"] != "succeeded" &&
|
|
114
|
-
!(claim["state"] == "failed-settled" && settlement_evidence_intact?(claim))
|
|
113
|
+
claim["state"] != "succeeded" && !proposal_no_effect?(claim)
|
|
115
114
|
end
|
|
116
115
|
raise AttemptErrors::ReceiptRejected, "Claimed proposal cannot be revised; reconcile the existing effect" if unresolved
|
|
117
116
|
raise AttemptErrors::ReceiptRejected, "Proposal revision must begin with exact immutable delivery scope" unless valid
|
|
@@ -119,10 +118,39 @@ module Ace
|
|
|
119
118
|
private :validate_proposal_revision!
|
|
120
119
|
|
|
121
120
|
def proposal_claims(reference)
|
|
122
|
-
service_request_records.select do |request|
|
|
121
|
+
claims = (service_request_records + fixed_merge_request_records).select do |request|
|
|
123
122
|
request["authorization"] == reference &&
|
|
124
123
|
!(request["state"] == "rejected" && request["consumed"] == false)
|
|
125
124
|
end
|
|
125
|
+
return claims if claims.empty?
|
|
126
|
+
events = read_events(claims.first.fetch("assignment_id"))
|
|
127
|
+
claims.sort_by do |claim|
|
|
128
|
+
kind = claim.key?("queue_entry") ? "merge_admission" : "service_claim"
|
|
129
|
+
events.index do |event|
|
|
130
|
+
event["type"] == kind && event["attempt_id"] == claim.fetch("attempt_id") &&
|
|
131
|
+
event.dig("payload", "request_id") == claim.fetch("request_id")
|
|
132
|
+
end || raise(AttemptErrors::EvidenceUnavailable, "Proposal claim has no canonical admission event")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Request IDs are scoped by record family. A same-text ID in the other
|
|
137
|
+
# family is another effect, never an idempotent replay.
|
|
138
|
+
def proposal_claim_conflict(reference, request_id:, fixed:)
|
|
139
|
+
[[service_request_records, false], [fixed_merge_request_records, true]].each do |records, family|
|
|
140
|
+
conflict = records.find do |claim|
|
|
141
|
+
claim["authorization"] == reference &&
|
|
142
|
+
!(family == fixed && claim["request_id"] == request_id) &&
|
|
143
|
+
!(claim["state"] == "rejected" && claim["consumed"] == false) &&
|
|
144
|
+
!proposal_no_effect?(claim)
|
|
145
|
+
end
|
|
146
|
+
return conflict if conflict
|
|
147
|
+
end
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def proposal_no_effect?(claim)
|
|
152
|
+
(claim["state"] == "failed-settled" && settlement_evidence_intact?(claim)) ||
|
|
153
|
+
(claim["state"] == "failed" && claim.key?("queue_entry") && fixed_no_effect_intact?(claim))
|
|
126
154
|
end
|
|
127
155
|
|
|
128
156
|
def proposal_authorize!(reference, binding)
|
|
@@ -24,7 +24,7 @@ module Ace
|
|
|
24
24
|
# @param identity_resolver [ExecutionIdentityResolver] Trust boundary
|
|
25
25
|
# @param artifact_reader [#call, nil] Source-owned canonical byte
|
|
26
26
|
# reader for protected composition; nil selects explicit local files
|
|
27
|
-
def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil)
|
|
27
|
+
def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil, review_independence_verifier: nil)
|
|
28
28
|
@identity_resolver = identity_resolver || ExecutionIdentityResolver.new
|
|
29
29
|
unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
|
|
30
30
|
raise ArgumentError, "artifact reader must be a source-owned callable"
|
|
@@ -34,6 +34,10 @@ module Ace
|
|
|
34
34
|
raise ArgumentError, "campaign verifier must be a source-owned callable"
|
|
35
35
|
end
|
|
36
36
|
@campaign_verifier = campaign_verifier
|
|
37
|
+
unless review_independence_verifier.nil? || review_independence_verifier.respond_to?(:call)
|
|
38
|
+
raise ArgumentError, "review independence verifier must be a source-owned callable"
|
|
39
|
+
end
|
|
40
|
+
@review_independence_verifier = review_independence_verifier
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# Validate a submitted receipt against an attempt.
|
|
@@ -222,7 +226,9 @@ module Ace
|
|
|
222
226
|
reject_unless(reviewer.is_a?(Hash) && !reviewer["actor"].to_s.strip.empty?, "review must name an executed reviewer")
|
|
223
227
|
|
|
224
228
|
producer_actor = data.dig("producer", "actor").to_s.strip
|
|
225
|
-
if
|
|
229
|
+
if @review_independence_verifier
|
|
230
|
+
reject("canonical reviewer independence is unverifiable") unless @review_independence_verifier.call(data) == true
|
|
231
|
+
elsif reviewer["actor"].to_s.strip == producer_actor
|
|
226
232
|
reject("reviewer and producer must differ (self-approval rejected)")
|
|
227
233
|
end
|
|
228
234
|
|
|
@@ -12,9 +12,10 @@ module Ace
|
|
|
12
12
|
# operation delegates to the resolved Ace::Runtime adapter; assign
|
|
13
13
|
# never calls a runtime-native API directly (k86.3 interface rule).
|
|
14
14
|
class RuntimeControlSurfaceRunner
|
|
15
|
-
def initialize(runtime: nil, env: ENV)
|
|
15
|
+
def initialize(runtime: nil, env: ENV, adapter: nil)
|
|
16
16
|
@runtime_name = runtime
|
|
17
17
|
@env = env
|
|
18
|
+
@runtime = adapter
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
attr_reader :runtime_name, :env
|
|
@@ -37,7 +37,10 @@ module Ace
|
|
|
37
37
|
# @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
|
|
38
38
|
# @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
|
|
39
39
|
def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
|
|
40
|
-
lifecycle_exclusion: nil)
|
|
40
|
+
lifecycle_exclusion: nil, recorded_binding_observer: nil, ordinary_merge_evidence: 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
|
|
41
44
|
@manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
42
45
|
@store = @manager.attempt_store
|
|
43
46
|
@repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
@@ -45,6 +48,8 @@ module Ace
|
|
|
45
48
|
@identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
|
|
46
49
|
@verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
|
|
47
50
|
@lifecycle_exclusion = lifecycle_exclusion
|
|
51
|
+
@recorded_binding_observer = recorded_binding_observer
|
|
52
|
+
@ordinary_merge_evidence = ordinary_merge_evidence
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
attr_reader :store
|
|
@@ -59,16 +64,17 @@ module Ace
|
|
|
59
64
|
{events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
|
|
60
65
|
end
|
|
61
66
|
|
|
62
|
-
# The
|
|
67
|
+
# The source-selected result owner authenticates imported bytes, candidate,
|
|
63
68
|
# closure and independent settlements at the same CAS prefix.
|
|
64
|
-
def self.finished_transition_plan(events:, receipt:)
|
|
69
|
+
def self.finished_transition_plan(events:, receipt:, authority:)
|
|
70
|
+
raise ArgumentError, "unknown result authority" unless %i[ordinary protected].include?(authority)
|
|
65
71
|
state = Molecules::CanonicalAttemptState.derive(events)
|
|
66
72
|
unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
|
|
67
73
|
raise AttemptErrors::Conflict, "Result finish requires a running attempt"
|
|
68
74
|
end
|
|
69
75
|
Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
|
|
70
76
|
{events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
|
|
71
|
-
{type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "
|
|
77
|
+
{type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "#{authority}_result_accepted"}}], blobs: {}}
|
|
72
78
|
end
|
|
73
79
|
|
|
74
80
|
def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
|
|
@@ -113,21 +119,13 @@ module Ace
|
|
|
113
119
|
raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
|
|
114
120
|
end
|
|
115
121
|
binding = reconciler.recorded_binding(attempt)
|
|
116
|
-
|
|
117
|
-
unless binding && observation["liveness"] == "live" &&
|
|
118
|
-
binding["process_identity"] == observation["identity"] &&
|
|
119
|
-
%w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
|
|
122
|
+
unless binding && %w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
|
|
120
123
|
raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
|
|
121
124
|
end
|
|
122
|
-
unless caller_pid.nil?
|
|
123
|
-
|
|
124
|
-
raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
|
|
125
|
-
end
|
|
126
|
-
caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
|
|
127
|
-
unless caller == binding
|
|
128
|
-
raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
|
|
129
|
-
end
|
|
125
|
+
unless caller_pid.nil? || (caller_pid.is_a?(Integer) && caller_pid.positive?)
|
|
126
|
+
raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
|
|
130
127
|
end
|
|
128
|
+
observe_runtime_binding!(attempt, binding, caller_pid)
|
|
131
129
|
latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
|
|
132
130
|
unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
|
|
133
131
|
raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
|
|
@@ -137,6 +135,28 @@ module Ace
|
|
|
137
135
|
raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
|
|
138
136
|
end
|
|
139
137
|
|
|
138
|
+
# An installed consumer selects its own bounded native reader. It must
|
|
139
|
+
# never fall back to the service process's ambient runtime on failure.
|
|
140
|
+
def observe_runtime_binding!(attempt, binding, caller_pid)
|
|
141
|
+
if @recorded_binding_observer
|
|
142
|
+
actual = @recorded_binding_observer.call(binding: binding, caller_pid: caller_pid)
|
|
143
|
+
unless actual == binding
|
|
144
|
+
raise AttemptErrors::UnauthorizedIdentity, "Installed native owner observation differs"
|
|
145
|
+
end
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
observation = reconciler.observation(attempt)
|
|
149
|
+
unless observation["liveness"] == "live" && binding["process_identity"] == observation["identity"]
|
|
150
|
+
raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
|
|
151
|
+
end
|
|
152
|
+
return if caller_pid.nil?
|
|
153
|
+
caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
|
|
154
|
+
unless caller == binding
|
|
155
|
+
raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
private :observe_runtime_binding!
|
|
159
|
+
|
|
140
160
|
# Claim an external service effect before dispatch. Only a managed,
|
|
141
161
|
# active attempt for the exact project and candidate may own it. The
|
|
142
162
|
# guard re-runs inside the journal lock per CAS attempt, so a
|
|
@@ -174,7 +194,10 @@ module Ace
|
|
|
174
194
|
end
|
|
175
195
|
|
|
176
196
|
def service_attempt(binding)
|
|
177
|
-
|
|
197
|
+
# An effect can never acquire authority from a requester cache, even
|
|
198
|
+
# when the assignment definition is missing from this consumer.
|
|
199
|
+
attempt = journal_for.derived_attempts(binding.fetch("assignment_id"))
|
|
200
|
+
.find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
|
|
178
201
|
raise AttemptErrors::NotFound, "Attempt not found" unless attempt
|
|
179
202
|
unless attempt.managed? && attempt.active? &&
|
|
180
203
|
attempt.binding.assignment_id == binding["assignment_id"] &&
|
|
@@ -306,28 +329,65 @@ module Ace
|
|
|
306
329
|
# never need message-string discrimination: replay/binding conflicts
|
|
307
330
|
# stay Conflict, a consumed authorization is AuthorizationConflict.
|
|
308
331
|
guard = lambda do
|
|
309
|
-
|
|
332
|
+
verify_fixed_merge_evidence!(record)
|
|
333
|
+
conflict = journal_for.fixed_authorization_conflict(record)
|
|
310
334
|
if conflict
|
|
311
335
|
raise AttemptErrors::AuthorizationConflict,
|
|
312
336
|
"Authorization reference is not currently valid"
|
|
313
337
|
end
|
|
314
338
|
nil
|
|
315
339
|
end
|
|
316
|
-
|
|
340
|
+
with_fixed_merge_scope(record) do
|
|
341
|
+
journal_for.admit_fixed_merge_request(record, guard: guard)
|
|
342
|
+
end
|
|
317
343
|
end
|
|
318
344
|
|
|
319
345
|
# Begin the fixed effect (pending -> uncertain). The single CAS
|
|
320
346
|
# transition is the at-most-one-merge proof: a restarted receiver
|
|
321
|
-
#
|
|
347
|
+
# refuses an already-started claim and must not dispatch again.
|
|
322
348
|
def begin_fixed_merge_effect(request_id)
|
|
323
|
-
|
|
349
|
+
record = journal_for.fixed_merge_request(request_id)
|
|
350
|
+
raise AttemptErrors::NotFound, "fixed merge record is unavailable" unless record
|
|
351
|
+
with_fixed_merge_scope(record) do
|
|
352
|
+
journal_for.begin_fixed_merge_request(request_id, guard: -> { verify_fixed_merge_evidence!(record) })
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Hold the existing assignment exclusion through the external effect.
|
|
357
|
+
# Review cancellation/head changes/finish cannot race this interval.
|
|
358
|
+
def with_fixed_merge_evidence(record)
|
|
359
|
+
with_fixed_merge_scope(record, exclusive: true) do
|
|
360
|
+
verify_fixed_merge_evidence!(record)
|
|
361
|
+
yield
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def verify_fixed_merge_evidence!(record)
|
|
366
|
+
unless @ordinary_merge_evidence
|
|
367
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary merge evidence owner is not installed"
|
|
368
|
+
end
|
|
369
|
+
@ordinary_merge_evidence.verify_merge_evidence_at!(record: record, commit: journal_for.ref_value,
|
|
370
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
371
|
+
nil
|
|
372
|
+
rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::UnauthorizedIdentity, AttemptErrors::Conflict, AttemptErrors::NotFound
|
|
373
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary review or executed test evidence is unavailable"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def with_fixed_merge_scope(record, exclusive: false, &block)
|
|
377
|
+
unless @ordinary_merge_evidence
|
|
378
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary merge evidence owner is not installed"
|
|
379
|
+
end
|
|
380
|
+
@ordinary_merge_evidence.with_merge_scope(record: record, exclusive: exclusive,
|
|
381
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30, &block)
|
|
324
382
|
end
|
|
383
|
+
private :with_fixed_merge_scope
|
|
325
384
|
|
|
326
385
|
# Settle the fixed effect with the exact terminal receipt. The
|
|
327
386
|
# journal validates the closed receipt schema and its binding to the
|
|
328
387
|
# admitted record before the transition lands.
|
|
329
|
-
def settle_fixed_merge_effect(request_id, terminal_status:, receipt:)
|
|
330
|
-
|
|
388
|
+
def settle_fixed_merge_effect(request_id, terminal_status:, receipt:, attestation: nil)
|
|
389
|
+
journal_for.settle_fixed_merge_request(request_id, terminal_status: terminal_status,
|
|
390
|
+
receipt: receipt, attestation: attestation)
|
|
331
391
|
end
|
|
332
392
|
|
|
333
393
|
# Read-only canonical record lookup by request ID: lets the producer
|
|
@@ -352,7 +412,7 @@ module Ace
|
|
|
352
412
|
# without revealing whether another project holds the reference.
|
|
353
413
|
def fixed_merge_status(reference:, request_id:, project_id:, assignment_id:, attempt_id:,
|
|
354
414
|
queue_entry:, input_digest:)
|
|
355
|
-
record =
|
|
415
|
+
record = journal_for.fixed_merge_request(request_id)
|
|
356
416
|
return nil unless record
|
|
357
417
|
matches = reference == record["reference"] && input_digest == record["input_digest"] &&
|
|
358
418
|
[project_id, assignment_id, attempt_id, queue_entry].zip(
|
|
@@ -603,7 +663,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
603
663
|
|
|
604
664
|
active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
|
|
605
665
|
active_scopes.concat(journal_actives.map(&:binding))
|
|
606
|
-
blocker = active_scopes.find { |binding|
|
|
666
|
+
blocker = active_scopes.find { |binding| Atoms::AssignmentScope.overlap?(binding.scope, scope) }
|
|
607
667
|
if blocker
|
|
608
668
|
raise AttemptErrors::Conflict,
|
|
609
669
|
"Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
|
|
@@ -842,9 +902,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
842
902
|
unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
|
|
843
903
|
raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
|
|
844
904
|
end
|
|
845
|
-
if !historical_head.nil? && (
|
|
905
|
+
if !historical_head.nil? && (!historical_head.is_a?(String) ||
|
|
846
906
|
!historical_head.match?(/\A[0-9a-f]{40,64}\z/))
|
|
847
|
-
raise AttemptErrors::ReceiptRejected, "Historical evidence requires
|
|
907
|
+
raise AttemptErrors::ReceiptRejected, "Historical evidence requires an exact recorded head"
|
|
848
908
|
end
|
|
849
909
|
unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
|
|
850
910
|
receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
|
|
@@ -994,14 +1054,6 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
994
1054
|
raise Error, "Failed to generate a unique attempt ID for #{assignment_id} after 100 attempts"
|
|
995
1055
|
end
|
|
996
1056
|
|
|
997
|
-
# Ancestors and descendants overlap: an active attempt for 010 owns
|
|
998
|
-
# its 010.01 subtree, and vice versa.
|
|
999
|
-
def scopes_overlap?(left, right)
|
|
1000
|
-
return true if Atoms::AssignmentScope.equal?(left, right)
|
|
1001
|
-
|
|
1002
|
-
left.start_with?("#{right}.") || right.start_with?("#{left}.")
|
|
1003
|
-
end
|
|
1004
|
-
|
|
1005
1057
|
# Active attempts derived from the authoritative journal, used when
|
|
1006
1058
|
# the local record may be missing (lost or wiped cache). Derivation
|
|
1007
1059
|
# lives in the journal (owner of attempt-state interpretation).
|
|
@@ -1059,7 +1111,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
1059
1111
|
# merges both). The loser records a stopped transition and refuses.
|
|
1060
1112
|
def yield_lost_ownership_race(attempt, assignment_id, scope)
|
|
1061
1113
|
rival = journal_for.active_attempts(assignment_id).find do |other|
|
|
1062
|
-
other.attempt_id != attempt.attempt_id &&
|
|
1114
|
+
other.attempt_id != attempt.attempt_id && Atoms::AssignmentScope.overlap?(other.binding.scope, scope)
|
|
1063
1115
|
end
|
|
1064
1116
|
return attempt if rival.nil?
|
|
1065
1117
|
|