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.
@@ -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
- worktrees = if targets.any?
34
- filter_by_targets(all_worktrees, targets)
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
- progress.call("Checking #{worktrees.length} worktree(s)...")
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: forced, pruned: [], failed: []}
69
+ return {dry_run: true, safe: safe, unsafe: unsafe, forced: [], pruned: [], failed: [], blocked: []}
48
70
  end
49
71
 
50
- print_candidates(safe, unsafe, force, output)
72
+ print_candidates(safe, unsafe, output)
51
73
 
52
- to_prune = safe + forced
53
- unless yes
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: forced, pruned: [], failed: [], aborted: true}
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
- to_prune.each do |candidate|
65
- remove_result = @worktree_manager.remove(
66
- candidate.worktree_path,
67
- force: force,
68
- ignore_untracked: true,
69
- delete_branch: true
70
- )
71
- if remove_result[:success]
72
- close_tmux_window(candidate.worktree_path)
73
- pruned << candidate
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
- failed << {candidate: candidate, error: remove_result[:error]}
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
- dry_run: false,
81
- safe: safe,
82
- unsafe: unsafe,
83
- forced: forced,
84
- pruned: pruned,
85
- failed: failed,
86
- aborted: false
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
- private
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? || force
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, force, output)
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: [], aborted: true}
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 = @assignment_manager.delete(assignment_id)
118
- pruned = deleted ? [candidate] : []
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
- {dry_run: false, assignment_candidate: candidate, pruned_assignments: pruned}
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, force, output)
124
- label = if candidate.safe_to_prune?
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 filter_by_targets(worktrees, targets)
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
- end
258
+ if selected.empty?
259
+ raise Error, "no worktree matches target(s): #{targets.join(", ")}"
260
+ end
139
261
 
140
- def print_candidates(safe, unsafe, force, output)
141
- if safe.any?
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
- if unsafe.any?
148
- if force
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 check_candidate(worktree)
159
- @prune_checker.check(worktree_path: worktree.path, task_ref: worktree.task_id)
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
- worktree = @worktree_provisioner.provision(primary_ref)
53
- if worktree[:created]
54
- progress.call("Worktree created at #{worktree[:worktree_path]}")
55
- else
56
- progress.call("Worktree exists at #{worktree[:worktree_path]}")
57
- end
58
-
59
- progress.call("Opening tmux window...")
60
- @tmux_window_opener.open(
61
- worktree_path: worktree[:worktree_path],
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
- progress.call("Checking assignment status...")
66
- existing = if @assignment_detector
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
- preset_name: preset_name,
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
- launched.merge(created: true)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Overseer
5
- VERSION = "0.17.0"
5
+ VERSION = "0.18.2"
6
6
  end
7
7
  end
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