ace-overseer 0.17.0 → 0.18.2

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.
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "open3"
5
+
6
+ module Ace
7
+ module Overseer
8
+ module Molecules
9
+ # Strict parser for the optional `--preservation FILE` manifest.
10
+ #
11
+ # FILE is a claim to verify, never authorization or proof by itself.
12
+ # Schema: `version: 1` plus `candidates:`; each record declares the
13
+ # cross-repository destination that received a candidate's work:
14
+ #
15
+ # - worktree_path: the candidate worktree the record belongs to
16
+ # - source_repo / source_base / source_head: the candidate's common
17
+ # repository and the claimed work range
18
+ # - destination_repo / destination_base / destination_head /
19
+ # destination_branch: where the work was transferred
20
+ #
21
+ # Paths must be local absolute repositories and revisions must resolve
22
+ # to exact commits. Duplicate worktree entries, entries pointing at the
23
+ # source checkout itself, and unresolvable revisions fail before any
24
+ # apply action.
25
+ class PreservationManifest
26
+ REQUIRED_FIELDS = %w[
27
+ worktree_path
28
+ source_repo
29
+ source_base
30
+ source_head
31
+ destination_repo
32
+ destination_base
33
+ destination_head
34
+ destination_branch
35
+ ].freeze
36
+
37
+ Record = Struct.new(
38
+ :worktree_path, :source_repo, :source_base, :source_head,
39
+ :destination_repo, :destination_base, :destination_head, :destination_branch,
40
+ :resolved_source_base, :resolved_source_head,
41
+ :resolved_destination_base, :resolved_destination_head, :resolved_destination_branch_tip,
42
+ keyword_init: true
43
+ ) do
44
+ def destination_branch_name
45
+ destination_branch.sub(%r{\Arefs/heads/}, "")
46
+ end
47
+ end
48
+
49
+ class Invalid < Ace::Overseer::Error; end
50
+
51
+ # @param path [String] Manifest file path
52
+ # @param git_runner [Proc] `->(repo, *args) { [stdout, stderr, status] }`
53
+ # @return [PreservationManifest]
54
+ def self.load(path, git_runner: method(:default_git_runner))
55
+ raise Invalid, "Preservation manifest not found: #{path}" unless File.file?(path.to_s)
56
+
57
+ data = begin
58
+ YAML.safe_load_file(path, permitted_classes: [Time, Date])
59
+ rescue Psych::SyntaxError, Psych::Exception => e
60
+ raise Invalid, "Preservation manifest is not valid YAML: #{e.message}"
61
+ end
62
+ unless data.is_a?(Hash)
63
+ raise Invalid, "Preservation manifest must be a YAML mapping"
64
+ end
65
+
66
+ version = data["version"]
67
+ unless version == 1
68
+ raise Invalid, "Preservation manifest requires `version: 1` (got #{version.inspect})"
69
+ end
70
+
71
+ candidates = data["candidates"]
72
+ unless candidates.is_a?(Array)
73
+ raise Invalid, "Preservation manifest requires a `candidates` list"
74
+ end
75
+
76
+ new(parse_candidates(candidates, git_runner: git_runner))
77
+ end
78
+
79
+ def self.default_git_runner(repo, *args)
80
+ Open3.capture3("git", "-C", repo, *args)
81
+ end
82
+ private_class_method :default_git_runner
83
+
84
+ def self.parse_candidates(entries, git_runner:)
85
+ records = entries.map.with_index(1) do |entry, index|
86
+ parse_record(entry, index, git_runner: git_runner)
87
+ end
88
+
89
+ duplicate = records.group_by(&:worktree_path).select { |_path, group| group.length > 1 }
90
+ unless duplicate.empty?
91
+ raise Invalid, "Preservation manifest has duplicate entries for #{duplicate.keys.join(", ")}"
92
+ end
93
+
94
+ records
95
+ end
96
+ private_class_method :parse_candidates
97
+
98
+ def self.parse_record(entry, index, git_runner:)
99
+ unless entry.is_a?(Hash)
100
+ raise Invalid, "Preservation manifest candidate #{index} must be a mapping"
101
+ end
102
+
103
+ unknown = entry.keys.map(&:to_s) - REQUIRED_FIELDS
104
+ unless unknown.empty?
105
+ raise Invalid, "Preservation manifest candidate #{index} has unknown fields: #{unknown.join(", ")}"
106
+ end
107
+
108
+ missing = REQUIRED_FIELDS.select { |field| entry[field].nil? || entry[field].to_s.strip.empty? }
109
+ unless missing.empty?
110
+ raise Invalid, "Preservation manifest candidate #{index} is missing: #{missing.join(", ")}"
111
+ end
112
+
113
+ attrs = REQUIRED_FIELDS.to_h { |field| [field.to_sym, entry[field].to_s] }
114
+ require_absolute_repository(attrs[:worktree_path], "worktree_path", index)
115
+ require_absolute_repository(attrs[:source_repo], "source_repo", index)
116
+ require_absolute_repository(attrs[:destination_repo], "destination_repo", index)
117
+ require_real_repository(attrs[:source_repo], "source_repo", index)
118
+ require_real_repository(attrs[:destination_repo], "destination_repo", "destination")
119
+
120
+ if same_path?(attrs[:worktree_path], attrs[:destination_repo])
121
+ raise Invalid,
122
+ "Preservation manifest candidate #{index}: the source checkout #{attrs[:worktree_path]} is " \
123
+ "scheduled for deletion and cannot be its own surviving destination"
124
+ end
125
+
126
+ record = Record.new(**attrs)
127
+ record.resolved_source_base = resolve_revision(record.source_repo, record.source_base, "source_base", index, git_runner)
128
+ record.resolved_source_head = resolve_revision(record.source_repo, record.source_head, "source_head", index, git_runner)
129
+ record.resolved_destination_base = resolve_revision(record.destination_repo, record.destination_base, "destination_base", index, git_runner)
130
+ record.resolved_destination_head = resolve_revision(record.destination_repo, record.destination_head, "destination_head", index, git_runner)
131
+ record.resolved_destination_branch_tip = resolve_branch_tip(
132
+ record.destination_repo, record.destination_branch, index, git_runner
133
+ )
134
+ record
135
+ end
136
+ private_class_method :parse_record
137
+
138
+ def self.require_absolute_repository(path, field, index)
139
+ return if path.to_s.start_with?("/")
140
+
141
+ raise Invalid, "Preservation manifest candidate #{index}: #{field} must be an absolute path"
142
+ end
143
+ private_class_method :require_absolute_repository
144
+
145
+ def self.require_real_repository(path, field, index)
146
+ return if File.directory?(path)
147
+
148
+ raise Invalid, "Preservation manifest candidate #{index}: #{field} is not a directory: #{path}"
149
+ end
150
+ private_class_method :require_real_repository
151
+
152
+ def self.same_path?(left, right)
153
+ File.realpath(left) == File.realpath(right)
154
+ rescue Errno::ENOENT
155
+ false
156
+ end
157
+ private_class_method :same_path?
158
+
159
+ def self.resolve_revision(repo, revision, field, index, git_runner)
160
+ stdout, stderr, status = git_runner.call(repo, "rev-parse", "--verify", "--quiet", "#{revision}^{commit}")
161
+ unless status.success?
162
+ raise Invalid,
163
+ "Preservation manifest candidate #{index}: #{field} `#{revision}` does not resolve to a commit " \
164
+ "in #{repo}: #{stderr.to_s.strip}"
165
+ end
166
+
167
+ stdout.to_s.strip
168
+ end
169
+ private_class_method :resolve_revision
170
+
171
+ # The destination must be an existing branch ref — never a raw SHA,
172
+ # which could name the candidate's own HEAD and masquerade as a
173
+ # surviving destination while prune deletes the only real branch.
174
+ def self.resolve_branch_tip(repo, branch, index, git_runner)
175
+ stdout, _stderr, status = git_runner.call(repo, "rev-parse", "--verify", "--quiet", branch.to_s)
176
+ unless status.success?
177
+ raise Invalid,
178
+ "Preservation manifest candidate #{index}: destination_branch `#{branch}` does not resolve in #{repo}"
179
+ end
180
+
181
+ full_name, _stderr, name_status = git_runner.call(repo, "rev-parse", "--symbolic-full-name", branch.to_s)
182
+ canonical = full_name.to_s.strip
183
+ if !name_status.success? || !canonical.start_with?("refs/heads/")
184
+ raise Invalid,
185
+ "Preservation manifest candidate #{index}: destination_branch `#{branch}` must name an existing " \
186
+ "branch ref (refs/heads/...)"
187
+ end
188
+
189
+ stdout.to_s.strip
190
+ end
191
+ private_class_method :resolve_branch_tip
192
+
193
+ # @param records [Array<Record>] Parsed, resolved records
194
+ def initialize(records)
195
+ @records = records.freeze
196
+ end
197
+
198
+ attr_reader :records
199
+
200
+ # @return [Record, nil] The record declaring the given worktree
201
+ def for_worktree(worktree_path)
202
+ canonical = canonical_path(worktree_path)
203
+ records.find { |record| canonical_path(record.worktree_path) == canonical }
204
+ end
205
+
206
+ # Reject manifest entries that do not belong to any selected
207
+ # candidate. A record for an unselected worktree is an input error.
208
+ #
209
+ # @param selected_paths [Array<String>] Candidate worktree paths
210
+ # @raise [Invalid] When an entry matches no selected worktree
211
+ def ensure_all_match!(selected_paths)
212
+ selected = selected_paths.map { |path| canonical_path(path) }
213
+ unmatched = records.reject { |record| selected.include?(canonical_path(record.worktree_path)) }
214
+ return if unmatched.empty?
215
+
216
+ raise Invalid,
217
+ "Preservation manifest entries do not match any selected worktree: " +
218
+ unmatched.map(&:worktree_path).join(", ")
219
+ end
220
+
221
+ private
222
+
223
+ def canonical_path(path)
224
+ File.realpath(path)
225
+ rescue Errno::ENOENT
226
+ File.expand_path(path)
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -5,23 +5,73 @@ require "open3"
5
5
  module Ace
