ace-assign 0.57.0 → 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 +18 -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
|
@@ -7,61 +7,53 @@ require "ace/support/fs"
|
|
|
7
7
|
module Ace
|
|
8
8
|
module Assign
|
|
9
9
|
module Molecules
|
|
10
|
-
# Computes
|
|
10
|
+
# Computes delivery evidence for an assignment from accepted,
|
|
11
|
+
# current-head execution evidence.
|
|
12
|
+
#
|
|
13
|
+
# Decisions are derived exclusively from accepted receipts recorded in
|
|
14
|
+
# the evidence journal (managed attempts) or the durable attempt store
|
|
15
|
+
# (taskless attempts). Report files on disk, exit codes, and prose never
|
|
16
|
+
# establish review or merge authorization, and no feedback state is
|
|
17
|
+
# hardcoded: an attempt that may have completed without a durable
|
|
18
|
+
# receipt yields `uncertain`, never success.
|
|
11
19
|
class EvidenceCalculator
|
|
12
|
-
def self.calculate(pr_number: nil, auto_merge: false)
|
|
13
|
-
new.calculate(pr_number: pr_number, auto_merge: auto_merge)
|
|
20
|
+
def self.calculate(pr_number: nil, auto_merge: false, assignment_id: nil)
|
|
21
|
+
new.calculate(pr_number: pr_number, auto_merge: auto_merge, assignment_id: assignment_id)
|
|
14
22
|
end
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
pr_data = JSON.parse(pr_output)
|
|
25
|
-
pr_number = pr_data["number"]
|
|
26
|
-
rescue StandardError
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
head, _s = Open3.capture2("git", "rev-parse", "HEAD", chdir: root)
|
|
32
|
-
head = head.to_s.strip
|
|
33
|
-
|
|
34
|
-
tree, _s = Open3.capture2("git", "rev-parse", "HEAD^{tree}", chdir: root)
|
|
35
|
-
tree = tree.to_s.strip
|
|
36
|
-
|
|
37
|
-
diff_output, _s = Open3.capture2("git", "diff", "HEAD", chdir: root)
|
|
38
|
-
changed_scope_digest = Digest::SHA256.hexdigest(diff_output.to_s)
|
|
39
|
-
|
|
40
|
-
review_receipt = evaluate_review_receipt(root, head)
|
|
41
|
-
release_receipt = evaluate_release_receipt(root, head)
|
|
42
|
-
feedback_state = "terminal"
|
|
43
|
-
|
|
44
|
-
merge_decision = evaluate_merge_decision(
|
|
45
|
-
auto_merge: auto_merge,
|
|
46
|
-
review_receipt: review_receipt,
|
|
47
|
-
release_receipt: release_receipt,
|
|
48
|
-
feedback_state: feedback_state
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
canonical_payload = [
|
|
52
|
-
pr_number || "none",
|
|
53
|
-
head,
|
|
54
|
-
tree,
|
|
55
|
-
changed_scope_digest,
|
|
56
|
-
review_receipt,
|
|
57
|
-
release_receipt,
|
|
58
|
-
feedback_state,
|
|
59
|
-
merge_decision
|
|
60
|
-
].join(":")
|
|
24
|
+
# @param cache_base [String, nil] Assignment cache base (default config)
|
|
25
|
+
# @param repo_root [String, nil] Candidate repository root (default project root)
|
|
26
|
+
# @param journal [EvidenceJournal, nil] Evidence journal (default built lazily)
|
|
27
|
+
def initialize(cache_base: nil, repo_root: nil, journal: nil)
|
|
28
|
+
@cache_base = cache_base
|
|
29
|
+
@repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
30
|
+
@journal = journal
|
|
31
|
+
end
|
|
61
32
|
|
|
62
|
-
|
|
33
|
+
def calculate(pr_number: nil, auto_merge: false, assignment_id: nil)
|
|
34
|
+
head, tree, changed_scope_digest = git_facts
|
|
35
|
+
assignment = find_assignment(assignment_id)
|
|
36
|
+
attempts = assignment ? attempts_for(assignment) : []
|
|
37
|
+
receipts = assignment ? accepted_receipts(assignment) : []
|
|
38
|
+
|
|
39
|
+
review_receipt = receipt_currency(receipts, "review", head)
|
|
40
|
+
release_receipt = receipt_currency(receipts, "release", head)
|
|
41
|
+
unresolved_effects = collect_unresolved_effects(attempts)
|
|
42
|
+
feedback_state = derive_feedback_state(attempts, receipts, head, unresolved_effects)
|
|
43
|
+
|
|
44
|
+
# Authorization requires managed evidence, settled work (no active
|
|
45
|
+
# attempts), no unresolved effects, and a current independent
|
|
46
|
+
# review verdict. Taskless assignments never authorize merges.
|
|
47
|
+
merge_decision = if auto_merge && assignment&.managed? &&
|
|
48
|
+
review_receipt == "current" && unresolved_effects.empty? && attempts.none?(&:active?)
|
|
49
|
+
"authorized"
|
|
50
|
+
else
|
|
51
|
+
"approval-required"
|
|
52
|
+
end
|
|
63
53
|
|
|
64
|
-
|
|
54
|
+
latest = attempts.first
|
|
55
|
+
active = attempts.find(&:active?)
|
|
56
|
+
evidence = {
|
|
65
57
|
pr: pr_number || "none",
|
|
66
58
|
head: head,
|
|
67
59
|
tree: tree,
|
|
@@ -70,63 +62,124 @@ module Ace
|
|
|
70
62
|
release_receipt: release_receipt,
|
|
71
63
|
feedback_state: feedback_state,
|
|
72
64
|
merge_decision: merge_decision,
|
|
73
|
-
decision_digest:
|
|
65
|
+
decision_digest: nil,
|
|
66
|
+
attempt: latest ? attempt_summary(latest, active: active) : nil,
|
|
67
|
+
base_head: latest&.binding&.base_head,
|
|
68
|
+
candidate_head: latest&.candidate_head,
|
|
69
|
+
evidence_git_ref: evidence_git_ref(assignment),
|
|
70
|
+
journal_commit: latest&.journal_commit,
|
|
71
|
+
unresolved_effects: unresolved_effects
|
|
74
72
|
}
|
|
73
|
+
evidence[:decision_digest] = Digest::SHA256.hexdigest(
|
|
74
|
+
Atoms::EvidenceDigest.canonical_json(evidence)
|
|
75
|
+
)
|
|
76
|
+
evidence
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
private
|
|
78
80
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
begin
|
|
93
|
-
require "yaml"
|
|
94
|
-
metadata = YAML.safe_load_file(latest, permitted_classes: [Time, Date]) || {}
|
|
95
|
-
return "stale" unless metadata["head"] == current_head
|
|
96
|
-
rescue StandardError
|
|
97
|
-
return "missing"
|
|
98
|
-
end
|
|
81
|
+
def git_facts
|
|
82
|
+
head, _s = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
|
|
83
|
+
tree, _s = Open3.capture2("git", "rev-parse", "HEAD^{tree}", chdir: @repo_root, stdin_data: "")
|
|
84
|
+
diff_output, _s = Open3.capture2("git", "diff", "HEAD", chdir: @repo_root, stdin_data: "")
|
|
85
|
+
|
|
86
|
+
[head.to_s.strip, tree.to_s.strip, Digest::SHA256.hexdigest(diff_output.to_s)]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find_assignment(assignment_id)
|
|
90
|
+
manager = Molecules::AssignmentManager.new(cache_base: @cache_base)
|
|
91
|
+
assignment_id ? manager.load(assignment_id) : manager.find_active
|
|
92
|
+
end
|
|
99
93
|
|
|
100
|
-
|
|
94
|
+
def store
|
|
95
|
+
@store ||= Molecules::AssignmentManager.new(cache_base: @cache_base).attempt_store
|
|
101
96
|
end
|
|
102
97
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
98
|
+
def journal
|
|
99
|
+
@journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
|
|
100
|
+
end
|
|
106
101
|
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
# Attempt state for evidence decisions. Managed assignments treat the
|
|
103
|
+
# journal as authoritative: ALL journal-derived attempts (including
|
|
104
|
+
# terminal states) replace local records with the same ID, so a crash
|
|
105
|
+
# between journaling and the local save cannot leave stale open state.
|
|
106
|
+
def attempts_for(assignment)
|
|
107
|
+
attempts = store.list(assignment.id)
|
|
108
|
+
return attempts unless assignment.managed?
|
|
109
|
+
|
|
110
|
+
journal_by_id = {}
|
|
111
|
+
journal.derived_attempts(assignment.id).each { |attempt| journal_by_id[attempt.attempt_id] = attempt }
|
|
112
|
+
merged = attempts.map { |attempt| journal_by_id.delete(attempt.attempt_id) || attempt }
|
|
113
|
+
merged + journal_by_id.values
|
|
114
|
+
end
|
|
109
115
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return "missing"
|
|
116
|
+
# Accepted receipts for evidence currency. Managed assignments use
|
|
117
|
+
# journal receipts only — the journal is the authority; local records
|
|
118
|
+
# are disposable projections.
|
|
119
|
+
def accepted_receipts(assignment)
|
|
120
|
+
if assignment.managed?
|
|
121
|
+
return journal.accepted_receipts(assignment.id).uniq { |receipt| receipt["digest"] }
|
|
117
122
|
end
|
|
118
123
|
|
|
119
|
-
|
|
124
|
+
store.list(assignment.id).flat_map(&:accepted_receipts)
|
|
125
|
+
.uniq { |receipt| receipt["digest"] }
|
|
120
126
|
end
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
# Currency against the current head. Review approval requires an
|
|
129
|
+
# executed independent reviewer verdict; receipts were verified at
|
|
130
|
+
# acceptance and the independence guard is re-checked here defensively
|
|
131
|
+
# (a review receipt without a reviewer verdict is not review evidence
|
|
132
|
+
# at all).
|
|
133
|
+
def receipt_currency(receipts, operation, head)
|
|
134
|
+
succeeded = receipts.select do |receipt|
|
|
135
|
+
next false unless receipt["operation"] == operation && receipt["verdict"] == "succeeded"
|
|
136
|
+
next false if operation == "review" && receipt.dig("review", "reviewer", "actor").nil?
|
|
137
|
+
|
|
138
|
+
true
|
|
139
|
+
end
|
|
124
140
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
current = succeeded.any? { |receipt| receipt["head"] == head }
|
|
142
|
+
|
|
143
|
+
return "missing" if succeeded.empty?
|
|
144
|
+
return "current" if current
|
|
145
|
+
|
|
146
|
+
"stale"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def collect_unresolved_effects(attempts)
|
|
150
|
+
attempts.flat_map { |attempt| attempt.unresolved_effects.map { |effect| effect["operation"] } }
|
|
151
|
+
.concat(attempts.select(&:uncertain?).map { |attempt| "#{attempt.attempt_id}:uncertain" })
|
|
152
|
+
.uniq
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Feedback is derived: uncertain evidence wins, active attempts keep
|
|
156
|
+
# the loop open, accepted current-head evidence closes it.
|
|
157
|
+
def derive_feedback_state(attempts, receipts, head, unresolved_effects)
|
|
158
|
+
return "unknown" if attempts.empty? && receipts.empty?
|
|
159
|
+
|
|
160
|
+
return "uncertain" if unresolved_effects.any?
|
|
161
|
+
|
|
162
|
+
return "terminal" if attempts.none?(&:active?) && receipts.any? do |receipt|
|
|
163
|
+
receipt["verdict"] == "succeeded" && receipt["head"] == head
|
|
129
164
|
end
|
|
165
|
+
|
|
166
|
+
"open"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def attempt_summary(attempt, active: nil)
|
|
170
|
+
{
|
|
171
|
+
"attempt_id" => attempt.attempt_id,
|
|
172
|
+
"state" => attempt.state,
|
|
173
|
+
"scope" => attempt.binding.scope,
|
|
174
|
+
"recovery_mode" => attempt.recovery_mode,
|
|
175
|
+
"active" => active ? active.attempt_id == attempt.attempt_id : false
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def evidence_git_ref(assignment)
|
|
180
|
+
return nil unless assignment&.managed?
|
|
181
|
+
|
|
182
|
+
journal.ref
|
|
130
183
|
end
|
|
131
184
|
end
|
|
132
185
|
end
|