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
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
3
5
|
module Ace
|
|
4
6
|
module Overseer
|
|
5
7
|
module Organisms
|
|
8
|
+
# Enforces the prune-safety contract on actual destructive paths.
|
|
9
|
+
#
|
|
10
|
+
# Preview is read-only: it classifies candidates but never mutates
|
|
11
|
+
# worktree metadata or state. Apply holds the durable lifecycle
|
|
12
|
+
# exclusion exclusively per candidate from its final evidence reads
|
|
13
|
+
# through removal, recomputes the full safety classification under the
|
|
14
|
+
# exclusion, removes the worktree without force or untracked
|
|
15
|
+
# suppression, deletes the branch only after re-verifying its tip and
|
|
16
|
+
# preservation, and records the removal so later starts fail closed.
|
|
17
|
+
#
|
|
18
|
+
# --force never bypasses preservation, dirtiness, lifecycle or
|
|
19
|
+
# revalidation blocks; it only suppresses the interactive confirmation
|
|
20
|
+
# for already-safe candidates.
|
|
6
21
|
class PruneOrchestrator
|
|
7
22
|
def initialize(worktree_manager: nil, prune_checker: nil, tmux_executor: nil, config: nil,
|
|
8
|
-
assignment_prune_checker: nil, assignment_manager: nil
|
|
23
|
+
assignment_prune_checker: nil, assignment_manager: nil, lifecycle_exclusion: nil,
|
|
24
|
+
preservation_manifest_loader: nil)
|
|
9
25
|
@worktree_manager = worktree_manager || Ace::Git::Worktree::Organisms::WorktreeManager.new
|
|
10
26
|
@prune_checker = prune_checker || Molecules::PruneSafetyChecker.new
|
|
11
27
|
@tmux_executor = tmux_executor || Ace::Tmux::Molecules::TmuxExecutor.new
|
|
12
28
|
@config = config || Ace::Overseer.config
|
|
13
29
|
@assignment_prune_checker = assignment_prune_checker || Molecules::AssignmentPruneSafetyChecker.new
|
|
14
30
|
@assignment_manager = assignment_manager || Ace::Assign::Molecules::AssignmentManager.new
|
|
31
|
+
@lifecycle_exclusion = lifecycle_exclusion
|
|
32
|
+
@preservation_manifest_loader = preservation_manifest_loader || ->(path) {
|
|
33
|
+
Molecules::PreservationManifest.load(path)
|
|
34
|
+
}
|
|
15
35
|
end
|
|
16
36
|
|
|
17
37
|
def call(dry_run:, yes:, force: false, targets: [], assignment_id: nil,
|
|
18
|
-
input: $stdin, output: $stdout, on_progress: nil)
|
|
38
|
+
preservation_manifest: nil, input: $stdin, output: $stdout, on_progress: nil)
|
|
19
39
|
if assignment_id
|
|
20
40
|
return prune_assignment(assignment_id: assignment_id, dry_run: dry_run,
|
|
21
41
|
yes: yes, force: force, input: input, output: output,
|
|
@@ -25,69 +45,160 @@ module Ace
|
|
|
25
45
|
progress = on_progress || ->(_msg) {}
|
|
26
46
|
|
|
27
47
|
progress.call("Scanning worktrees...")
|
|
28
|
-
prune_stale_metadata(progress)
|
|
29
48
|
result = @worktree_manager.list_all(show_tasks: true)
|
|
30
49
|
raise Error, result[:error] || "Failed to list worktrees" unless result[:success]
|
|
31
50
|
|
|
32
51
|
all_worktrees = Array(result[:worktrees]).reject(&:bare)
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
selection = if targets.any?
|
|
53
|
+
select_by_targets(all_worktrees, targets)
|
|
35
54
|
else
|
|
36
55
|
all_worktrees.select(&:task_associated?)
|
|
37
56
|
end
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
checked = worktrees.map { |worktree| check_candidate(worktree) }
|
|
58
|
+
manifest = load_manifest(preservation_manifest, selection)
|
|
41
59
|
|
|
60
|
+
progress.call("Checking #{selection.length} worktree(s)...")
|
|
61
|
+
accepted_base = accepted_base_for(selection)
|
|
62
|
+
checked = selection.map do |worktree|
|
|
63
|
+
check_candidate(worktree, manifest: manifest, accepted_base: accepted_base)
|
|
64
|
+
end
|
|
42
65
|
safe = checked.select(&:safe_to_prune?)
|
|
43
66
|
unsafe = checked.reject(&:safe_to_prune?)
|
|
44
|
-
forced = force ? unsafe : []
|
|
45
67
|
|
|
46
68
|
if dry_run
|
|
47
|
-
return {dry_run: true, safe: safe, unsafe: unsafe, forced:
|
|
69
|
+
return {dry_run: true, safe: safe, unsafe: unsafe, forced: [], pruned: [], failed: [], blocked: []}
|
|
48
70
|
end
|
|
49
71
|
|
|
50
|
-
print_candidates(safe, unsafe,
|
|
72
|
+
print_candidates(safe, unsafe, output)
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
# --force suppresses only the convenience confirmation for
|
|
75
|
+
# already-safe candidates; every safety block still applies.
|
|
76
|
+
unless yes || force
|
|
54
77
|
output.print("Continue? [y/N] ")
|
|
55
78
|
answer = input.gets.to_s.strip.downcase
|
|
56
79
|
unless %w[y yes].include?(answer)
|
|
57
|
-
return {dry_run: false, safe: safe, unsafe: unsafe, forced:
|
|
80
|
+
return {dry_run: false, safe: safe, unsafe: unsafe, forced: [], pruned: [],
|
|
81
|
+
failed: [], blocked: [], aborted: true}
|
|
58
82
|
end
|
|
59
83
|
end
|
|
60
84
|
|
|
85
|
+
prune_stale_metadata(progress)
|
|
86
|
+
apply_removals(safe, accepted_base: accepted_base, manifest: manifest, progress: progress)
|
|
87
|
+
.merge(safe: safe, unsafe: unsafe, forced: [], aborted: false, dry_run: false)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Remove each safe candidate independently under its exclusion: a
|
|
93
|
+
# blocked candidate never stops other safe candidates from
|
|
94
|
+
# completing.
|
|
95
|
+
def apply_removals(safe, accepted_base:, manifest:, progress:)
|
|
61
96
|
pruned = []
|
|
62
97
|
failed = []
|
|
98
|
+
blocked = []
|
|
99
|
+
|
|
100
|
+
safe.each do |candidate|
|
|
101
|
+
exclusion.with_exclusive(identity_key(candidate)) do
|
|
102
|
+
# Re-resolve the surviving base inside the exclusion: a base
|
|
103
|
+
# branch reset after preview must not authorize removal against
|
|
104
|
+
# its stale tip, and an unresolvable base cannot re-verify
|
|
105
|
+
# preservation at all.
|
|
106
|
+
fresh_base = accepted_base_for([candidate_worktree(candidate)])
|
|
107
|
+
recheck = check_candidate(
|
|
108
|
+
candidate_worktree(candidate), manifest: manifest, accepted_base: fresh_base
|
|
109
|
+
)
|
|
110
|
+
unless recheck.safe_to_prune?
|
|
111
|
+
blocked << {candidate: candidate, reasons: recheck.reasons}
|
|
112
|
+
progress.call("Blocked after recheck: task.#{candidate.task_id} — #{recheck.reasons.join(", ")}")
|
|
113
|
+
next
|
|
114
|
+
end
|
|
63
115
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
116
|
+
removal = remove_worktree(
|
|
117
|
+
candidate,
|
|
118
|
+
verified_head: recheck.verified_head,
|
|
119
|
+
verified_branch: recheck.verified_branch
|
|
120
|
+
)
|
|
121
|
+
if removal[:success]
|
|
122
|
+
pruned << candidate
|
|
123
|
+
record_removed(candidate)
|
|
124
|
+
else
|
|
125
|
+
failed << {candidate: candidate, error: removal[:error]}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
rescue => e
|
|
129
|
+
failed << {candidate: candidate, error: e.message}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
{pruned: pruned, failed: failed, blocked: blocked}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def remove_worktree(candidate, verified_head:, verified_branch: nil)
|
|
136
|
+
# Bind both identities the recheck proved to the removal: a HEAD
|
|
137
|
+
# move or a branch switch after the recheck must not let the
|
|
138
|
+
# worktree disappear or an unproven ref be deleted. Unreadable
|
|
139
|
+
# identities are left to the removal itself, which fails closed.
|
|
140
|
+
head_now = candidate_head(candidate.worktree_path)
|
|
141
|
+
branch_now = candidate_branch(candidate.worktree_path)
|
|
142
|
+
if verified_head && head_now && head_now != verified_head
|
|
143
|
+
return {success: false, error: "HEAD changed after recheck (#{head_now[0, 12]}); preserving"}
|
|
144
|
+
end
|
|
145
|
+
# Compare branch identities in BOTH directions, including nil: a
|
|
146
|
+
# detach after the recheck preserves the candidate instead of
|
|
147
|
+
# deleting the verified branch, and attaching after a detached
|
|
148
|
+
# recheck preserves the candidate instead of deleting a branch
|
|
149
|
+
# that was never proven.
|
|
150
|
+
if verified_branch != branch_now
|
|
151
|
+
detail = if verified_branch.nil?
|
|
152
|
+
"worktree attached to #{branch_now} after recheck"
|
|
153
|
+
elsif branch_now.to_s.empty?
|
|
154
|
+
"worktree detached after recheck"
|
|
74
155
|
else
|
|
75
|
-
|
|
156
|
+
"branch switched to #{branch_now} after recheck"
|
|
76
157
|
end
|
|
158
|
+
return {success: false, error: "#{detail}; preserving"}
|
|
77
159
|
end
|
|
78
160
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
161
|
+
repo = candidate_repo(candidate.worktree_path)
|
|
162
|
+
|
|
163
|
+
remove_result = @worktree_manager.remove(
|
|
164
|
+
candidate.worktree_path,
|
|
165
|
+
force: false,
|
|
166
|
+
ignore_untracked: false,
|
|
167
|
+
delete_branch: false
|
|
168
|
+
)
|
|
169
|
+
return remove_result unless remove_result[:success]
|
|
170
|
+
|
|
171
|
+
close_tmux_window(candidate.worktree_path)
|
|
172
|
+
# Only the branch the recheck verified is deletable — never a
|
|
173
|
+
# freshly observed branch name.
|
|
174
|
+
branch_result = delete_branch(repo, branch: verified_branch, head: verified_head)
|
|
175
|
+
return branch_result unless branch_result[:success]
|
|
176
|
+
|
|
177
|
+
remove_result
|
|
88
178
|
end
|
|
89
179
|
|
|
90
|
-
|
|
180
|
+
# The branch deletion boundary: the ref is deleted by compare-and-swap
|
|
181
|
+
# against the exact HEAD whose preservation the recheck just proved —
|
|
182
|
+
# never a freshly re-read commit that was itself never proven, and a
|
|
183
|
+
# branch advanced concurrently is preserved, not force-deleted.
|
|
184
|
+
def delete_branch(repo, branch:, head:)
|
|
185
|
+
return {success: true} if branch.nil? || branch.empty?
|
|
186
|
+
if head.nil?
|
|
187
|
+
return {success: false, error: "recheck did not verify a candidate HEAD; preserving branch #{branch}"}
|
|
188
|
+
end
|
|
189
|
+
return {success: false, error: "cannot resolve common repository for branch deletion"} if repo.nil?
|
|
190
|
+
|
|
191
|
+
_out, status = Open3.capture2(
|
|
192
|
+
"git", "-C", repo, "update-ref", "-d", "refs/heads/#{branch}", head
|
|
193
|
+
)
|
|
194
|
+
if status.success?
|
|
195
|
+
{success: true}
|
|
196
|
+
else
|
|
197
|
+
tip = rev_parse(repo, "--verify", "refs/heads/#{branch}")
|
|
198
|
+
{success: false,
|
|
199
|
+
error: "branch #{branch} changed after preview (tip #{tip ? tip[0, 12] : "missing"}); preserving"}
|
|
200
|
+
end
|
|
201
|
+
end
|
|
91
202
|
|
|
92
203
|
def prune_assignment(assignment_id:, dry_run:, yes:, force:, input:, output:, on_progress:)
|
|
93
204
|
progress = on_progress || ->(_msg) {}
|
|
@@ -96,73 +207,114 @@ module Ace
|
|
|
96
207
|
candidate = @assignment_prune_checker.check(assignment_id: assignment_id)
|
|
97
208
|
|
|
98
209
|
if dry_run
|
|
99
|
-
return {dry_run: true, assignment_candidate: candidate, pruned_assignments: []}
|
|
210
|
+
return {dry_run: true, assignment_candidate: candidate, pruned_assignments: [], blocked: false}
|
|
100
211
|
end
|
|
101
212
|
|
|
102
|
-
unless candidate.safe_to_prune?
|
|
213
|
+
unless candidate.safe_to_prune?
|
|
103
214
|
output.puts("Cannot prune assignment #{assignment_id}: #{candidate.reasons.join(", ")}")
|
|
104
215
|
return {dry_run: false, assignment_candidate: candidate, pruned_assignments: [], blocked: true}
|
|
105
216
|
end
|
|
106
217
|
|
|
107
|
-
print_assignment_candidate(candidate,
|
|
218
|
+
print_assignment_candidate(candidate, output)
|
|
108
219
|
|
|
109
220
|
unless yes
|
|
110
221
|
output.print("Continue? [y/N] ")
|
|
111
222
|
answer = input.gets.to_s.strip.downcase
|
|
112
223
|
unless %w[y yes].include?(answer)
|
|
113
|
-
return {dry_run: false, assignment_candidate: candidate, pruned_assignments: [],
|
|
224
|
+
return {dry_run: false, assignment_candidate: candidate, pruned_assignments: [],
|
|
225
|
+
blocked: false, aborted: true}
|
|
114
226
|
end
|
|
115
227
|
end
|
|
116
228
|
|
|
117
|
-
deleted =
|
|
118
|
-
|
|
229
|
+
deleted = false
|
|
230
|
+
exclusion.with_exclusive(exclusion.assignment_key(assignment_id)) do
|
|
231
|
+
recheck = @assignment_prune_checker.check(assignment_id: assignment_id)
|
|
232
|
+
unless recheck.safe_to_prune?
|
|
233
|
+
output.puts("Blocked after recheck: assignment #{assignment_id}: #{recheck.reasons.join(", ")}")
|
|
234
|
+
return {dry_run: false, assignment_candidate: recheck, pruned_assignments: [], blocked: true}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
deleted = @assignment_manager.delete(assignment_id)
|
|
238
|
+
# The removed marker records a completed deletion only: a failed
|
|
239
|
+
# delete must keep admitting starts against the surviving cache.
|
|
240
|
+
exclusion.record_removed!(exclusion.assignment_key(assignment_id)) if deleted
|
|
241
|
+
end
|
|
119
242
|
|
|
120
|
-
|
|
243
|
+
pruned = deleted ? [candidate] : []
|
|
244
|
+
{dry_run: false, assignment_candidate: candidate, pruned_assignments: pruned,
|
|
245
|
+
blocked: false, deleted: deleted}
|
|
121
246
|
end
|
|
122
247
|
|
|
123
|
-
def print_assignment_candidate(candidate,
|
|
124
|
-
|
|
125
|
-
"Safe to prune"
|
|
126
|
-
else
|
|
127
|
-
(force ? "Force removing" : "Blocked")
|
|
128
|
-
end
|
|
129
|
-
output.puts("#{label}: assignment #{candidate.assignment_id} (#{candidate.assignment_name})")
|
|
248
|
+
def print_assignment_candidate(candidate, output)
|
|
249
|
+
output.puts("Safe to prune: assignment #{candidate.assignment_id} (#{candidate.assignment_name})")
|
|
130
250
|
output.puts(" State: #{candidate.assignment_state}")
|
|
131
251
|
output.puts(" Reasons: #{candidate.reasons.join(", ")}") if candidate.reasons.any?
|
|
132
252
|
end
|
|
133
253
|
|
|
134
|
-
def
|
|
135
|
-
worktrees.select do |wt|
|
|
254
|
+
def select_by_targets(worktrees, targets)
|
|
255
|
+
selected = worktrees.select do |wt|
|
|
136
256
|
targets.any? { |t| wt.task_id.to_s == t.to_s || wt.path.include?(t.to_s) }
|
|
137
257
|
end
|
|
138
|
-
|
|
258
|
+
if selected.empty?
|
|
259
|
+
raise Error, "no worktree matches target(s): #{targets.join(", ")}"
|
|
260
|
+
end
|
|
139
261
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
output.puts("Safe to prune (#{safe.length}):")
|
|
143
|
-
safe.each { |c| output.puts(" task.#{c.task_id} — #{c.worktree_path}") }
|
|
144
|
-
else
|
|
145
|
-
output.puts("No worktrees safe to prune.")
|
|
262
|
+
unmatched = targets.reject do |t|
|
|
263
|
+
selected.any? { |wt| wt.task_id.to_s == t.to_s || wt.path.include?(t.to_s) }
|
|
146
264
|
end
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
output.puts("Force removing (#{unsafe.length}):")
|
|
150
|
-
unsafe.each { |c| output.puts(" task.#{c.task_id} — #{c.reasons.join(", ")}") }
|
|
151
|
-
else
|
|
152
|
-
output.puts("Skipping (#{unsafe.length}):")
|
|
153
|
-
unsafe.each { |c| output.puts(" task.#{c.task_id} — #{c.reasons.join(", ")}") }
|
|
154
|
-
end
|
|
265
|
+
unless unmatched.empty?
|
|
266
|
+
raise Error, "no worktree matches target(s): #{unmatched.join(", ")}"
|
|
155
267
|
end
|
|
268
|
+
|
|
269
|
+
selected
|
|
156
270
|
end
|
|
157
271
|
|
|
158
|
-
def
|
|
159
|
-
|
|
272
|
+
def load_manifest(preservation_manifest, selection)
|
|
273
|
+
return nil if preservation_manifest.nil?
|
|
274
|
+
|
|
275
|
+
manifest = @preservation_manifest_loader.call(preservation_manifest)
|
|
276
|
+
manifest.ensure_all_match!(selection.map(&:path))
|
|
277
|
+
manifest
|
|
278
|
+
rescue Molecules::PreservationManifest::Invalid => e
|
|
279
|
+
raise Error, e.message
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def accepted_base_for(selection)
|
|
283
|
+
sample = selection.first
|
|
284
|
+
return nil if sample.nil?
|
|
285
|
+
|
|
286
|
+
repo = candidate_repo(sample.path)
|
|
287
|
+
return nil if repo.nil?
|
|
288
|
+
|
|
289
|
+
branch = rev_parse(repo, "--abbrev-ref", "HEAD")
|
|
290
|
+
head = rev_parse(repo, "--verify", "HEAD")
|
|
291
|
+
return nil if branch.nil? || head.nil? || branch == "HEAD"
|
|
292
|
+
|
|
293
|
+
base = {branch: branch, head: head}
|
|
294
|
+
@cached_accepted_base = base
|
|
295
|
+
base
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def check_candidate(worktree, manifest:, accepted_base:)
|
|
299
|
+
record = manifest&.for_worktree(worktree.path)
|
|
300
|
+
@prune_checker.check(
|
|
301
|
+
worktree_path: worktree.path,
|
|
302
|
+
task_ref: worktree.task_id,
|
|
303
|
+
manifest_record: record,
|
|
304
|
+
accepted_base: accepted_base
|
|
305
|
+
)
|
|
160
306
|
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
161
307
|
unsafe_candidate(worktree, "worktree directory missing")
|
|
162
308
|
rescue => e
|
|
163
309
|
unsafe_candidate(worktree, "prune safety check failed: #{e.message}")
|
|
164
310
|
end
|
|
165
311
|
|
|
312
|
+
# A recheck needs a worktree-shaped object; the candidate model
|
|
313
|
+
# carries both path and task id.
|
|
314
|
+
def candidate_worktree(candidate)
|
|
315
|
+
Struct.new(:path, :task_id).new(candidate.worktree_path, candidate.task_id)
|
|
316
|
+
end
|
|
317
|
+
|
|
166
318
|
def unsafe_candidate(worktree, reason)
|
|
167
319
|
Models::PruneCandidate.new(
|
|
168
320
|
task_id: worktree.task_id || "unknown",
|
|
@@ -170,10 +322,60 @@ module Ace
|
|
|
170
322
|
assignment_complete: false,
|
|
171
323
|
task_done: false,
|
|
172
324
|
git_clean: false,
|
|
325
|
+
attempts_terminal: false,
|
|
326
|
+
preserved: false,
|
|
173
327
|
reasons: [reason]
|
|
174
328
|
)
|
|
175
329
|
end
|
|
176
330
|
|
|
331
|
+
def identity_key(candidate)
|
|
332
|
+
if candidate.task_id.to_s == "" || candidate.task_id.to_s == "unknown"
|
|
333
|
+
exclusion.worktree_key(candidate.worktree_path)
|
|
334
|
+
else
|
|
335
|
+
exclusion.task_key(candidate.task_id)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def record_removed(candidate)
|
|
340
|
+
exclusion.record_removed!(identity_key(candidate))
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def exclusion
|
|
344
|
+
@lifecycle_exclusion ||= Ace::Assign::Molecules::LifecycleExclusion.new
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def candidate_head(worktree_path)
|
|
348
|
+
rev_parse(worktree_path, "--verify", "HEAD")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def candidate_branch(worktree_path)
|
|
352
|
+
branch = rev_parse(worktree_path, "--abbrev-ref", "HEAD")
|
|
353
|
+
branch == "HEAD" ? nil : branch
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def candidate_repo(worktree_path)
|
|
357
|
+
common_dir = rev_parse(worktree_path, "--path-format=absolute", "--git-common-dir")
|
|
358
|
+
common_dir.nil? || common_dir.empty? ? nil : File.dirname(common_dir)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def rev_parse(repo, *args)
|
|
362
|
+
stdout, _stderr, status = Open3.capture3("git", "-C", repo, "rev-parse", *args)
|
|
363
|
+
status.success? ? stdout.to_s.strip : nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def print_candidates(safe, unsafe, output)
|
|
367
|
+
if safe.any?
|
|
368
|
+
output.puts("Safe to prune (#{safe.length}):")
|
|
369
|
+
safe.each { |c| output.puts(" task.#{c.task_id} — #{c.worktree_path}") }
|
|
370
|
+
else
|
|
371
|
+
output.puts("No worktrees safe to prune.")
|
|
372
|
+
end
|
|
373
|
+
if unsafe.any?
|
|
374
|
+
output.puts("Skipping (#{unsafe.length}):")
|
|
375
|
+
unsafe.each { |c| output.puts(" task.#{c.task_id} — #{c.reasons.join(", ")}") }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
177
379
|
def prune_stale_metadata(progress)
|
|
178
380
|
result = @worktree_manager.prune
|
|
179
381
|
return if result.nil? || result[:success]
|
|
@@ -8,13 +8,14 @@ module Ace
|
|
|
8
8
|
SUBTASK_PATTERN = /^[0-9a-z]{3}\.[a-z]\.[0-9a-z]{3}\.[a-z0-9]$/
|
|
9
9
|
|
|
10
10
|
def initialize(worktree_provisioner: nil, tmux_window_opener: nil, assignment_launcher: nil,
|
|
11
|
-
task_loader: nil, config: nil, assignment_detector: nil)
|
|
11
|
+
task_loader: nil, config: nil, assignment_detector: nil, lifecycle_exclusion: nil)
|
|
12
12
|
@worktree_provisioner = worktree_provisioner || Molecules::WorktreeProvisioner.new
|
|
13
13
|
@tmux_window_opener = tmux_window_opener || Molecules::TmuxWindowOpener.new
|
|
14
14
|
@task_manager = task_loader || Ace::Task::Organisms::TaskManager.new
|
|
15
15
|
@assignment_launcher = assignment_launcher || Molecules::AssignmentLauncher.new(task_manager: @task_manager)
|
|
16
16
|
@config = config || Ace::Overseer.config
|
|
17
17
|
@assignment_detector = assignment_detector
|
|
18
|
+
@lifecycle_exclusion = lifecycle_exclusion
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def call(task_ref:, task_refs: nil, cli_preset: nil, on_progress: nil)
|
|
@@ -48,57 +49,70 @@ module Ace
|
|
|
48
49
|
primary_subtask_refs = extract_subtask_refs(primary_task)
|
|
49
50
|
tmux_preset = @config.dig("tmux_window_presets", preset_name)
|
|
50
51
|
|
|
52
|
+
# Participate in prune exclusion for the whole start: provisioning,
|
|
53
|
+
# runtime window and assignment launch all hold the shared side of
|
|
54
|
+
# the task identity, so a worktree prune cannot interleave with a
|
|
55
|
+
# writer starting into the same task. Provisioning a fresh worktree
|
|
56
|
+
# for a task whose previous worktree was pruned clears the removed
|
|
57
|
+
# marker explicitly.
|
|
51
58
|
progress.call("Provisioning worktree...")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
preset: tmux_preset
|
|
63
|
-
)
|
|
59
|
+
outcome = nil
|
|
60
|
+
lifecycle_exclusion.with_shared(
|
|
61
|
+
lifecycle_exclusion.task_key(primary_ref), reset_removed: true
|
|
62
|
+
) do
|
|
63
|
+
worktree = @worktree_provisioner.provision(primary_ref)
|
|
64
|
+
if worktree[:created]
|
|
65
|
+
progress.call("Worktree created at #{worktree[:worktree_path]}")
|
|
66
|
+
else
|
|
67
|
+
progress.call("Worktree exists at #{worktree[:worktree_path]}")
|
|
68
|
+
end
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@assignment_detector.call(worktree[:worktree_path])
|
|
68
|
-
else
|
|
69
|
-
existing_assignment(worktree[:worktree_path])
|
|
70
|
-
end
|
|
71
|
-
assignment_result = if existing
|
|
72
|
-
progress.call("Assignment already active: #{existing.dig("assignment", "id")}")
|
|
73
|
-
focused_step = existing.dig("focus_step", "number") || existing.dig("next_step", "number")
|
|
74
|
-
{
|
|
75
|
-
assignment_id: existing.dig("assignment", "id"),
|
|
76
|
-
first_step: focused_step,
|
|
77
|
-
created: false
|
|
78
|
-
}
|
|
79
|
-
else
|
|
80
|
-
progress.call("Launching assignment (preset: #{preset_name})...")
|
|
81
|
-
launched = @assignment_launcher.launch(
|
|
70
|
+
progress.call("Opening tmux window...")
|
|
71
|
+
@tmux_window_opener.open(
|
|
82
72
|
worktree_path: worktree[:worktree_path],
|
|
83
|
-
|
|
84
|
-
task_ref: primary_ref.to_s,
|
|
85
|
-
subtask_refs: primary_subtask_refs,
|
|
86
|
-
task_refs: expanded_taskrefs
|
|
73
|
+
preset: tmux_preset
|
|
87
74
|
)
|
|
88
|
-
|
|
75
|
+
|
|
76
|
+
progress.call("Checking assignment status...")
|
|
77
|
+
existing = if @assignment_detector
|
|
78
|
+
@assignment_detector.call(worktree[:worktree_path])
|
|
79
|
+
else
|
|
80
|
+
existing_assignment(worktree[:worktree_path])
|
|
81
|
+
end
|
|
82
|
+
assignment_result = if existing
|
|
83
|
+
progress.call("Assignment already active: #{existing.dig("assignment", "id")}")
|
|
84
|
+
focused_step = existing.dig("focus_step", "number") || existing.dig("next_step", "number")
|
|
85
|
+
{
|
|
86
|
+
assignment_id: existing.dig("assignment", "id"),
|
|
87
|
+
first_step: focused_step,
|
|
88
|
+
created: false
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
progress.call("Launching assignment (preset: #{preset_name})...")
|
|
92
|
+
launched = @assignment_launcher.launch(
|
|
93
|
+
worktree_path: worktree[:worktree_path],
|
|
94
|
+
preset_name: preset_name,
|
|
95
|
+
task_ref: primary_ref.to_s,
|
|
96
|
+
subtask_refs: primary_subtask_refs,
|
|
97
|
+
task_refs: expanded_taskrefs
|
|
98
|
+
)
|
|
99
|
+
launched.merge(created: true)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
outcome = {
|
|
103
|
+
task_ref: primary_ref.to_s,
|
|
104
|
+
task_refs: expanded_taskrefs,
|
|
105
|
+
preset: preset_name,
|
|
106
|
+
worktree_path: worktree[:worktree_path],
|
|
107
|
+
branch: worktree[:branch],
|
|
108
|
+
worktree_created: worktree[:created],
|
|
109
|
+
assignment_id: assignment_result[:assignment_id],
|
|
110
|
+
first_step: assignment_result[:first_step],
|
|
111
|
+
assignment_created: assignment_result[:created]
|
|
112
|
+
}
|
|
89
113
|
end
|
|
90
114
|
|
|
91
|
-
|
|
92
|
-
task_ref: primary_ref.to_s,
|
|
93
|
-
task_refs: expanded_taskrefs,
|
|
94
|
-
preset: preset_name,
|
|
95
|
-
worktree_path: worktree[:worktree_path],
|
|
96
|
-
branch: worktree[:branch],
|
|
97
|
-
worktree_created: worktree[:created],
|
|
98
|
-
assignment_id: assignment_result[:assignment_id],
|
|
99
|
-
first_step: assignment_result[:first_step],
|
|
100
|
-
assignment_created: assignment_result[:created]
|
|
101
|
-
}
|
|
115
|
+
outcome
|
|
102
116
|
end
|
|
103
117
|
|
|
104
118
|
private
|
|
@@ -111,6 +125,10 @@ module Ace
|
|
|
111
125
|
active.any? ? active.map(&:id) : nil
|
|
112
126
|
end
|
|
113
127
|
|
|
128
|
+
def lifecycle_exclusion
|
|
129
|
+
@lifecycle_exclusion ||= Ace::Assign::Molecules::LifecycleExclusion.new
|
|
130
|
+
end
|
|
131
|
+
|
|
114
132
|
def normalize_requested_refs(task_ref, task_refs)
|
|
115
133
|
raw = (task_refs && !task_refs.empty?) ? task_refs : [task_ref]
|
|
116
134
|
raw
|
data/lib/ace/overseer/version.rb
CHANGED
data/lib/ace/overseer.rb
CHANGED
|
@@ -8,10 +8,17 @@ require "ace/git/worktree"
|
|
|
8
8
|
require "ace/task"
|
|
9
9
|
require "ace/tmux"
|
|
10
10
|
|
|
11
|
+
module Ace
|
|
12
|
+
module Overseer
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
require_relative "overseer/version"
|
|
12
18
|
require_relative "overseer/models/work_context"
|
|
13
19
|
require_relative "overseer/models/prune_candidate"
|
|
14
20
|
require_relative "overseer/models/assignment_prune_candidate"
|
|
21
|
+
require_relative "overseer/models/preservation_proof"
|
|
15
22
|
require_relative "overseer/atoms/repo_guard"
|
|
16
23
|
require_relative "overseer/atoms/preset_resolver"
|
|
17
24
|
require_relative "overseer/atoms/status_formatter"
|
|
@@ -20,8 +27,11 @@ require_relative "overseer/molecules/lab_client"
|
|
|
20
27
|
require_relative "overseer/molecules/tmux_window_opener"
|
|
21
28
|
require_relative "overseer/molecules/assignment_launcher"
|
|
22
29
|
require_relative "overseer/molecules/worktree_context_collector"
|
|
30
|
+
require_relative "overseer/molecules/preservation_manifest"
|
|
31
|
+
require_relative "overseer/molecules/git_preservation_checker"
|
|
23
32
|
require_relative "overseer/molecules/prune_safety_checker"
|
|
24
33
|
require_relative "overseer/molecules/assignment_prune_safety_checker"
|
|
34
|
+
require_relative "overseer/molecules/lab_prune_safety_checker"
|
|
25
35
|
require_relative "overseer/organisms/work_on_orchestrator"
|
|
26
36
|
require_relative "overseer/organisms/status_collector"
|
|
27
37
|
require_relative "overseer/organisms/prune_orchestrator"
|
|
@@ -29,8 +39,6 @@ require_relative "overseer/cli"
|
|
|
29
39
|
|
|
30
40
|
module Ace
|
|
31
41
|
module Overseer
|
|
32
|
-
class Error < StandardError; end
|
|
33
|
-
|
|
34
42
|
@config_mutex = Mutex.new
|
|
35
43
|
@gem_root_mutex = Mutex.new
|
|
36
44
|
|