6
6
  module Overseer
7
7
  module Molecules
8
+ # Classifies one worktree candidate for prune, fail-closed.
9
+ #
10
+ # A candidate is safe only when ALL of the following hold:
11
+ # - the assignment queue label is completed,
12
+ # - the task is done (a missing task record alone proves nothing but
13
+ # never authorizes removal by itself),
14
+ # - the checkout is clean including untracked files (dirty tracked AND
15
+ # untracked files are work; --force never overrides this),
16
+ # - required ignored artifacts are preserved: a local evidence journal
17
+ # checkout must already be contained in refs/ace/execution,
18
+ # - all recorded attempts are terminal (active or uncertain attempts
19
+ # and unreadable attempt state block),
20
+ # - an executed preservation proof passes (accepted ancestry or a
21
+ # verified declared destination).
8
22
  class PruneSafetyChecker
9
- def initialize(context_collector: nil, task_loader_factory: nil)
23
+ def initialize(context_collector: nil, task_loader_factory: nil, preservation_checker: nil,
24
+ attempt_store_factory: nil)
10
25
  @context_collector = context_collector || WorktreeContextCollector.new
11
26
  @task_loader_factory = task_loader_factory || -> { Ace::Task::Organisms::TaskManager.new }
27
+ @preservation_checker = preservation_checker || GitPreservationChecker.new
28
+ @attempt_store_factory = attempt_store_factory || ->(cache_base) {
29
+ Ace::Assign::Molecules::AttemptStore.new(cache_base: cache_base)
30
+ }
12
31
  end
