ace-assign 0.57.1 → 0.58.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/config.yml +13 -0
- data/CHANGELOG.md +13 -0
- data/docs/exit-codes.md +10 -0
- data/docs/usage.md +32 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
- data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
- data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
- data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
- data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
- data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
- data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
- data/lib/ace/assign/cli/commands/status.rb +24 -1
- data/lib/ace/assign/cli.rb +30 -2
- data/lib/ace/assign/models/assignment.rb +21 -4
- data/lib/ace/assign/models/attempt.rb +178 -0
- data/lib/ace/assign/models/attempt_binding.rb +116 -0
- data/lib/ace/assign/models/evidence_event.rb +82 -0
- data/lib/ace/assign/models/execution_receipt.rb +135 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
- data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
- data/lib/ace/assign/molecules/attempt_store.rb +201 -0
- data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
- data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
- data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +56 -0
- metadata +20 -2
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Organisms
|
|
9
|
+
# Authoritative coordinator for assignment attempts.
|
|
10
|
+
#
|
|
11
|
+
# The coordinator is the only authority allowed to reserve and start
|
|
12
|
+
# attempts, accept authoritative receipts, transition attempt state,
|
|
13
|
+
# invalidate stale candidate evidence, and write accepted journal
|
|
14
|
+
# events. It composes the attempt store (local ownership), the evidence
|
|
15
|
+
# journal (managed Git-ref evidence), the identity resolver (trusted
|
|
16
|
+
# boundary), and the receipt verifier (acceptance rules).
|
|
17
|
+
#
|
|
18
|
+
# Guarantee: only one active attempt owns an assignment + subtree;
|
|
19
|
+
# `base_head` is captured exactly once at start; `candidate_head` is
|
|
20
|
+
# pinned only when candidate-bound evidence is accepted and any change
|
|
21
|
+
# to the candidate revision invalidates prior candidate authorization;
|
|
22
|
+
# `journal_commit` is tracked separately from both.
|
|
23
|
+
class AttemptCoordinator
|
|
24
|
+
# @param cache_base [String, nil] Assignment cache base
|
|
25
|
+
# @param repo_root [String, nil] Candidate repository root (default: project root)
|
|
26
|
+
# @param journal [Molecules::EvidenceJournal, nil] Evidence journal (default built per repo)
|
|
27
|
+
# @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
|
|
28
|
+
# @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
|
|
29
|
+
def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil)
|
|
30
|
+
@manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
31
|
+
@store = @manager.attempt_store
|
|
32
|
+
@repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
33
|
+
@journal = journal
|
|
34
|
+
@identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
|
|
35
|
+
@verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :store
|
|
39
|
+
|
|
40
|
+
# Start a scoped attempt for an assignment.
|
|
41
|
+
#
|
|
42
|
+
# A repeated identical start returns the existing active attempt; a
|
|
43
|
+
# conflicting or scope-overlapping binding raises
|
|
44
|
+
# {AttemptErrors::Conflict} without ever launching a second writer.
|
|
45
|
+
# Active attempts are resolved from the local store AND, for managed
|
|
46
|
+
# assignments, from the authoritative journal, so a lost local record
|
|
47
|
+
# cannot admit a competing writer.
|
|
48
|
+
#
|
|
49
|
+
# @param assignment_id [String] Assignment ID
|
|
50
|
+
# @param step [String] Step/subtree scope
|
|
51
|
+
# @param project_id [String] Project the attempt executes in
|
|
52
|
+
# @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
|
|
53
|
+
# @return [Models::Attempt] Active attempt (reused, recovered, or newly reserved)
|
|
54
|
+
def start(assignment_id:, step:, project_id:, identity: nil)
|
|
55
|
+
assignment = @manager.load(assignment_id)
|
|
56
|
+
raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment
|
|
57
|
+
|
|
58
|
+
scope = Atoms::AssignmentScope.canonicalize(step)
|
|
59
|
+
project = project_id.to_s.strip
|
|
60
|
+
raise Error, "attempt start requires --project ID" if project.empty?
|
|
61
|
+
|
|
62
|
+
identity ||= @identity_resolver.resolve
|
|
63
|
+
base_head = candidate_head!
|
|
64
|
+
|
|
65
|
+
@store.with_lock(assignment_id) do
|
|
66
|
+
journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
|
|
67
|
+
|
|
68
|
+
existing = @store.active(assignment_id, scope) ||
|
|
69
|
+
journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
|
|
70
|
+
if existing
|
|
71
|
+
if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
|
|
72
|
+
existing.binding.actor == identity.actor && existing.binding.role == identity.role
|
|
73
|
+
return existing
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
raise AttemptErrors::Conflict,
|
|
77
|
+
"Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
|
|
78
|
+
"(project #{existing.binding.project_id}); refusing to launch a second writer"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
|
|
82
|
+
active_scopes.concat(journal_actives.map(&:binding))
|
|
83
|
+
blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
|
|
84
|
+
if blocker
|
|
85
|
+
raise AttemptErrors::Conflict,
|
|
86
|
+
"Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
|
|
87
|
+
"refusing overlapping writers"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
|
|
91
|
+
events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
|
|
92
|
+
attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
|
|
93
|
+
if attempt.managed?
|
|
94
|
+
commit = journal_for.append(
|
|
95
|
+
assignment_id: assignment_id,
|
|
96
|
+
attempt_id: attempt.attempt_id,
|
|
97
|
+
events: events
|
|
98
|
+
)
|
|
99
|
+
attempt = attempt.with(journal_commit: commit)
|
|
100
|
+
attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
|
|
101
|
+
else
|
|
102
|
+
attempt = attempt.with(events: events)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@store.save(attempt)
|
|
106
|
+
@store.claim(assignment_id, scope, attempt)
|
|
107
|
+
attempt
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Accept a receipt for an attempt and transition it.
|
|
112
|
+
#
|
|
113
|
+
# @param attempt_id [String] Attempt ID
|
|
114
|
+
# @param receipt_path [String] Path to the receipt JSON file
|
|
115
|
+
# @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
|
|
116
|
+
# @return [Models::Attempt] Updated attempt
|
|
117
|
+
def finish(attempt_id:, receipt_path:, identity: nil)
|
|
118
|
+
data = read_receipt_file(receipt_path)
|
|
119
|
+
assignment_id = data["assignment_id"].to_s
|
|
120
|
+
|
|
121
|
+
# Serialize load-validate-accept so concurrent finishes observe one
|
|
122
|
+
# consistent state and cannot persist contradictory terminal outcomes.
|
|
123
|
+
@store.with_lock(assignment_id) do
|
|
124
|
+
attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
|
|
125
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
126
|
+
|
|
127
|
+
ensure_journal_consistent!(attempt) if attempt.managed?
|
|
128
|
+
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
|
|
129
|
+
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
|
|
130
|
+
|
|
131
|
+
identity ||= @identity_resolver.resolve
|
|
132
|
+
live_head = candidate_head!
|
|
133
|
+
|
|
134
|
+
attempt = invalidate_stale_candidate(attempt, live_head)
|
|
135
|
+
|
|
136
|
+
receipt = @verifier.verify!(
|
|
137
|
+
data,
|
|
138
|
+
attempt: attempt,
|
|
139
|
+
identity: identity,
|
|
140
|
+
live_head: live_head,
|
|
141
|
+
repo_root: @repo_root
|
|
142
|
+
)
|
|
143
|
+
if @verifier.external_effect?(receipt.operation)
|
|
144
|
+
unless attempt.managed?
|
|
145
|
+
raise AttemptErrors::InvalidState,
|
|
146
|
+
"Taskless attempts cannot record external effects without managed evidence"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
require_review_evidence(attempt, live_head)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
accept(attempt, receipt, live_head)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Active-attempt projection for status surfaces.
|
|
157
|
+
#
|
|
158
|
+
# @param assignment_id [String] Assignment ID
|
|
159
|
+
# @return [Hash, nil] Projection fields or nil when no attempt exists
|
|
160
|
+
def status(assignment_id)
|
|
161
|
+
attempts = @store.list(assignment_id)
|
|
162
|
+
attempt = attempts.find(&:active?) || attempts.first
|
|
163
|
+
return nil unless attempt
|
|
164
|
+
|
|
165
|
+
attempt.projection
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Reconcile an interrupted attempt.
|
|
169
|
+
#
|
|
170
|
+
# Running attempts are classified conservatively (stopped before
|
|
171
|
+
# process start, uncertain when an effect cannot be proven either
|
|
172
|
+
# way, still running only for verifiably live processes). Uncertain
|
|
173
|
+
# attempts resolve only against a verified receipt attributed to the
|
|
174
|
+
# recorded execution boundary. Reconciliation never replays merge,
|
|
175
|
+
# publish, or deploy effects.
|
|
176
|
+
#
|
|
177
|
+
# @param attempt_id [String] Attempt ID
|
|
178
|
+
# @param receipt_path [String, nil] Receipt JSON for resolving uncertainty
|
|
179
|
+
# @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
|
|
180
|
+
# @return [Models::Attempt] Updated attempt
|
|
181
|
+
def reconcile(attempt_id:, receipt_path: nil, identity: nil)
|
|
182
|
+
probe = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
183
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless probe
|
|
184
|
+
|
|
185
|
+
@store.with_lock(probe.binding.assignment_id) do
|
|
186
|
+
attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
|
|
187
|
+
recover_managed_attempt(probe.binding.assignment_id, attempt_id)
|
|
188
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
189
|
+
|
|
190
|
+
derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
|
|
191
|
+
# The journal is authoritative for managed attempts: uncertainty
|
|
192
|
+
# journaled after the local save drives reconciliation.
|
|
193
|
+
attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
|
|
194
|
+
|
|
195
|
+
if attempt.terminal?
|
|
196
|
+
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
return classify_running(attempt) if attempt.state == "running"
|
|
200
|
+
|
|
201
|
+
identity ||= @identity_resolver.resolve
|
|
202
|
+
resolve_uncertain(attempt, receipt_path, identity)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# Attempt IDs must be unique per assignment AND globally: records,
|
|
209
|
+
# journal events, and reconciliation lookups key on them. Allocation
|
|
210
|
+
# runs under the assignment lock, avoids journal-known IDs, and
|
|
211
|
+
# claims the ID atomically in the cross-assignment registry.
|
|
212
|
+
def unique_attempt_id(assignment_id, taken_ids = [])
|
|
213
|
+
base = Ace::B36ts.now
|
|
214
|
+
suffix = 0
|
|
215
|
+
100.times do
|
|
216
|
+
candidate = suffix.zero? ? base : "#{base}#{suffix.to_s(36)}"
|
|
217
|
+
suffix += 1
|
|
218
|
+
next if taken_ids.include?(candidate)
|
|
219
|
+
next unless @store.load(assignment_id, candidate).nil?
|
|
220
|
+
return candidate if @store.reserve_id(candidate)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
raise Error, "Failed to generate a unique attempt ID for #{assignment_id} after 100 attempts"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Ancestors and descendants overlap: an active attempt for 010 owns
|
|
227
|
+
# its 010.01 subtree, and vice versa.
|
|
228
|
+
def scopes_overlap?(left, right)
|
|
229
|
+
return true if Atoms::AssignmentScope.equal?(left, right)
|
|
230
|
+
|
|
231
|
+
left.start_with?("#{right}.") || right.start_with?("#{left}.")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Active attempts derived from the authoritative journal, used when
|
|
235
|
+
# the local record may be missing (lost or wiped cache). Derivation
|
|
236
|
+
# lives in the journal (owner of attempt-state interpretation).
|
|
237
|
+
def active_journal_attempts(assignment)
|
|
238
|
+
journal_for.active_attempts(assignment.id)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Recover a managed attempt from the journal when the local record is
|
|
242
|
+
# missing. With an assignment ID only that journal is consulted;
|
|
243
|
+
# without one, assignment IDs are discovered from the evidence ref
|
|
244
|
+
# itself, so a fully lost cache still reconciles.
|
|
245
|
+
def recover_managed_attempt(assignment_id, attempt_id)
|
|
246
|
+
if assignment_id
|
|
247
|
+
assignment = @manager.load(assignment_id)
|
|
248
|
+
# A surviving taskless record never consults the journal; a lost
|
|
249
|
+
# record falls through to journal evidence (only managed attempts
|
|
250
|
+
# are journaled).
|
|
251
|
+
return nil if assignment && !assignment.managed?
|
|
252
|
+
|
|
253
|
+
return journal_for.derived_attempts(assignment_id)
|
|
254
|
+
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
journal_for.assignment_ids.each do |journal_assignment_id|
|
|
258
|
+
attempt = journal_for.derived_attempts(journal_assignment_id)
|
|
259
|
+
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
260
|
+
return attempt if attempt
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The journal is authoritative for managed attempts: a crash after a
|
|
267
|
+
# journaled terminal/uncertain event but before the local save must
|
|
268
|
+
# not admit contradictory transitions.
|
|
269
|
+
def ensure_journal_consistent!(attempt, allow_uncertain: false)
|
|
270
|
+
derived = journal_for.derived_attempts(attempt.binding.assignment_id)
|
|
271
|
+
.find { |candidate| candidate.attempt_id == attempt.attempt_id }
|
|
272
|
+
return if derived.nil?
|
|
273
|
+
|
|
274
|
+
journal_state = derived.state
|
|
275
|
+
if %w[succeeded failed stopped].include?(journal_state)
|
|
276
|
+
raise AttemptErrors::InvalidState,
|
|
277
|
+
"Journal shows attempt #{attempt.attempt_id} is #{journal_state}; accepted history is immutable"
|
|
278
|
+
end
|
|
279
|
+
return derived if allow_uncertain || journal_state != "uncertain" || attempt.uncertain?
|
|
280
|
+
|
|
281
|
+
raise AttemptErrors::InvalidState,
|
|
282
|
+
"Journal shows attempt #{attempt.attempt_id} is uncertain; reconcile before finishing"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# After appending our start events, revalidate ownership: a
|
|
286
|
+
# coordinator with a separate cache directory may have claimed the
|
|
287
|
+
# same scope between our read and our append (journal CAS replay
|
|
288
|
+
# merges both). The loser records a stopped transition and refuses.
|
|
289
|
+
def yield_lost_ownership_race(attempt, assignment_id, scope)
|
|
290
|
+
rival = journal_for.active_attempts(assignment_id).find do |other|
|
|
291
|
+
other.attempt_id != attempt.attempt_id && scopes_overlap?(other.binding.scope, scope)
|
|
292
|
+
end
|
|
293
|
+
return attempt if rival.nil?
|
|
294
|
+
|
|
295
|
+
stopped = append_events(attempt, [transition_event(attempt, "stopped", "lost ownership race to #{rival.attempt_id}")])
|
|
296
|
+
stopped = stopped.transition("stopped")
|
|
297
|
+
@store.save(stopped)
|
|
298
|
+
raise AttemptErrors::Conflict,
|
|
299
|
+
"Active attempt #{rival.attempt_id} owns overlapping scope #{rival.binding.scope} on #{assignment_id}; " \
|
|
300
|
+
"our start #{attempt.attempt_id} was recorded stopped without executing"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Conservative classification of a running attempt after interruption.
|
|
304
|
+
def classify_running(attempt)
|
|
305
|
+
case reconciler.classify(attempt)
|
|
306
|
+
when :live
|
|
307
|
+
raise AttemptErrors::InvalidState,
|
|
308
|
+
"Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
|
|
309
|
+
when :stopped
|
|
310
|
+
attempt = append_events(attempt, [transition_event(attempt, "stopped", "interrupted before process start")])
|
|
311
|
+
attempt = attempt.transition("stopped")
|
|
312
|
+
@store.save(attempt)
|
|
313
|
+
@store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id)
|
|
314
|
+
attempt
|
|
315
|
+
else
|
|
316
|
+
attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
|
|
317
|
+
attempt = attempt.transition("uncertain")
|
|
318
|
+
@store.save(attempt)
|
|
319
|
+
attempt
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Resolve an uncertain attempt against a verified, boundary-attributed
|
|
324
|
+
# receipt. Never resolves by assumption.
|
|
325
|
+
def resolve_uncertain(attempt, receipt_path, identity)
|
|
326
|
+
unless attempt.uncertain?
|
|
327
|
+
raise AttemptErrors::InvalidState,
|
|
328
|
+
"Attempt #{attempt.attempt_id} is #{attempt.state}; reconciliation targets uncertain attempts"
|
|
329
|
+
end
|
|
330
|
+
unless receipt_path
|
|
331
|
+
raise AttemptErrors::InvalidState,
|
|
332
|
+
"Reconciliation requires --receipt FILE; uncertain attempts are never resolved by assumption"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
data = read_receipt_file(receipt_path)
|
|
336
|
+
live_head = candidate_head!
|
|
337
|
+
attempt = invalidate_stale_candidate(attempt, live_head)
|
|
338
|
+
|
|
339
|
+
recorded = reconciler.recorded_runtime(attempt)
|
|
340
|
+
unless data.dig("producer", "runtime") == recorded
|
|
341
|
+
raise AttemptErrors::ReceiptRejected,
|
|
342
|
+
"Receipt runtime #{data.dig('producer', 'runtime').inspect} does not match the recorded " \
|
|
343
|
+
"execution boundary #{recorded.inspect}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
receipt = @verifier.verify!(
|
|
347
|
+
data,
|
|
348
|
+
attempt: attempt,
|
|
349
|
+
identity: identity,
|
|
350
|
+
live_head: live_head,
|
|
351
|
+
repo_root: @repo_root
|
|
352
|
+
)
|
|
353
|
+
if @verifier.external_effect?(receipt.operation)
|
|
354
|
+
unless attempt.managed?
|
|
355
|
+
raise AttemptErrors::InvalidState,
|
|
356
|
+
"Taskless attempts cannot record external effects without managed evidence"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
require_review_evidence(attempt, live_head)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
reconciliation = Models::EvidenceEvent.build(
|
|
363
|
+
type: "reconciliation",
|
|
364
|
+
attempt_id: attempt.attempt_id,
|
|
365
|
+
payload: {"resolution" => receipt.verdict, "receipt_digest" => receipt.digest},
|
|
366
|
+
previous_digest: last_event_digest(attempt)
|
|
367
|
+
)
|
|
368
|
+
accept(attempt, receipt, live_head, [reconciliation])
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def transition_event(attempt, to, reason)
|
|
372
|
+
Models::EvidenceEvent.build(
|
|
373
|
+
type: "transition",
|
|
374
|
+
attempt_id: attempt.attempt_id,
|
|
375
|
+
payload: {"from" => attempt.state, "to" => to, "reason" => reason},
|
|
376
|
+
previous_digest: last_event_digest(attempt)
|
|
377
|
+
)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def reconciler
|
|
381
|
+
@reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def reserve(assignment, scope, project, identity, base_head, attempt_id) binding = Models::AttemptBinding.new(
|
|
385
|
+
attempt_id: attempt_id,
|
|
386
|
+
assignment_id: assignment.id,
|
|
387
|
+
scope: scope,
|
|
388
|
+
project_id: project,
|
|
389
|
+
task_id: assignment.task_id,
|
|
390
|
+
actor: identity.actor,
|
|
391
|
+
role: identity.role,
|
|
392
|
+
runtime: identity.runtime,
|
|
393
|
+
base_head: base_head,
|
|
394
|
+
evidence_git_ref: assignment.managed? ? journal_for.ref : nil,
|
|
395
|
+
created_at: Time.now.utc
|
|
396
|
+
)
|
|
397
|
+
Models::Attempt.new(binding: binding, state: "reserved").transition("running")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
|
|
401
|
+
intent = Models::EvidenceEvent.build(
|
|
402
|
+
type: "intent",
|
|
403
|
+
attempt_id: attempt_id,
|
|
404
|
+
payload: {
|
|
405
|
+
"assignment_id" => assignment_id,
|
|
406
|
+
"scope" => scope,
|
|
407
|
+
"project_id" => project,
|
|
408
|
+
"task_id" => assignment.task_id,
|
|
409
|
+
"base_head" => base_head
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
process_start = Models::EvidenceEvent.build(
|
|
413
|
+
type: "process_start",
|
|
414
|
+
attempt_id: attempt_id,
|
|
415
|
+
payload: {
|
|
416
|
+
"actor" => identity.actor,
|
|
417
|
+
"role" => identity.role,
|
|
418
|
+
"runtime" => identity.runtime,
|
|
419
|
+
"pid" => Process.pid
|
|
420
|
+
},
|
|
421
|
+
previous_digest: intent["digest"]
|
|
422
|
+
)
|
|
423
|
+
[intent, process_start]
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# A changed candidate SHA invalidates prior candidate evidence and
|
|
427
|
+
# authorizations; the change itself is journaled before anything else.
|
|
428
|
+
def invalidate_stale_candidate(attempt, live_head)
|
|
429
|
+
return attempt if attempt.candidate_head.nil? || attempt.candidate_head == live_head
|
|
430
|
+
|
|
431
|
+
event = Models::EvidenceEvent.build(
|
|
432
|
+
type: "candidate_invalidated",
|
|
433
|
+
attempt_id: attempt.attempt_id,
|
|
434
|
+
payload: {"previous_candidate_head" => attempt.candidate_head, "current_head" => live_head}
|
|
435
|
+
)
|
|
436
|
+
attempt = append_events(attempt, [event])
|
|
437
|
+
attempt = attempt.with(candidate_head: nil, accepted_receipts: [])
|
|
438
|
+
@store.save(attempt)
|
|
439
|
+
attempt
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def accept(attempt, receipt, live_head, pre_events = [])
|
|
443
|
+
previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
|
|
444
|
+
event = Models::EvidenceEvent.build(
|
|
445
|
+
type: "receipt_accepted",
|
|
446
|
+
attempt_id: attempt.attempt_id,
|
|
447
|
+
payload: {"receipt" => receipt.to_h},
|
|
448
|
+
previous_digest: previous
|
|
449
|
+
)
|
|
450
|
+
attempt = append_events(attempt, pre_events + [event])
|
|
451
|
+
|
|
452
|
+
receipts = attempt.accepted_receipts + [receipt.to_h]
|
|
453
|
+
effects = attempt.effects
|
|
454
|
+
if @verifier.external_effect?(receipt.operation)
|
|
455
|
+
effects += [{"operation" => receipt.operation, "receipt_digest" => receipt.digest}]
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
attempt = attempt.with(
|
|
459
|
+
candidate_head: attempt.candidate_head || receipt.head,
|
|
460
|
+
accepted_receipts: receipts,
|
|
461
|
+
effects: effects,
|
|
462
|
+
state: attempt.state,
|
|
463
|
+
updated_at: Time.now.utc
|
|
464
|
+
)
|
|
465
|
+
attempt = attempt.transition(receipt.verdict) if %w[succeeded failed].include?(receipt.verdict)
|
|
466
|
+
|
|
467
|
+
@store.save(attempt)
|
|
468
|
+
@store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
|
|
469
|
+
|
|
470
|
+
attempt
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def require_review_evidence(attempt, live_head)
|
|
474
|
+
receipts = if attempt.managed?
|
|
475
|
+
journal_for.read_events(attempt.binding.assignment_id)
|
|
476
|
+
.select { |event| event["type"] == "receipt_accepted" }
|
|
477
|
+
.map { |event| event.dig("payload", "receipt") }
|
|
478
|
+
.compact
|
|
479
|
+
else
|
|
480
|
+
attempt.accepted_receipts
|
|
481
|
+
end
|
|
482
|
+
approved = receipts.any? do |candidate|
|
|
483
|
+
candidate["operation"] == Molecules::ReceiptVerifier::REVIEW_OPERATION &&
|
|
484
|
+
candidate["verdict"] == "succeeded" &&
|
|
485
|
+
candidate["head"] == live_head &&
|
|
486
|
+
candidate.dig("review", "verdict") == Molecules::ReceiptVerifier::APPROVED_VERDICT
|
|
487
|
+
end
|
|
488
|
+
return if approved
|
|
489
|
+
|
|
490
|
+
raise AttemptErrors::ReceiptRejected,
|
|
491
|
+
"External effect requires an executed independent review receipt for candidate #{live_head}"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def read_receipt_file(path)
|
|
495
|
+
raise AttemptErrors::ReceiptRejected, "Receipt file not found: #{path}" unless File.exist?(path.to_s)
|
|
496
|
+
|
|
497
|
+
data = JSON.parse(File.read(path))
|
|
498
|
+
raise AttemptErrors::ReceiptRejected, "Receipt must be a JSON object" unless data.is_a?(Hash)
|
|
499
|
+
|
|
500
|
+
data
|
|
501
|
+
rescue JSON::ParserError => e
|
|
502
|
+
raise AttemptErrors::ReceiptRejected, "Receipt file is not valid JSON: #{e.message}"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def append_events(attempt, events)
|
|
506
|
+
if attempt.managed?
|
|
507
|
+
commit = journal_for.append(
|
|
508
|
+
assignment_id: attempt.binding.assignment_id,
|
|
509
|
+
attempt_id: attempt.attempt_id,
|
|
510
|
+
events: events
|
|
511
|
+
)
|
|
512
|
+
attempt.with(journal_commit: commit)
|
|
513
|
+
else
|
|
514
|
+
attempt.with(events: attempt.events + events)
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Digest chain continuation: managed attempts chain through the
|
|
519
|
+
# authoritative journal; taskless attempts chain through the local
|
|
520
|
+
# event trail.
|
|
521
|
+
def last_event_digest(attempt)
|
|
522
|
+
if attempt.managed?
|
|
523
|
+
journal_for.read_events(attempt.binding.assignment_id).last&.dig("digest")
|
|
524
|
+
else
|
|
525
|
+
attempt.events.last&.dig("digest")
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def journal_for
|
|
530
|
+
@journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
|
|
531
|
+
@journal
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def candidate_head!
|
|
535
|
+
out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
|
|
536
|
+
head = out.to_s.strip
|
|
537
|
+
return head if status.success? && !head.empty?
|
|
538
|
+
|
|
539
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot resolve candidate HEAD in #{@repo_root}"
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
data/lib/ace/assign/version.rb
CHANGED
data/lib/ace/assign.rb
CHANGED
|
@@ -60,6 +60,62 @@ module Ace
|
|
|
60
60
|
class InvalidState < Error; end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Attempt/evidence-related errors (exit code 5)
|
|
64
|
+
module AttemptErrors
|
|
65
|
+
class InvalidTransition < Error
|
|
66
|
+
def initialize(message = "Illegal attempt state transition")
|
|
67
|
+
super(message, exit_code: 5)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class InvalidScope < Error
|
|
72
|
+
def initialize(message = "Invalid attempt scope")
|
|
73
|
+
super(message, exit_code: 5)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Duplicate start with a conflicting binding for an owned subtree
|
|
78
|
+
class Conflict < Error
|
|
79
|
+
def initialize(message = "Conflicting active attempt for scope")
|
|
80
|
+
super(message, exit_code: 5)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class NotFound < Error
|
|
85
|
+
def initialize(message = "Attempt not found")
|
|
86
|
+
super(message, exit_code: 5)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Untrusted or unknown execution identity; authority fails closed
|
|
91
|
+
class UnauthorizedIdentity < Error
|
|
92
|
+
def initialize(message = "Unknown or unauthorized execution identity")
|
|
93
|
+
super(message, exit_code: 5)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Receipt failed verification (binding, head, digest, artifacts, review)
|
|
98
|
+
class ReceiptRejected < Error
|
|
99
|
+
def initialize(message = "Receipt rejected")
|
|
100
|
+
super(message, exit_code: 5)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Attempt state forbids the requested effect (terminal, uncertain, blocked)
|
|
105
|
+
class InvalidState < Error
|
|
106
|
+
def initialize(message = "Attempt state forbids the requested operation")
|
|
107
|
+
super(message, exit_code: 5)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Evidence storage is missing or unwritable; external effects are blocked
|
|
112
|
+
class EvidenceUnavailable < Error
|
|
113
|
+
def initialize(message = "Evidence storage unavailable")
|
|
114
|
+
super(message, exit_code: 5)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
63
119
|
# Define module namespaces
|
|
64
120
|
module Commands; end
|
|
65
121
|
|