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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +18 -0
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +32 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  12. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  13. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  14. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  15. data/lib/ace/assign/cli/commands/status.rb +24 -1
  16. data/lib/ace/assign/cli.rb +30 -2
  17. data/lib/ace/assign/models/assignment.rb +21 -4
  18. data/lib/ace/assign/models/attempt.rb +178 -0
  19. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  20. data/lib/ace/assign/models/evidence_event.rb +82 -0
  21. data/lib/ace/assign/models/execution_receipt.rb +135 -0
  22. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  23. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  24. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  25. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  26. data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
  27. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  28. data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
  29. data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
  30. data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
  31. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  32. data/lib/ace/assign/version.rb +1 -1
  33. data/lib/ace/assign.rb +56 -0
  34. 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 exact-head evidence receipts and canonical decision digests for assignment delivery.
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
- def calculate(pr_number: nil, auto_merge: false)
17
- root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
18
-
19
- if pr_number.nil?
20
- pr_output, s = Open3.capture2("ace-git", "pr", "--format", "json", chdir: root)
21
- if s.success?
22
- begin
23
- require "json"
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
- decision_digest = Digest::SHA256.hexdigest(canonical_payload)
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: 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 evaluate_review_receipt(root, current_head)
80
- session_dir = File.join(root, ".ace-local", "review", "sessions")
81
- return "missing" unless Dir.exist?(session_dir)
82
-
83
- reviews = Dir.glob(File.join(session_dir, "*", "metadata.yml"))
84
- return "missing" if reviews.empty?
85
-
86
- latest = reviews.max_by { |f| File.mtime(f) }
87
- session_dir = File.dirname(latest)
88
-
89
- # Review must actually be executed, not just prepared
90
- return "missing" unless File.exist?(File.join(session_dir, "report.md")) || File.exist?(File.join(session_dir, "report.json"))
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
- "current"
94
+ def store
95
+ @store ||= Molecules::AssignmentManager.new(cache_base: @cache_base).attempt_store
101
96
  end
102
97
 
103
- def evaluate_release_receipt(root, current_head)
104
- session_dir = File.join(root, ".ace-local", "release", "sessions")
105
- return "missing" unless Dir.exist?(session_dir)
98
+ def journal
99
+ @journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
100
+ end
106
101
 
107
- releases = Dir.glob(File.join(session_dir, "*", "metadata.yml"))
108
- return "missing" if releases.empty?
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
- latest = releases.max_by { |f| File.mtime(f) }
111
- begin
112
- require "yaml"
113
- metadata = YAML.safe_load_file(latest, permitted_classes: [Time, Date]) || {}
114
- return "stale" unless metadata["head"] == current_head
115
- rescue StandardError
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
- "current"
124
+ store.list(assignment.id).flat_map(&:accepted_receipts)
125
+ .uniq { |receipt| receipt["digest"] }
120
126
  end
121
127
 
122
- def evaluate_merge_decision(auto_merge:, review_receipt:, release_receipt:, feedback_state:)
123
- return "report-only" unless auto_merge
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
- if review_receipt == "current" && release_receipt == "current" && feedback_state == "terminal"
126
- "authorized"
127
- else
128
- "approval-required"
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