13
32
 
14
- def check(worktree_path:, task_ref:)
33
+ attr_reader :preservation_checker
34
+
35
+ # @param worktree_path [String] Candidate worktree
36
+ # @param task_ref [String, nil] Task id recorded for the worktree
37
+ # @param manifest_record [PreservationManifest::Record, nil] Declared
38
+ # destination claim for this worktree
39
+ # @param accepted_base [Hash, nil] `{branch:, head:}` surviving base
40
+ # @return [Models::PruneCandidate]
41
+ def check(worktree_path:, task_ref:, manifest_record: nil, accepted_base: nil)
15
42
  context = @context_collector.collect(worktree_path)
16
43
 
17
44
  assignment_complete = assignment_complete?(context.assignment_status)
18
45
  task_done = task_done?(worktree_path, task_ref)
19
- git_clean = git_clean_for_prune?(worktree_path, context.git_status)
46
+ git_clean = git_clean_for_prune?(worktree_path)
47
+ artifacts_safe, artifact_reasons = required_ignored_artifacts_safe?(worktree_path)
48
+ attempts_terminal, attempt_reasons, recorded_base = attempts_evidence(worktree_path, context)
49
+ # One branch identity for the whole check: read before the proof,
50
+ # handed to the proof as the candidate's own branch, and verified
51
+ # downstream. A later read could name a switched-to destination.
52
+ branch_at_check = head_branch(worktree_path)
53
+ proof = preservation_proof(
54
+ worktree_path: worktree_path,
55
+ accepted_base: accepted_base,
56
+ manifest_record: manifest_record,
57
+ recorded_base: recorded_base,
58
+ candidate_branch: branch_at_check
59
+ )
60
+ # The HEAD and branch whose preservation was actually proven:
61
+ # destructive boundaries downstream must use these identities,
62
+ # never a later re-read that was itself never proven. Detachment
63
+ # comes from Git's symbolic HEAD state, not the branch name's
64
+ # shape — a 40-hex branch name is a real branch.
65
+ verified_head = proof.preserved? ? proof.head : nil
66
+ verified_branch = proof.preserved? ? branch_at_check : nil
20
67
 
