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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
  4. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
  6. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
  7. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
  8. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
  9. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
  10. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
  11. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
  12. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
  13. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
  14. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
  15. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
  16. data/.ace-defaults/assign/config.yml +1 -0
  17. data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
  18. data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
  19. data/.ace-defaults/assign/presets/work-on-task.yml +6 -16
  20. data/CHANGELOG.md +216 -0
  21. data/README.md +20 -43
  22. data/docs/demo/canonical-skill-source.gif +0 -0
  23. data/docs/demo/canonical-skill-source.tape.yml +51 -0
  24. data/docs/demo/fork-provider.cast +834 -0
  25. data/docs/demo/fork-provider.gif +0 -0
  26. data/docs/demo/fork-provider.recording.json +30 -0
  27. data/docs/demo/fork-provider.tape.yml +77 -20
  28. data/docs/getting-started.md +5 -2
  29. data/docs/usage.md +74 -4
  30. data/handbook/guides/fork-context.g.md +31 -7
  31. data/handbook/skills/as-assign-drive/SKILL.md +13 -1
  32. data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
  33. data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
  34. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
  35. data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
  36. data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
  37. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
  38. data/handbook/workflow-instructions/assign/create.wf.md +6 -3
  39. data/handbook/workflow-instructions/assign/drive.wf.md +330 -40
  40. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
  41. data/handbook/workflow-instructions/assign/prepare.wf.md +10 -5
  42. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
  43. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
  44. data/handbook/workflow-instructions/assign/start.wf.md +5 -2
  45. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
  46. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
  47. data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
  48. data/lib/ace/assign/atoms/preset_expander.rb +4 -0
  49. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  50. data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
  51. data/lib/ace/assign/cli/commands/add.rb +20 -11
  52. data/lib/ace/assign/cli/commands/assignment_target.rb +87 -3
  53. data/lib/ace/assign/cli/commands/create.rb +1 -1
  54. data/lib/ace/assign/cli/commands/fail.rb +1 -1
  55. data/lib/ace/assign/cli/commands/finish.rb +32 -8
  56. data/lib/ace/assign/cli/commands/fork_run.rb +58 -16
  57. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  58. data/lib/ace/assign/cli/commands/list.rb +4 -3
  59. data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
  60. data/lib/ace/assign/cli/commands/start.rb +9 -3
  61. data/lib/ace/assign/cli/commands/status.rb +237 -230
  62. data/lib/ace/assign/cli/commands/step.rb +62 -0
  63. data/lib/ace/assign/cli.rb +8 -1
  64. data/lib/ace/assign/models/assignment_info.rb +33 -4
  65. data/lib/ace/assign/models/queue_state.rb +101 -39
  66. data/lib/ace/assign/models/step.rb +17 -5
  67. data/lib/ace/assign/molecules/fork_session_launcher.rb +218 -21
  68. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  69. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
  70. data/lib/ace/assign/molecules/step_writer.rb +3 -3
  71. data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
  72. data/lib/ace/assign/organisms/assignment_executor.rb +355 -106
  73. data/lib/ace/assign/version.rb +1 -1
  74. data/lib/ace/assign.rb +1 -0
  75. metadata +35 -5
  76. 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
- def initialize(cache_base: nil)
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
- step_writer.mark_in_progress(target_step.file_path)
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 in-progress step and advance queue state.
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 in-progress step number to finish
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 in progress. Try 'ace-assign start' or 'ace-assign retry'." unless current
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 and advance
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.current
269
- raise Error, "No step currently in progress. Try 'ace-assign add' to add a new step or 'ace-assign retry' to retry a failed step." unless current
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: initial_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
- base_number = if state.current
433
- state.current.number
434
- elsif state.last_done
435
- state.last_done.number
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
- "Delegate this subtree into forked context:",
644
+ "Unscoped driver action: delegate this subtree into forked context:",
656
645
  "- ace-assign fork-run --assignment <assignment-id>@{{parent_number}}",
657
- "Inside the forked agent, continue execution within this subtree scope only."
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 ||= begin
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
- project_catalog = File.join(project_root, ".ace", "assign", "catalog", "steps")
1131
- default_catalog = File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps")
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
- default_steps = Atoms::CatalogLoader.load_all(default_catalog)
1134
- base_catalog = if File.directory?(project_catalog)
1135
- project_steps = Atoms::CatalogLoader.load_all(project_catalog)
1136
- merge_step_catalog(default_steps, project_steps)
1137
- else
1138
- default_steps
1139
- end
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
- canonical_steps = skill_source_resolver.assign_step_catalog
1142
- merge_step_catalog(base_catalog, canonical_steps)
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
- current = state.current
1216
- return unless current && current.number == parent_number
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(current.file_path)
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 in_progress." unless target.status == :in_progress
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
- active_in_subtree = state.in_progress_in_subtree(fork_root)
1551
- if active_in_subtree.size > 1
1552
- active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
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/in_progress parent steps that have children
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 == :in_progress) &&
1837
+ (s.status == :pending || s.status == :active) &&
1589
1838
  !completed_this_pass.include?(s.number)
1590
1839
  end
1591
1840