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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -10
- data/docs/usage.md +41 -5
- data/handbook/workflow-instructions/overseer.wf.md +33 -48
- data/lib/ace/overseer/cli/commands/prune.rb +140 -22
- data/lib/ace/overseer/models/assignment_prune_candidate.rb +7 -3
- data/lib/ace/overseer/models/preservation_proof.rb +37 -0
- data/lib/ace/overseer/models/prune_candidate.rb +11 -3
- data/lib/ace/overseer/molecules/assignment_prune_safety_checker.rb +94 -1
- data/lib/ace/overseer/molecules/git_preservation_checker.rb +314 -0
- data/lib/ace/overseer/molecules/lab_client.rb +21 -0
- data/lib/ace/overseer/molecules/lab_prune_safety_checker.rb +69 -0
- data/lib/ace/overseer/molecules/preservation_manifest.rb +231 -0
- data/lib/ace/overseer/molecules/prune_safety_checker.rb +159 -16
- data/lib/ace/overseer/organisms/prune_orchestrator.rb +269 -67
- data/lib/ace/overseer/organisms/work_on_orchestrator.rb +64 -46
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +10 -2
- metadata +10 -6
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
File.dirname(common_dir)
|
|
233
|
+
common_dir.empty? ? nil : common_dir
|
|
91
234
|
rescue
|
|
92
235
|
nil
|
|
93
236
|
end
|