21
68
  reasons = []
22
69
  reasons << "assignment not complete" unless assignment_complete
23
70
  reasons << "task not done" unless task_done
24
71
  reasons << "git not clean" unless git_clean
72
+ reasons.concat(artifact_reasons)
73
+ reasons.concat(attempt_reasons)
74
+ reasons << "preservation not proven: #{proof.reason}" unless proof.preserved?
25
75
 
26
76
  Models::PruneCandidate.new(
27
77
  task_id: task_ref,
@@ -29,6 +79,10 @@ module Ace
29
79
  assignment_complete: assignment_complete,
30
80
  task_done: task_done,
31
81
  git_clean: git_clean,
82
+ attempts_terminal: attempts_terminal && artifacts_safe,
83
+ preserved: proof.preserved?,
84
+ verified_head: verified_head,
85
+ verified_branch: verified_branch,
32
86
  reasons: reasons
33
87
  )
34
88
  end
@@ -46,28 +100,112 @@ module Ace
46
100
  paths.any? { |path| task_done_in_context?(path, task_ref) }
47
101
  end
48
102
 
49
- def git_clean?(git_status)
50
- git_status.is_a?(Hash) && (git_status["clean"] == true || git_status[:clean] == true)
103
+ # Full cleanliness: untracked files are work too. Ignored files are
104
+ # not dirt, but required ignored artifacts are checked separately.
105
+ def git_clean_for_prune?(worktree_path)
106
+ stdout, status = Open3.capture2(
107
+ "git", "-C", worktree_path, "status", "--porcelain"
108
+ )
109
+ return false unless status.success?
110
+
111
+ stdout.to_s.strip.empty?
112
+ rescue
113
+ false
51
114
  end
52
115
 
53
- def git_clean_for_prune?(worktree_path, git_status)
54
- tracked_clean = tracked_changes_clean?(worktree_path)
55
- return tracked_clean unless tracked_clean.nil?
116
+ # A journal checkout inside the worktree is a required ignored
117
+ # artifact unless every journaled commit already lives in the
118
+ # authoritative evidence ref.
119
+ def required_ignored_artifacts_safe?(worktree_path)
120
+ checkout = File.join(worktree_path, assign_cache_relative, "evidence-checkout", "journal")
121
+ return [true, []] unless File.directory?(checkout)
122
+
123
+ head, status = Open3.capture2("git", "-C", checkout, "rev-parse", "HEAD")
124
+ checkout_head = head.to_s.strip
125
+ if !status.success? || checkout_head.empty?
126
+ return [false, ["evidence journal checkout unreadable: #{checkout}"]]
127
+ end
128
+
129
+ common_dir = project_git_common_dir(worktree_path)
130
+ if common_dir.nil?
131
+ return [false, ["cannot resolve common git dir to verify evidence journal"]]
132
+ end
56
133
 
57
- git_clean?(git_status)
134
+ _out, evidence_status = Open3.capture2(
135
+ "git", "-C", common_dir, "merge-base", "--is-ancestor", checkout_head, "refs/ace/execution"
136
+ )
137
+ if evidence_status.success?
138
+ [true, []]
139
+ else
140
+ [false, ["evidence journal checkout has commits not in refs/ace/execution"]]
141
+ end
58
142
  end
