ace-assign 0.42.4 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
- data/.ace-defaults/assign/config.yml +1 -0
- data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
- data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +6 -16
- data/CHANGELOG.md +216 -0
- data/README.md +20 -43
- data/docs/demo/canonical-skill-source.gif +0 -0
- data/docs/demo/canonical-skill-source.tape.yml +51 -0
- data/docs/demo/fork-provider.cast +834 -0
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +30 -0
- data/docs/demo/fork-provider.tape.yml +77 -20
- data/docs/getting-started.md +5 -2
- data/docs/usage.md +74 -4
- data/handbook/guides/fork-context.g.md +31 -7
- data/handbook/skills/as-assign-drive/SKILL.md +13 -1
- data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
- data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
- data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
- data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
- data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
- data/handbook/workflow-instructions/assign/create.wf.md +6 -3
- data/handbook/workflow-instructions/assign/drive.wf.md +330 -40
- data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +10 -5
- data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
- data/handbook/workflow-instructions/assign/start.wf.md +5 -2
- data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
- data/lib/ace/assign/atoms/preset_expander.rb +4 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
- data/lib/ace/assign/cli/commands/add.rb +20 -11
- data/lib/ace/assign/cli/commands/assignment_target.rb +87 -3
- data/lib/ace/assign/cli/commands/create.rb +1 -1
- data/lib/ace/assign/cli/commands/fail.rb +1 -1
- data/lib/ace/assign/cli/commands/finish.rb +32 -8
- data/lib/ace/assign/cli/commands/fork_run.rb +58 -16
- data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
- data/lib/ace/assign/cli/commands/list.rb +4 -3
- data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
- data/lib/ace/assign/cli/commands/start.rb +9 -3
- data/lib/ace/assign/cli/commands/status.rb +237 -230
- data/lib/ace/assign/cli/commands/step.rb +62 -0
- data/lib/ace/assign/cli.rb +8 -1
- data/lib/ace/assign/models/assignment_info.rb +33 -4
- data/lib/ace/assign/models/queue_state.rb +101 -39
- data/lib/ace/assign/models/step.rb +17 -5
- data/lib/ace/assign/molecules/fork_session_launcher.rb +218 -21
- data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
- data/lib/ace/assign/molecules/step_writer.rb +3 -3
- data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +355 -106
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -0
- metadata +35 -5
- data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +0 -42
|
@@ -12,15 +12,40 @@ module Ace
|
|
|
12
12
|
# start → advance → complete (with fail/add/retry branches)
|
|
13
13
|
class AssignmentExecutor
|
|
14
14
|
DEFAULT_DYNAMIC_STEP_INSTRUCTIONS = "Complete this step and finish with: ace-assign finish --message report.md".freeze
|
|
15
|
+
PROJECT_ROOT_SIGNAL = "project_root".freeze
|
|
16
|
+
CATALOG_SIGNAL = "catalog".freeze
|
|
15
17
|
|
|
16
18
|
attr_reader :assignment_manager, :queue_scanner, :step_writer, :step_renumberer, :skill_source_resolver
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
class << self
|
|
21
|
+
def clear_caches!
|
|
22
|
+
@cache_store = { step_catalog_cache: {} }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cache_store
|
|
26
|
+
@cache_store ||= { step_catalog_cache: {} }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def cached_value(store_key, key)
|
|
32
|
+
cache_store[store_key][key]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def store_cached_value(store_key, key, value)
|
|
36
|
+
cache_store[store_key][key] = value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil)
|
|
19
41
|
@assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
20
42
|
@queue_scanner = Molecules::QueueScanner.new
|
|
21
43
|
@step_writer = Molecules::StepWriter.new
|
|
22
|
-
@skill_source_resolver = Molecules::SkillAssignSourceResolver.new
|
|
44
|
+
@skill_source_resolver = skill_source_resolver || Molecules::SkillAssignSourceResolver.new
|
|
23
45
|
@step_catalog = nil
|
|
46
|
+
@step_catalog_from_fixture = step_catalog
|
|
47
|
+
@step_catalog_from_fixture_set = !step_catalog.nil?
|
|
48
|
+
@step_catalog_loaded = false
|
|
24
49
|
@step_renumberer = Molecules::StepRenumberer.new(
|
|
25
50
|
step_writer: @step_writer,
|
|
26
51
|
queue_scanner: @queue_scanner
|
|
@@ -73,12 +98,6 @@ module Ace
|
|
|
73
98
|
)
|
|
74
99
|
end
|
|
75
100
|
|
|
76
|
-
# Mark first workable step as in_progress.
|
|
77
|
-
# This skips batch parent containers that have incomplete children.
|
|
78
|
-
initial_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
79
|
-
first_workable = initial_state.next_workable
|
|
80
|
-
step_writer.mark_in_progress(first_workable.file_path) if first_workable
|
|
81
|
-
|
|
82
101
|
# Archive source config into task's steps directory and update assignment metadata
|
|
83
102
|
archived_path = archive_source_config(config_path, assignment.id)
|
|
84
103
|
assignment = Models::Assignment.new(
|
|
@@ -120,7 +139,6 @@ module Ace
|
|
|
120
139
|
# Start a pending step.
|
|
121
140
|
#
|
|
122
141
|
# Rules:
|
|
123
|
-
# - Fails if any step is already in progress (strict mode)
|
|
124
142
|
# - Starts an explicit pending target when provided
|
|
125
143
|
# - Otherwise starts the next workable pending step
|
|
126
144
|
#
|
|
@@ -132,7 +150,6 @@ module Ace
|
|
|
132
150
|
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
133
151
|
|
|
134
152
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
135
|
-
raise StepErrors::InvalidState, "Cannot start: step #{state.current.number} is already in progress. Finish or fail it first." if state.current
|
|
136
153
|
|
|
137
154
|
fork_root = fork_root&.strip
|
|
138
155
|
target_step = if step_number && !step_number.to_s.strip.empty?
|
|
@@ -151,7 +168,8 @@ module Ace
|
|
|
151
168
|
raise StepErrors::InvalidState, "No pending workable step found."
|
|
152
169
|
end
|
|
153
170
|
|
|
154
|
-
|
|
171
|
+
validate_start_activation!(state, target_step, fork_root: fork_root)
|
|
172
|
+
step_writer.mark_active(target_step.file_path)
|
|
155
173
|
assignment_manager.update(assignment)
|
|
156
174
|
|
|
157
175
|
new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
@@ -163,10 +181,10 @@ module Ace
|
|
|
163
181
|
}
|
|
164
182
|
end
|
|
165
183
|
|
|
166
|
-
# Finish an
|
|
184
|
+
# Finish an active step and update queue state.
|
|
167
185
|
#
|
|
168
186
|
# @param report_content [String] Completion report content
|
|
169
|
-
# @param step_number [String, nil] Optional
|
|
187
|
+
# @param step_number [String, nil] Optional active step number to finish
|
|
170
188
|
# @param fork_root [String, nil] Optional subtree root to constrain advancement
|
|
171
189
|
# @return [Hash] Result with completed step and updated state
|
|
172
190
|
def finish_step(report_content:, step_number: nil, fork_root: nil)
|
|
@@ -175,7 +193,7 @@ module Ace
|
|
|
175
193
|
|
|
176
194
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
177
195
|
current = find_target_step_for_finish(state, step_number, fork_root)
|
|
178
|
-
raise Error, "No step currently
|
|
196
|
+
raise Error, "No step currently active. Try 'ace-assign start' or 'ace-assign retry'." unless current
|
|
179
197
|
|
|
180
198
|
# Enforce hierarchy: cannot mark parent as done with incomplete children
|
|
181
199
|
if state.has_incomplete_children?(current.number)
|
|
@@ -196,18 +214,6 @@ module Ace
|
|
|
196
214
|
# Re-scan to get fresh state after auto-completions
|
|
197
215
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
198
216
|
|
|
199
|
-
fork_root = fork_root&.strip
|
|
200
|
-
# Find next step to work on using hierarchical rules.
|
|
201
|
-
# When fork_root is provided, keep advancement inside that subtree.
|
|
202
|
-
next_step = if fork_root && !fork_root.empty? && state.find_by_number(fork_root)
|
|
203
|
-
find_next_step_in_subtree(state, current.number, fork_root)
|
|
204
|
-
else
|
|
205
|
-
find_next_step(state, current.number)
|
|
206
|
-
end
|
|
207
|
-
if next_step
|
|
208
|
-
step_writer.mark_in_progress(next_step.file_path)
|
|
209
|
-
end
|
|
210
|
-
|
|
211
217
|
assignment_manager.update(assignment)
|
|
212
218
|
|
|
213
219
|
new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
@@ -219,13 +225,7 @@ module Ace
|
|
|
219
225
|
}
|
|
220
226
|
end
|
|
221
227
|
|
|
222
|
-
# Complete current step with report
|
|
223
|
-
#
|
|
224
|
-
# Legacy bridge: preserves single-call semantics for fork-run callers.
|
|
225
|
-
# Previously, advance() auto-started the next step as a side effect.
|
|
226
|
-
# The new start/finish split makes this explicit, but advance() retains
|
|
227
|
-
# the auto-start behavior for subtree entry so fork-run workflows
|
|
228
|
-
# (which call advance() with fork_root) continue to work unchanged.
|
|
228
|
+
# Complete current step with report content from a file.
|
|
229
229
|
#
|
|
230
230
|
# @param report_path [String] Path to report file
|
|
231
231
|
# @param fork_root [String, nil] Optional subtree root to constrain advancement
|
|
@@ -233,26 +233,6 @@ module Ace
|
|
|
233
233
|
def advance(report_path, fork_root: nil)
|
|
234
234
|
raise ConfigErrors::NotFound, "Report file not found: #{report_path}" unless File.exist?(report_path)
|
|
235
235
|
|
|
236
|
-
# Auto-start the next workable subtree step when fork_root is given but
|
|
237
|
-
# no step in the subtree is yet in_progress (subtree entry case).
|
|
238
|
-
fork_root_str = fork_root&.strip
|
|
239
|
-
if fork_root_str && !fork_root_str.empty?
|
|
240
|
-
assignment = assignment_manager.find_active
|
|
241
|
-
if assignment
|
|
242
|
-
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
243
|
-
active_in_subtree = state.in_progress_in_subtree(fork_root_str)
|
|
244
|
-
if active_in_subtree.size > 1
|
|
245
|
-
active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
|
|
246
|
-
raise StepErrors::InvalidState, "Cannot advance subtree #{fork_root_str}: multiple steps are in progress (#{active_refs})."
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
if active_in_subtree.empty?
|
|
250
|
-
next_workable = state.next_workable_in_subtree(fork_root_str)
|
|
251
|
-
step_writer.mark_in_progress(next_workable.file_path) if next_workable
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
end
|
|
255
|
-
|
|
256
236
|
finish_step(report_content: File.read(report_path), fork_root: fork_root)
|
|
257
237
|
end
|
|
258
238
|
|
|
@@ -260,13 +240,13 @@ module Ace
|
|
|
260
240
|
#
|
|
261
241
|
# @param message [String] Error message
|
|
262
242
|
# @return [Hash] Result with updated state
|
|
263
|
-
def fail(message)
|
|
243
|
+
def fail(message, fork_root: nil)
|
|
264
244
|
assignment = assignment_manager.find_active
|
|
265
245
|
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
266
246
|
|
|
267
247
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
268
|
-
current = state
|
|
269
|
-
raise Error, "No step currently
|
|
248
|
+
current = find_target_step_for_fail(state, fork_root)
|
|
249
|
+
raise Error, "No step currently active. Try 'ace-assign add' to add a new step or 'ace-assign retry' to retry a failed step." unless current
|
|
270
250
|
|
|
271
251
|
# Mark step as failed
|
|
272
252
|
step_writer.mark_failed(current.file_path, error_message: message)
|
|
@@ -290,7 +270,7 @@ module Ace
|
|
|
290
270
|
# @param after [String, nil] Insert after this step number (optional)
|
|
291
271
|
# @param as_child [Boolean] Insert as child of 'after' step (default: false, sibling)
|
|
292
272
|
# @return [Hash] Result with new step
|
|
293
|
-
def add(name, instructions, after: nil, as_child: false, added_by: nil, extra: {})
|
|
273
|
+
def add(name, instructions, after: nil, as_child: false, added_by: nil, extra: {}, fork_root: nil)
|
|
294
274
|
assignment = assignment_manager.find_active
|
|
295
275
|
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
296
276
|
|
|
@@ -298,6 +278,7 @@ module Ace
|
|
|
298
278
|
raise Error, "Step name cannot be empty." if step_name.empty?
|
|
299
279
|
|
|
300
280
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
281
|
+
after, as_child = normalize_scoped_insertion_anchor(state, after: after, as_child: as_child, fork_root: fork_root)
|
|
301
282
|
existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
|
|
302
283
|
|
|
303
284
|
# Validate --after step exists
|
|
@@ -319,9 +300,6 @@ module Ace
|
|
|
319
300
|
queue_scanner.step_numbers(assignment.steps_dir)
|
|
320
301
|
end
|
|
321
302
|
|
|
322
|
-
# Determine initial status upfront to avoid redundant I/O
|
|
323
|
-
initial_status = state.current ? :pending : :in_progress
|
|
324
|
-
|
|
325
303
|
# Build added_by metadata for audit trail
|
|
326
304
|
added_by ||= if after && as_child
|
|
327
305
|
"child_of:#{after}"
|
|
@@ -339,7 +317,7 @@ module Ace
|
|
|
339
317
|
number: new_number,
|
|
340
318
|
name: step_name,
|
|
341
319
|
instructions: instructions,
|
|
342
|
-
status:
|
|
320
|
+
status: :pending,
|
|
343
321
|
added_by: added_by,
|
|
344
322
|
parent: as_child ? after : nil,
|
|
345
323
|
extra: extra_frontmatter
|
|
@@ -371,7 +349,7 @@ module Ace
|
|
|
371
349
|
# @note Structural validation is performed for the full batch before any writes.
|
|
372
350
|
# Runtime I/O failures can still interrupt insertion after partial writes.
|
|
373
351
|
# @return [Hash] Result with added steps and final state
|
|
374
|
-
def add_batch(steps:, after: nil, as_child: false, source_file: nil)
|
|
352
|
+
def add_batch(steps:, after: nil, as_child: false, source_file: nil, fork_root: nil)
|
|
375
353
|
unless steps.is_a?(Array) && steps.any?
|
|
376
354
|
source_label = source_file.to_s.strip.empty? ? "batch input" : source_file
|
|
377
355
|
raise Error, "No steps defined in #{source_label}"
|
|
@@ -383,6 +361,12 @@ module Ace
|
|
|
383
361
|
|
|
384
362
|
prevalidate_batch_trees!(steps)
|
|
385
363
|
|
|
364
|
+
assignment = assignment_manager.find_active
|
|
365
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
366
|
+
|
|
367
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
368
|
+
after, as_child = normalize_scoped_insertion_anchor(state, after: after, as_child: as_child, fork_root: fork_root)
|
|
369
|
+
|
|
386
370
|
added_steps = []
|
|
387
371
|
renumbered = []
|
|
388
372
|
sibling_cursor = after
|
|
@@ -400,7 +384,6 @@ module Ace
|
|
|
400
384
|
sibling_cursor = inserted[:root_number] unless as_child
|
|
401
385
|
end
|
|
402
386
|
|
|
403
|
-
assignment = assignment_manager.find_active
|
|
404
387
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
405
388
|
{
|
|
406
389
|
assignment: assignment,
|
|
@@ -414,7 +397,7 @@ module Ace
|
|
|
414
397
|
#
|
|
415
398
|
# @param step_ref [String] Step number or reference to retry
|
|
416
399
|
# @return [Hash] Result with new retry step
|
|
417
|
-
def retry_step(step_ref)
|
|
400
|
+
def retry_step(step_ref, fork_root: nil)
|
|
418
401
|
assignment = assignment_manager.find_active
|
|
419
402
|
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
420
403
|
|
|
@@ -423,16 +406,18 @@ module Ace
|
|
|
423
406
|
# Find the step to retry
|
|
424
407
|
original = state.find_by_number(step_ref.to_s)
|
|
425
408
|
raise StepErrors::NotFound, "Step #{step_ref} not found in queue" unless original
|
|
409
|
+
validate_retry_scope!(state, original, fork_root)
|
|
426
410
|
|
|
427
411
|
# Get existing numbers
|
|
428
412
|
existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
|
|
429
413
|
|
|
430
414
|
# Insert after all current steps (at end of queue before pending)
|
|
431
415
|
# Find last done or failed step
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
416
|
+
scoped_state = scoped_retry_state(state, assignment: assignment, fork_root: fork_root)
|
|
417
|
+
base_number = if scoped_state.current
|
|
418
|
+
scoped_state.current.number
|
|
419
|
+
elsif scoped_state.last_done
|
|
420
|
+
scoped_state.last_done.number
|
|
436
421
|
else
|
|
437
422
|
original.number
|
|
438
423
|
end
|
|
@@ -573,7 +558,10 @@ module Ace
|
|
|
573
558
|
# @param sub_steps [Array<String>] Declared sub-step names
|
|
574
559
|
# @return [Hash] Parent step config for runtime queue
|
|
575
560
|
def build_split_parent_step(step:, parent_number:, parent_context:, sub_steps:)
|
|
576
|
-
source_skill = step["skill"]
|
|
561
|
+
source_skill = step["source_skill"] || step["skill"]
|
|
562
|
+
if (source_skill.nil? || source_skill.to_s.strip.empty?) && step["source"].to_s.start_with?("skill://")
|
|
563
|
+
source_skill = step["source"].to_s.delete_prefix("skill://").strip
|
|
564
|
+
end
|
|
577
565
|
original_text = normalize_instructions(step["instructions"]).strip
|
|
578
566
|
definition = find_step_definition("split-subtree-root") || {}
|
|
579
567
|
|
|
@@ -600,6 +588,7 @@ module Ace
|
|
|
600
588
|
)
|
|
601
589
|
parent_step.delete("sub_steps")
|
|
602
590
|
parent_step.delete("sub-steps")
|
|
591
|
+
parent_step.delete("source")
|
|
603
592
|
parent_step.delete("skill")
|
|
604
593
|
parent_step.delete("workflow")
|
|
605
594
|
parent_step["source_skill"] = source_skill if source_skill
|
|
@@ -652,9 +641,11 @@ module Ace
|
|
|
652
641
|
if parent_context == "fork"
|
|
653
642
|
lines.concat(
|
|
654
643
|
[
|
|
655
|
-
"
|
|
644
|
+
"Unscoped driver action: delegate this subtree into forked context:",
|
|
656
645
|
"- ace-assign fork-run --assignment <assignment-id>@{{parent_number}}",
|
|
657
|
-
"
|
|
646
|
+
"Scoped forked agent action: do not call fork-run again for {{parent_number}}.",
|
|
647
|
+
"If this scoped root is active and no child step is active yet: ace-assign start --assignment <assignment-id>@{{parent_number}}",
|
|
648
|
+
"After a child step becomes active, continue execution within this subtree scope only."
|
|
658
649
|
]
|
|
659
650
|
)
|
|
660
651
|
else
|
|
@@ -712,6 +703,13 @@ module Ace
|
|
|
712
703
|
child["workflow"] = step_def["workflow"] if step_def["workflow"]
|
|
713
704
|
preserve_explicit_skill = (sub_steps_origin == "explicit")
|
|
714
705
|
child["skill"] = step_def["skill"] if step_def["skill"] && (preserve_explicit_skill || !step_def["workflow"])
|
|
706
|
+
child["source"] = if step_def["source"]
|
|
707
|
+
step_def["source"]
|
|
708
|
+
elsif step_def["workflow"]
|
|
709
|
+
step_def["workflow"]
|
|
710
|
+
elsif step_def["skill"]
|
|
711
|
+
"skill://#{step_def["skill"]}"
|
|
712
|
+
end
|
|
715
713
|
|
|
716
714
|
context_default = step_def.dig("context", "default")
|
|
717
715
|
child["context"] = context_default if context_default && !fork_context_value?(parent_context)
|
|
@@ -811,14 +809,9 @@ module Ace
|
|
|
811
809
|
when "pre-commit-review"
|
|
812
810
|
pre_commit_review_action_instructions(task_hint: task_hint)
|
|
813
811
|
when "verify-test"
|
|
814
|
-
"- Identify modified packages#{task_hint}.\n- For each modified package, run: cd <package> && ace-test --profile 6\n- If no package-level code changes are present, mark this step skipped with a clear reason."
|
|
812
|
+
"- Identify modified packages#{task_hint}.\n- For each modified package, run: cd <package> && ace-test all --profile 6\n- This subtree step verifies modified packages only; do not run the monorepo suite here.\n- If no package-level code changes are present, mark this step skipped with a clear reason."
|
|
815
813
|
when /\Arelease(?:-.+)?\z/
|
|
816
814
|
"- Release all modified packages and update both package and root changelogs.\n- Follow semantic versioning expectations for this step.\n- When auto-detecting packages, include `git diff origin/main...HEAD --name-only` in addition to working-tree state — prior steps may have already committed changes."
|
|
817
|
-
when "verify-e2e"
|
|
818
|
-
"- Check change scope: run `git diff origin/main --name-only` to list modified files.\n" \
|
|
819
|
-
"- **Skip criteria**: If ALL modified files match `*.md`, `*.yml` (non-CI config), `.ace-tasks/**`, or `.ace-retros/**`, skip E2E verification — mark step done with \"skipped: docs/task-spec only changes, no runnable code affected\".\n" \
|
|
820
|
-
"- Otherwise: detect modified packages, run E2E scenarios for each package with `test/e2e/` scenarios#{task_hint}.\n" \
|
|
821
|
-
"- If no modified package has E2E scenarios, mark step done with \"skipped: no E2E scenarios for modified packages\"."
|
|
822
815
|
else
|
|
823
816
|
"- Execute the #{sub_name} step."
|
|
824
817
|
end
|
|
@@ -861,6 +854,8 @@ module Ace
|
|
|
861
854
|
"instructions" => rendered_instructions,
|
|
862
855
|
"workflow" => rendering["workflow"]
|
|
863
856
|
)
|
|
857
|
+
resolved_source = resolved_step_source(step, rendering)
|
|
858
|
+
materialized["source"] = resolved_source if resolved_source && !resolved_source.empty?
|
|
864
859
|
unless split_child_without_explicit_fork?(step)
|
|
865
860
|
context_default = rendering.dig("context", "default")
|
|
866
861
|
materialized["context"] ||= context_default if context_default
|
|
@@ -879,6 +874,24 @@ module Ace
|
|
|
879
874
|
end
|
|
880
875
|
|
|
881
876
|
def resolve_step_rendering(step)
|
|
877
|
+
explicit_source = step["source"]&.to_s&.strip
|
|
878
|
+
if explicit_source && !explicit_source.empty?
|
|
879
|
+
canonical_step = find_step_definition_with_source_fallback(step, explicit_source: explicit_source)
|
|
880
|
+
if canonical_step && split_child_without_explicit_fork?(step)
|
|
881
|
+
canonical_step = canonical_step.dup
|
|
882
|
+
canonical_step.delete("context")
|
|
883
|
+
canonical_step.delete("fork")
|
|
884
|
+
end
|
|
885
|
+
source_skill = step["source_skill"]&.to_s&.strip
|
|
886
|
+
source_skill = canonical_step&.dig("source_skill") if source_skill.nil? || source_skill.empty?
|
|
887
|
+
rendering = skill_source_resolver.resolve_source_rendering(
|
|
888
|
+
explicit_source,
|
|
889
|
+
step_name: step["name"]&.to_s,
|
|
890
|
+
source_skill: source_skill
|
|
891
|
+
)
|
|
892
|
+
return canonical_step ? canonical_step.merge(rendering || {}) : rendering if rendering
|
|
893
|
+
end
|
|
894
|
+
|
|
882
895
|
explicit_workflow = step["workflow"]&.to_s&.strip
|
|
883
896
|
if explicit_workflow && !explicit_workflow.empty?
|
|
884
897
|
canonical_step = find_step_definition(step["name"]&.to_s)
|
|
@@ -1039,6 +1052,15 @@ module Ace
|
|
|
1039
1052
|
end
|
|
1040
1053
|
|
|
1041
1054
|
def resolve_step_assign_config(step)
|
|
1055
|
+
source_ref = step["source"]&.to_s&.strip
|
|
1056
|
+
if source_ref && !source_ref.empty?
|
|
1057
|
+
return skill_source_resolver.resolve_source_assign_config(
|
|
1058
|
+
source_ref,
|
|
1059
|
+
step_name: step["name"]&.to_s,
|
|
1060
|
+
source_skill: step["source_skill"]&.to_s
|
|
1061
|
+
)
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1042
1064
|
explicit_workflow = step["workflow"]&.to_s&.strip
|
|
1043
1065
|
if explicit_workflow && !explicit_workflow.empty?
|
|
1044
1066
|
return skill_source_resolver.resolve_workflow_assign_config(
|
|
@@ -1119,30 +1141,120 @@ module Ace
|
|
|
1119
1141
|
Atoms::CatalogLoader.find_by_name(step_catalog, step_name)
|
|
1120
1142
|
end
|
|
1121
1143
|
|
|
1144
|
+
def find_step_definition_with_source_fallback(step, explicit_source:)
|
|
1145
|
+
step_name = step["name"]&.to_s
|
|
1146
|
+
canonical_step = find_step_definition(step_name)
|
|
1147
|
+
return canonical_step if canonical_step
|
|
1148
|
+
|
|
1149
|
+
source = explicit_source.to_s.strip
|
|
1150
|
+
return nil if source.empty?
|
|
1151
|
+
|
|
1152
|
+
source_skill = step["source_skill"]&.to_s&.strip
|
|
1153
|
+
source_skill = source.delete_prefix("skill://").strip if source_skill.to_s.empty? && source.start_with?("skill://")
|
|
1154
|
+
|
|
1155
|
+
step_catalog.find do |entry|
|
|
1156
|
+
next unless entry.is_a?(Hash)
|
|
1157
|
+
|
|
1158
|
+
entry_source = entry["source"]&.to_s&.strip
|
|
1159
|
+
entry_workflow = entry["workflow"]&.to_s&.strip
|
|
1160
|
+
entry_source_skill = entry["source_skill"]&.to_s&.strip
|
|
1161
|
+
entry_skill = entry["skill"]&.to_s&.strip
|
|
1162
|
+
|
|
1163
|
+
next true if entry_source == source || entry_workflow == source
|
|
1164
|
+
next true if !source_skill.to_s.empty? && (entry_source_skill == source_skill || entry_skill == source_skill)
|
|
1165
|
+
|
|
1166
|
+
false
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1122
1170
|
# Load step catalog from project override or gem defaults.
|
|
1123
1171
|
#
|
|
1124
1172
|
# @return [Array<Hash>] Loaded step definitions
|
|
1125
1173
|
def step_catalog
|
|
1126
|
-
@step_catalog
|
|
1127
|
-
project_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
1128
|
-
gem_root = Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
|
|
1174
|
+
return @step_catalog if @step_catalog_loaded
|
|
1129
1175
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1176
|
+
if @step_catalog_from_fixture_set
|
|
1177
|
+
@step_catalog_loaded = true
|
|
1178
|
+
@step_catalog = @step_catalog_from_fixture
|
|
1179
|
+
return @step_catalog
|
|
1180
|
+
end
|
|
1132
1181
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1182
|
+
cached = self.class.send(:cached_value, :step_catalog_cache, step_catalog_signature)
|
|
1183
|
+
return @step_catalog = cached if cached
|
|
1184
|
+
|
|
1185
|
+
@step_catalog_loaded = true
|
|
1186
|
+
@step_catalog = load_step_catalog
|
|
1187
|
+
self.class.send(:store_cached_value, :step_catalog_cache, step_catalog_signature, @step_catalog)
|
|
1188
|
+
@step_catalog
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
def step_catalog_signature
|
|
1192
|
+
[
|
|
1193
|
+
PROJECT_ROOT_SIGNAL,
|
|
1194
|
+
project_catalog_signature,
|
|
1195
|
+
default_catalog_signature,
|
|
1196
|
+
step_catalog_cache_token,
|
|
1197
|
+
CATALOG_SIGNAL
|
|
1198
|
+
].join("|")
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def project_catalog_signature
|
|
1202
|
+
@project_catalog_signature ||= catalog_signature(File.join(project_root, ".ace", "assign", "catalog", "steps"))
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
def default_catalog_signature
|
|
1206
|
+
@default_catalog_signature ||= catalog_signature(File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps"))
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
def load_step_catalog
|
|
1210
|
+
project_catalog = File.join(project_root, ".ace", "assign", "catalog", "steps")
|
|
1211
|
+
default_catalog = File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps")
|
|
1212
|
+
|
|
1213
|
+
canonical_steps = @skill_source_resolver.assign_step_catalog
|
|
1214
|
+
default_steps = Atoms::CatalogLoader.load_all(default_catalog, canonical_steps: false)
|
|
1215
|
+
base_catalog = merge_step_catalog(default_steps, canonical_steps)
|
|
1140
1216
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1217
|
+
if File.directory?(project_catalog)
|
|
1218
|
+
project_steps = Atoms::CatalogLoader.load_all(project_catalog, canonical_steps: false)
|
|
1219
|
+
merge_step_catalog(base_catalog, project_steps)
|
|
1220
|
+
else
|
|
1221
|
+
base_catalog
|
|
1143
1222
|
end
|
|
1144
1223
|
end
|
|
1145
1224
|
|
|
1225
|
+
def catalog_signature(catalog_dir)
|
|
1226
|
+
return "missing" unless File.directory?(catalog_dir)
|
|
1227
|
+
|
|
1228
|
+
Dir.glob(File.join(catalog_dir, "*.step.yml")).sort.map do |path|
|
|
1229
|
+
"#{path}:#{file_signature(path)}"
|
|
1230
|
+
end.join("|")
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
def file_signature(path)
|
|
1234
|
+
stat = File.stat(path)
|
|
1235
|
+
"#{stat.mtime.to_f}:#{stat.size}"
|
|
1236
|
+
rescue
|
|
1237
|
+
"missing"
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
def step_catalog_cache_token
|
|
1241
|
+
token = if @skill_source_resolver.respond_to?(:cache_signature)
|
|
1242
|
+
@skill_source_resolver.cache_signature
|
|
1243
|
+
else
|
|
1244
|
+
"resolver:#{@skill_source_resolver.object_id}"
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
"resolver:#{token}"
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
def project_root
|
|
1251
|
+
@project_root ||= Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
def gem_root
|
|
1255
|
+
@gem_root ||= Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1146
1258
|
# Merge default and project step catalogs by step name.
|
|
1147
1259
|
# Later definitions override earlier ones with matching names.
|
|
1148
1260
|
#
|
|
@@ -1178,6 +1290,11 @@ module Ace
|
|
|
1178
1290
|
|
|
1179
1291
|
merged = base.dup
|
|
1180
1292
|
override.each do |key, value|
|
|
1293
|
+
if runtime_binding_override_key?(key, base, override)
|
|
1294
|
+
merged[key] = base[key]
|
|
1295
|
+
next
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1181
1298
|
merged[key] =
|
|
1182
1299
|
if merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
1183
1300
|
deep_merge_step_definition(merged[key], value)
|
|
@@ -1188,6 +1305,33 @@ module Ace
|
|
|
1188
1305
|
merged
|
|
1189
1306
|
end
|
|
1190
1307
|
|
|
1308
|
+
def runtime_binding_override_key?(key, base, override)
|
|
1309
|
+
return false unless %w[source workflow skill source_skill].include?(key)
|
|
1310
|
+
return false unless local_runtime_binding_present?(base)
|
|
1311
|
+
canonical_binding_present?(override)
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
def local_runtime_binding_present?(entry)
|
|
1315
|
+
entry.is_a?(Hash) && (
|
|
1316
|
+
present_string?(entry["source"]) ||
|
|
1317
|
+
present_string?(entry["workflow"]) ||
|
|
1318
|
+
present_string?(entry["skill"])
|
|
1319
|
+
)
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def canonical_binding_present?(entry)
|
|
1323
|
+
entry.is_a?(Hash) && (
|
|
1324
|
+
present_string?(entry["source"]) ||
|
|
1325
|
+
present_string?(entry["workflow"]) ||
|
|
1326
|
+
present_string?(entry["skill"]) ||
|
|
1327
|
+
present_string?(entry["source_skill"])
|
|
1328
|
+
)
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
def present_string?(value)
|
|
1332
|
+
value.is_a?(String) && !value.strip.empty?
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1191
1335
|
# Archive source config into the task's jobs/ directory.
|
|
1192
1336
|
# If config is already in a jobs/ or steps/ directory, keeps it in place.
|
|
1193
1337
|
# Otherwise moves job.yaml to <task>/jobs/<assignment_id>-job.yml for provenance.
|
|
@@ -1212,13 +1356,11 @@ module Ace
|
|
|
1212
1356
|
end
|
|
1213
1357
|
|
|
1214
1358
|
def rebalance_after_child_injection(assignment:, state:, parent_number:)
|
|
1215
|
-
|
|
1216
|
-
return unless
|
|
1359
|
+
parent = state.find_by_number(parent_number)
|
|
1360
|
+
return unless parent&.status == :active
|
|
1361
|
+
return if parent.fork?
|
|
1217
1362
|
|
|
1218
|
-
step_writer.mark_pending(
|
|
1219
|
-
rebalanced_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
1220
|
-
next_step = rebalanced_state.next_workable_in_subtree(parent_number)
|
|
1221
|
-
step_writer.mark_in_progress(next_step.file_path) if next_step
|
|
1363
|
+
step_writer.mark_pending(parent.file_path)
|
|
1222
1364
|
end
|
|
1223
1365
|
|
|
1224
1366
|
# Normalize instructions to a string.
|
|
@@ -1281,10 +1423,27 @@ module Ace
|
|
|
1281
1423
|
def canonical_batch_insert_requested?(step_config)
|
|
1282
1424
|
raw_sub_steps = step_config["sub_steps"] || step_config["sub-steps"]
|
|
1283
1425
|
has_declared_sub_steps = raw_sub_steps.is_a?(Array) && raw_sub_steps.any?
|
|
1426
|
+
has_source = !step_config["source"].to_s.strip.empty?
|
|
1284
1427
|
has_workflow = !step_config["workflow"].to_s.strip.empty?
|
|
1285
1428
|
has_skill = !step_config["skill"].to_s.strip.empty?
|
|
1286
1429
|
|
|
1287
|
-
has_declared_sub_steps || has_workflow || has_skill
|
|
1430
|
+
has_declared_sub_steps || has_source || has_workflow || has_skill
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
def resolved_step_source(step, rendering)
|
|
1434
|
+
explicit_source = step["source"]&.to_s&.strip
|
|
1435
|
+
return explicit_source unless explicit_source.nil? || explicit_source.empty?
|
|
1436
|
+
|
|
1437
|
+
rendered_source = rendering["source"]&.to_s&.strip
|
|
1438
|
+
return rendered_source unless rendered_source.nil? || rendered_source.empty?
|
|
1439
|
+
|
|
1440
|
+
workflow_source = rendering["workflow"]&.to_s&.strip
|
|
1441
|
+
return workflow_source unless workflow_source.nil? || workflow_source.empty?
|
|
1442
|
+
|
|
1443
|
+
skill_name = rendering["skill"]&.to_s&.strip
|
|
1444
|
+
return nil if skill_name.nil? || skill_name.empty?
|
|
1445
|
+
|
|
1446
|
+
"skill://#{skill_name}"
|
|
1288
1447
|
end
|
|
1289
1448
|
|
|
1290
1449
|
def insert_canonical_batch_step_tree(step_config, after:, as_child:, added_by:, location:)
|
|
@@ -1515,6 +1674,28 @@ module Ace
|
|
|
1515
1674
|
DEFAULT_DYNAMIC_STEP_INSTRUCTIONS
|
|
1516
1675
|
end
|
|
1517
1676
|
|
|
1677
|
+
def targeted_batch_parent_startable?(target, fork_root:)
|
|
1678
|
+
return false unless fork_root.nil? || fork_root.empty?
|
|
1679
|
+
|
|
1680
|
+
target.batch_parent == true
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1683
|
+
def find_target_step_for_fail(state, fork_root)
|
|
1684
|
+
fork_root = fork_root&.strip
|
|
1685
|
+
current = state.current
|
|
1686
|
+
return current if fork_root.nil? || fork_root.empty?
|
|
1687
|
+
|
|
1688
|
+
raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
|
|
1689
|
+
if state.active_branch_conflict_in_subtree?(fork_root)
|
|
1690
|
+
active_refs = state.active_in_subtree(fork_root).map { |step| "#{step.number}(#{step.name})" }.join(", ")
|
|
1691
|
+
raise StepErrors::InvalidState, "Cannot fail in subtree #{fork_root}: multiple active branches exist (#{active_refs})."
|
|
1692
|
+
end
|
|
1693
|
+
|
|
1694
|
+
return current if current && state.in_subtree?(fork_root, current.number)
|
|
1695
|
+
|
|
1696
|
+
state.current_in_subtree(fork_root)
|
|
1697
|
+
end
|
|
1698
|
+
|
|
1518
1699
|
def find_target_step_for_start(state, step_number, fork_root)
|
|
1519
1700
|
target = state.find_by_number(step_number)
|
|
1520
1701
|
raise StepErrors::NotFound, "Step #{step_number} not found in queue" unless target
|
|
@@ -1524,13 +1705,40 @@ module Ace
|
|
|
1524
1705
|
raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}." unless state.in_subtree?(fork_root, target.number)
|
|
1525
1706
|
end
|
|
1526
1707
|
raise StepErrors::InvalidState, "Cannot start step #{target.number}: status is #{target.status}, expected pending." unless target.status == :pending
|
|
1527
|
-
if state.has_incomplete_children?(target.number)
|
|
1708
|
+
if state.has_incomplete_children?(target.number) && !targeted_batch_parent_startable?(target, fork_root: fork_root)
|
|
1528
1709
|
raise StepErrors::InvalidState, "Cannot start step #{target.number}: has incomplete children."
|
|
1529
1710
|
end
|
|
1530
1711
|
|
|
1531
1712
|
target
|
|
1532
1713
|
end
|
|
1533
1714
|
|
|
1715
|
+
def validate_start_activation!(state, target, fork_root:)
|
|
1716
|
+
root_ref = fork_root if fork_root && !fork_root.empty?
|
|
1717
|
+
active_fork_root = if root_ref
|
|
1718
|
+
state.find_by_number(root_ref)
|
|
1719
|
+
else
|
|
1720
|
+
state.nearest_fork_ancestor(target.number)
|
|
1721
|
+
end
|
|
1722
|
+
|
|
1723
|
+
if active_fork_root &&
|
|
1724
|
+
target.number != active_fork_root.number &&
|
|
1725
|
+
active_fork_root.status != :active
|
|
1726
|
+
raise StepErrors::InvalidState,
|
|
1727
|
+
"Cannot start step #{target.number}: fork root #{active_fork_root.number} is not active."
|
|
1728
|
+
end
|
|
1729
|
+
|
|
1730
|
+
return unless active_fork_root
|
|
1731
|
+
|
|
1732
|
+
if state.active_branch_conflict_in_subtree?(active_fork_root.number, extra_active: [target.number])
|
|
1733
|
+
active_refs = (
|
|
1734
|
+
state.active_in_subtree(active_fork_root.number).map { |step| "#{step.number}(#{step.name})" } +
|
|
1735
|
+
["#{target.number}(#{target.name})"]
|
|
1736
|
+
).uniq.join(", ")
|
|
1737
|
+
raise StepErrors::InvalidState,
|
|
1738
|
+
"Cannot start step #{target.number}: subtree #{active_fork_root.number} already has multiple active branches (#{active_refs})."
|
|
1739
|
+
end
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1534
1742
|
def find_target_step_for_finish(state, step_number, fork_root)
|
|
1535
1743
|
fork_root = fork_root&.strip
|
|
1536
1744
|
if step_number && !step_number.to_s.strip.empty?
|
|
@@ -1539,7 +1747,7 @@ module Ace
|
|
|
1539
1747
|
if fork_root && !fork_root.empty? && !state.in_subtree?(fork_root, target.number)
|
|
1540
1748
|
raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}."
|
|
1541
1749
|
end
|
|
1542
|
-
raise StepErrors::InvalidState, "Cannot finish step #{target.number}: status is #{target.status}, expected
|
|
1750
|
+
raise StepErrors::InvalidState, "Cannot finish step #{target.number}: status is #{target.status}, expected active." unless target.status == :active
|
|
1543
1751
|
|
|
1544
1752
|
return target
|
|
1545
1753
|
end
|
|
@@ -1547,10 +1755,9 @@ module Ace
|
|
|
1547
1755
|
current = state.current
|
|
1548
1756
|
if fork_root && !fork_root.empty?
|
|
1549
1757
|
raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
raise StepErrors::InvalidState, "Cannot finish in subtree #{fork_root}: multiple steps are in progress (#{active_refs})."
|
|
1758
|
+
if state.active_branch_conflict_in_subtree?(fork_root)
|
|
1759
|
+
active_refs = state.active_in_subtree(fork_root).map { |step| "#{step.number}(#{step.name})" }.join(", ")
|
|
1760
|
+
raise StepErrors::InvalidState, "Cannot finish in subtree #{fork_root}: multiple active branches exist (#{active_refs})."
|
|
1554
1761
|
end
|
|
1555
1762
|
if current.nil? || !state.in_subtree?(fork_root, current.number)
|
|
1556
1763
|
current = state.current_in_subtree(fork_root)
|
|
@@ -1561,6 +1768,48 @@ module Ace
|
|
|
1561
1768
|
current
|
|
1562
1769
|
end
|
|
1563
1770
|
|
|
1771
|
+
def scoped_retry_state(state, assignment:, fork_root:)
|
|
1772
|
+
fork_root = fork_root&.strip
|
|
1773
|
+
return state if fork_root.nil? || fork_root.empty?
|
|
1774
|
+
|
|
1775
|
+
scoped_steps = state.subtree_steps(fork_root)
|
|
1776
|
+
Models::QueueState.new(steps: scoped_steps, assignment: assignment)
|
|
1777
|
+
end
|
|
1778
|
+
|
|
1779
|
+
def validate_retry_scope!(state, original, fork_root)
|
|
1780
|
+
fork_root = fork_root&.strip
|
|
1781
|
+
return if fork_root.nil? || fork_root.empty?
|
|
1782
|
+
|
|
1783
|
+
raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
|
|
1784
|
+
return if state.in_subtree?(fork_root, original.number)
|
|
1785
|
+
|
|
1786
|
+
raise StepErrors::InvalidState, "Step #{original.number} is outside scoped subtree #{fork_root}."
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
def normalize_scoped_insertion_anchor(state, after:, as_child:, fork_root:)
|
|
1790
|
+
fork_root = fork_root&.strip
|
|
1791
|
+
return [after, as_child] if fork_root.nil? || fork_root.empty?
|
|
1792
|
+
|
|
1793
|
+
root = state.find_by_number(fork_root)
|
|
1794
|
+
raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless root
|
|
1795
|
+
|
|
1796
|
+
after_ref = after&.to_s&.strip
|
|
1797
|
+
if after_ref && !after_ref.empty?
|
|
1798
|
+
unless state.in_subtree?(fork_root, after_ref)
|
|
1799
|
+
raise StepErrors::InvalidState, "Step #{after_ref} is outside scoped subtree #{fork_root}."
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
return [after_ref, as_child]
|
|
1803
|
+
end
|
|
1804
|
+
|
|
1805
|
+
subtree = state.subtree_steps(fork_root)
|
|
1806
|
+
last_subtree_step = subtree.last
|
|
1807
|
+
return [fork_root, true] unless last_subtree_step
|
|
1808
|
+
return [fork_root, true] if last_subtree_step.number == fork_root
|
|
1809
|
+
|
|
1810
|
+
[last_subtree_step.number, false]
|
|
1811
|
+
end
|
|
1812
|
+
|
|
1564
1813
|
# Auto-complete parent steps when all their children are done.
|
|
1565
1814
|
# Walks up the hierarchy marking parents as done, handling multi-level
|
|
1566
1815
|
# completion in a single pass (grandparents become eligible when parents complete).
|
|
@@ -1583,9 +1832,9 @@ module Ace
|
|
|
1583
1832
|
iterations += 1
|
|
1584
1833
|
completed_any = false
|
|
1585
1834
|
|
|
1586
|
-
# Find all pending/
|
|
1835
|
+
# Find all pending/active parent steps that have children
|
|
1587
1836
|
eligible_parents = state.steps.select do |s|
|
|
1588
|
-
(s.status == :pending || s.status == :
|
|
1837
|
+
(s.status == :pending || s.status == :active) &&
|
|
1589
1838
|
!completed_this_pass.include?(s.number)
|
|
1590
1839
|
end
|
|
1591
1840
|
|