59
143
 
60
- def tracked_changes_clean?(worktree_path)
61
- stdout, status = Open3.capture2(
62
- "git", "-C", worktree_path, "status", "--porcelain", "--untracked-files=no"
144
+ # Attempt evidence from the worktree's own assignment cache: any
145
+ # active or uncertain attempt blocks; an unreadable store blocks.
146
+ # Returns the independently recorded creation baseline (base_head)
147
+ # for patch-range preservation proofs.
148
+ def attempts_evidence(worktree_path, context)
149
+ ids = Array(context.assignments).filter_map { |entry| entry.dig("assignment", "id") }
150
+ return [true, [], nil] if ids.empty?
151
+
152
+ cache_base = File.join(worktree_path, assign_cache_relative)
153
+ begin
154
+ store = @attempt_store_factory.call(cache_base)
155
+ rescue => e
156
+ return [false, ["attempt state unreadable: #{e.message}"], nil]
157
+ end
158
+ reasons = []
159
+ recorded_base = nil
160
+ terminal = true
161
+ ids.each do |assignment_id|
162
+ attempts = store.list(assignment_id)
163
+ attempts.each do |attempt|
164
+ recorded_base ||= attempt.binding.base_head
165
+ next unless attempt.active? || attempt.uncertain?
166
+
167
+ terminal = false
168
+ reasons << "attempt #{attempt.attempt_id} on assignment #{assignment_id} is #{attempt.state}"
169
+ end
170
+ rescue => e
171
+ terminal = false
172
+ reasons << "attempt state for assignment #{assignment_id} unreadable: #{e.message}"
173
+ end
174
+ [terminal, reasons, recorded_base]
175
+ end
176
+
177
+ def preservation_proof(worktree_path:, accepted_base:, manifest_record:, recorded_base:, candidate_branch: nil)
178
+ return Models::PreservationProof.blocked("no surviving accepted base could be resolved") if accepted_base.nil?
179
+
180
+ @preservation_checker.proof(
181
+ worktree_path: worktree_path,
182
+ accepted_base: accepted_base,
183
+ manifest_record: manifest_record,
184
+ recorded_base: recorded_base,
185
+ candidate_branch: candidate_branch
186
+ )
187
+ rescue => e
188
+ Models::PreservationProof.blocked("preservation proof failed: #{e.message}")
189
+ end
190
+
191
+ # The checked-out branch per Git's symbolic HEAD state; nil when
192
+ # detached (or unreadable — then there is no deletable ref).
193
+ def head_branch(worktree_path)
194
+ stdout, _stderr, status = Open3.capture3(
195
+ "git", "-C", worktree_path, "symbolic-ref", "-q", "HEAD"
63
196
  )
64
197
  return nil unless status.success?
65
198
 
66
- stdout.to_s.strip.empty?
199
+ branch = stdout.to_s.strip.sub(/\Arefs\/heads\//, "")
200
+ branch.empty? ? nil : branch
67
201
  rescue
68
202
  nil
69
203
  end
70
204
 
205
+ def assign_cache_relative
206
+ Ace::Assign.config["cache_dir"] || ".ace-local/assign"
207
+ end
208
+
71
209
  def task_done_in_context?(path, task_ref)
72
210
  Dir.chdir(path) do
73
211
  manager = @task_loader_factory.call
@@ -79,15 +217,20 @@ module Ace
79
217
  end
80
218
 
81
219
  def project_root_from_worktree(worktree_path)
220
+ common_dir = project_git_common_dir(worktree_path)
221
+ return nil if common_dir.nil?
222
+
223
+ File.dirname(common_dir)
224
+ end
225
+
226
+ def project_git_common_dir(worktree_path)
82
227
  stdout, status = Open3.capture2(
83
228
  "git", "-C", worktree_path, "rev-parse", "--path-format=absolute", "--git-common-dir"
84
229
  )
85
230
  return nil unless status.success?
86
231
 
87
232
  common_dir = stdout.to_s.strip
88
- return nil if common_dir.empty?
89
-
90
- File.dirname(common_dir)
233
+ common_dir.empty? ? nil : common_dir
91
234
  rescue
92
235
  nil
93
236
  end