ace-assign 0.37.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 +7 -0
- data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
- data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
- data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
- data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
- data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
- data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
- data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
- data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
- data/.ace-defaults/assign/config.yml +48 -0
- data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
- data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
- data/.ace-defaults/assign/presets/release-only.yml +35 -0
- data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
- data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
- data/CHANGELOG.md +1415 -0
- data/README.md +87 -0
- data/Rakefile +16 -0
- data/docs/exit-codes.md +61 -0
- data/docs/getting-started.md +121 -0
- data/docs/handbook.md +40 -0
- data/docs/usage.md +224 -0
- data/exe/ace-assign +16 -0
- data/handbook/guides/fork-context.g.md +231 -0
- data/handbook/skills/as-assign-compose/SKILL.md +24 -0
- data/handbook/skills/as-assign-create/SKILL.md +23 -0
- data/handbook/skills/as-assign-drive/SKILL.md +24 -0
- data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
- data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
- data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
- data/handbook/skills/as-assign-start/SKILL.md +25 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
- data/handbook/workflow-instructions/assign/create.wf.md +215 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
- data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
- data/handbook/workflow-instructions/assign/start.wf.md +46 -0
- data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
- data/lib/ace/assign/atoms/composition_rules.rb +219 -0
- data/lib/ace/assign/atoms/number_generator.rb +110 -0
- data/lib/ace/assign/atoms/preset_expander.rb +277 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
- data/lib/ace/assign/atoms/step_numbering.rb +227 -0
- data/lib/ace/assign/atoms/step_sorter.rb +66 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
- data/lib/ace/assign/cli/commands/add.rb +102 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
- data/lib/ace/assign/cli/commands/create.rb +63 -0
- data/lib/ace/assign/cli/commands/fail.rb +43 -0
- data/lib/ace/assign/cli/commands/finish.rb +88 -0
- data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
- data/lib/ace/assign/cli/commands/list.rb +166 -0
- data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
- data/lib/ace/assign/cli/commands/select.rb +45 -0
- data/lib/ace/assign/cli/commands/start.rb +40 -0
- data/lib/ace/assign/cli/commands/status.rb +407 -0
- data/lib/ace/assign/cli.rb +144 -0
- data/lib/ace/assign/models/assignment.rb +107 -0
- data/lib/ace/assign/models/assignment_info.rb +66 -0
- data/lib/ace/assign/models/queue_state.rb +326 -0
- data/lib/ace/assign/models/step.rb +197 -0
- data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
- data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
- data/lib/ace/assign/molecules/step_writer.rb +246 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
- data/lib/ace/assign/version.rb +7 -0
- data/lib/ace/assign.rb +141 -0
- metadata +289 -0
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Organisms
|
|
9
|
+
# Orchestrates workflow operations on the work queue.
|
|
10
|
+
#
|
|
11
|
+
# Implements the state machine for queue operations:
|
|
12
|
+
# start → advance → complete (with fail/add/retry branches)
|
|
13
|
+
class AssignmentExecutor
|
|
14
|
+
attr_reader :assignment_manager, :queue_scanner, :step_writer, :step_renumberer, :skill_source_resolver
|
|
15
|
+
|
|
16
|
+
def initialize(cache_base: nil)
|
|
17
|
+
@assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
18
|
+
@queue_scanner = Molecules::QueueScanner.new
|
|
19
|
+
@step_writer = Molecules::StepWriter.new
|
|
20
|
+
@skill_source_resolver = Molecules::SkillAssignSourceResolver.new
|
|
21
|
+
@step_catalog = nil
|
|
22
|
+
@step_renumberer = Molecules::StepRenumberer.new(
|
|
23
|
+
step_writer: @step_writer,
|
|
24
|
+
queue_scanner: @queue_scanner
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Start a new workflow assignment from config file
|
|
29
|
+
#
|
|
30
|
+
# @param config_path [String] Path to job.yaml config
|
|
31
|
+
# @param parent_id [String, nil] Parent assignment ID for hierarchy linking
|
|
32
|
+
# @return [Hash] Result with assignment and first step
|
|
33
|
+
def start(config_path, parent_id: nil)
|
|
34
|
+
raise ConfigErrors::NotFound, "Config file not found: #{config_path}" unless File.exist?(config_path)
|
|
35
|
+
|
|
36
|
+
config = YAML.safe_load_file(config_path, permitted_classes: [Time, Date])
|
|
37
|
+
|
|
38
|
+
assignment_config = config["assignment"] || {}
|
|
39
|
+
steps_config = config["steps"] || []
|
|
40
|
+
|
|
41
|
+
raise Error, "No steps defined in config" if steps_config.empty?
|
|
42
|
+
|
|
43
|
+
# Enrich steps using declared workflow/skill assign metadata.
|
|
44
|
+
steps_config = enrich_declared_sub_steps(steps_config)
|
|
45
|
+
|
|
46
|
+
# Expand sub-step declarations into batch parent + child steps
|
|
47
|
+
steps_config = expand_sub_steps(steps_config)
|
|
48
|
+
steps_config = materialize_skill_backed_steps(steps_config)
|
|
49
|
+
|
|
50
|
+
# Create assignment
|
|
51
|
+
assignment = assignment_manager.create(
|
|
52
|
+
name: assignment_config["name"] || File.basename(config_path, ".yaml"),
|
|
53
|
+
description: assignment_config["description"],
|
|
54
|
+
source_config: config_path,
|
|
55
|
+
parent: parent_id
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Create initial step files
|
|
59
|
+
# Steps may have pre-assigned numbers (from expansion) or need auto-numbering
|
|
60
|
+
steps_config.each_with_index do |step, index|
|
|
61
|
+
# Use pre-assigned number if present, otherwise generate from index
|
|
62
|
+
number = step["number"] || Atoms::NumberGenerator.from_index(index)
|
|
63
|
+
extra = step.reject { |k, _| %w[name instructions number].include?(k) }
|
|
64
|
+
step_writer.create(
|
|
65
|
+
steps_dir: assignment.steps_dir,
|
|
66
|
+
number: number,
|
|
67
|
+
name: step["name"],
|
|
68
|
+
instructions: normalize_instructions(step["instructions"]),
|
|
69
|
+
status: :pending,
|
|
70
|
+
extra: extra
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Mark first workable step as in_progress.
|
|
75
|
+
# This skips batch parent containers that have incomplete children.
|
|
76
|
+
initial_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
77
|
+
first_workable = initial_state.next_workable
|
|
78
|
+
step_writer.mark_in_progress(first_workable.file_path) if first_workable
|
|
79
|
+
|
|
80
|
+
# Archive source config into task's steps directory and update assignment metadata
|
|
81
|
+
archived_path = archive_source_config(config_path, assignment.id)
|
|
82
|
+
assignment = Models::Assignment.new(
|
|
83
|
+
id: assignment.id,
|
|
84
|
+
name: assignment.name,
|
|
85
|
+
description: assignment.description,
|
|
86
|
+
created_at: assignment.created_at,
|
|
87
|
+
updated_at: assignment.updated_at,
|
|
88
|
+
source_config: archived_path,
|
|
89
|
+
cache_dir: assignment.cache_dir,
|
|
90
|
+
parent: assignment.parent
|
|
91
|
+
)
|
|
92
|
+
assignment_manager.update(assignment)
|
|
93
|
+
|
|
94
|
+
# Return result
|
|
95
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
96
|
+
{
|
|
97
|
+
assignment: assignment,
|
|
98
|
+
state: state,
|
|
99
|
+
current: state.current
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get current assignment and queue state
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] Result with assignment and state
|
|
106
|
+
def status
|
|
107
|
+
assignment = assignment_manager.find_active
|
|
108
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
109
|
+
|
|
110
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
111
|
+
{
|
|
112
|
+
assignment: assignment,
|
|
113
|
+
state: state,
|
|
114
|
+
current: state.current
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Start a pending step.
|
|
119
|
+
#
|
|
120
|
+
# Rules:
|
|
121
|
+
# - Fails if any step is already in progress (strict mode)
|
|
122
|
+
# - Starts an explicit pending target when provided
|
|
123
|
+
# - Otherwise starts the next workable pending step
|
|
124
|
+
#
|
|
125
|
+
# @param step_number [String, nil] Optional target step number
|
|
126
|
+
# @param fork_root [String, nil] Optional subtree root scope
|
|
127
|
+
# @return [Hash] Result with started step and updated state
|
|
128
|
+
def start_step(step_number: nil, fork_root: nil)
|
|
129
|
+
assignment = assignment_manager.find_active
|
|
130
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
131
|
+
|
|
132
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
133
|
+
raise StepErrors::InvalidState, "Cannot start: step #{state.current.number} is already in progress. Finish or fail it first." if state.current
|
|
134
|
+
|
|
135
|
+
fork_root = fork_root&.strip
|
|
136
|
+
target_step = if step_number && !step_number.to_s.strip.empty?
|
|
137
|
+
find_target_step_for_start(state, step_number, fork_root)
|
|
138
|
+
elsif fork_root && !fork_root.empty?
|
|
139
|
+
raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
|
|
140
|
+
state.next_workable_in_subtree(fork_root)
|
|
141
|
+
else
|
|
142
|
+
state.next_workable
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
unless target_step
|
|
146
|
+
if fork_root && !fork_root.empty?
|
|
147
|
+
raise StepErrors::InvalidState, "No pending workable step found in subtree #{fork_root}."
|
|
148
|
+
end
|
|
149
|
+
raise StepErrors::InvalidState, "No pending workable step found."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
step_writer.mark_in_progress(target_step.file_path)
|
|
153
|
+
assignment_manager.update(assignment)
|
|
154
|
+
|
|
155
|
+
new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
156
|
+
{
|
|
157
|
+
assignment: assignment,
|
|
158
|
+
state: new_state,
|
|
159
|
+
started: new_state.find_by_number(target_step.number),
|
|
160
|
+
current: new_state.current
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Finish an in-progress step and advance queue state.
|
|
165
|
+
#
|
|
166
|
+
# @param report_content [String] Completion report content
|
|
167
|
+
# @param step_number [String, nil] Optional in-progress step number to finish
|
|
168
|
+
# @param fork_root [String, nil] Optional subtree root to constrain advancement
|
|
169
|
+
# @return [Hash] Result with completed step and updated state
|
|
170
|
+
def finish_step(report_content:, step_number: nil, fork_root: nil)
|
|
171
|
+
assignment = assignment_manager.find_active
|
|
172
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
173
|
+
|
|
174
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
175
|
+
current = find_target_step_for_finish(state, step_number, fork_root)
|
|
176
|
+
raise Error, "No step currently in progress. Try 'ace-assign start' or 'ace-assign retry'." unless current
|
|
177
|
+
|
|
178
|
+
# Enforce hierarchy: cannot mark parent as done with incomplete children
|
|
179
|
+
if state.has_incomplete_children?(current.number)
|
|
180
|
+
incomplete = state.children_of(current.number).reject { |c| c.status == :done }
|
|
181
|
+
incomplete_nums = incomplete.map(&:number).join(", ")
|
|
182
|
+
raise Error, "Cannot complete step #{current.number}: has incomplete children (#{incomplete_nums}). Complete children first or use 'ace-assign fail' to mark as failed."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Mark current step as done
|
|
186
|
+
step_writer.mark_done(current.file_path, report_content: report_content, reports_dir: assignment.reports_dir)
|
|
187
|
+
|
|
188
|
+
# Rescan to get updated state after marking done
|
|
189
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
190
|
+
|
|
191
|
+
# Auto-complete parent steps if all their children are done
|
|
192
|
+
auto_complete_parents(state, assignment)
|
|
193
|
+
|
|
194
|
+
# Re-scan to get fresh state after auto-completions
|
|
195
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
196
|
+
|
|
197
|
+
fork_root = fork_root&.strip
|
|
198
|
+
# Find next step to work on using hierarchical rules.
|
|
199
|
+
# When fork_root is provided, keep advancement inside that subtree.
|
|
200
|
+
next_step = if fork_root && !fork_root.empty? && state.find_by_number(fork_root)
|
|
201
|
+
find_next_step_in_subtree(state, current.number, fork_root)
|
|
202
|
+
else
|
|
203
|
+
find_next_step(state, current.number)
|
|
204
|
+
end
|
|
205
|
+
if next_step
|
|
206
|
+
step_writer.mark_in_progress(next_step.file_path)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
assignment_manager.update(assignment)
|
|
210
|
+
|
|
211
|
+
new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
212
|
+
{
|
|
213
|
+
assignment: assignment,
|
|
214
|
+
state: new_state,
|
|
215
|
+
completed: current,
|
|
216
|
+
current: new_state.current
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Complete current step with report and advance
|
|
221
|
+
#
|
|
222
|
+
# Legacy bridge: preserves single-call semantics for fork-run callers.
|
|
223
|
+
# Previously, advance() auto-started the next step as a side effect.
|
|
224
|
+
# The new start/finish split makes this explicit, but advance() retains
|
|
225
|
+
# the auto-start behavior for subtree entry so fork-run workflows
|
|
226
|
+
# (which call advance() with fork_root) continue to work unchanged.
|
|
227
|
+
#
|
|
228
|
+
# @param report_path [String] Path to report file
|
|
229
|
+
# @param fork_root [String, nil] Optional subtree root to constrain advancement
|
|
230
|
+
# @return [Hash] Result with updated state
|
|
231
|
+
def advance(report_path, fork_root: nil)
|
|
232
|
+
raise ConfigErrors::NotFound, "Report file not found: #{report_path}" unless File.exist?(report_path)
|
|
233
|
+
|
|
234
|
+
# Auto-start the next workable subtree step when fork_root is given but
|
|
235
|
+
# no step in the subtree is yet in_progress (subtree entry case).
|
|
236
|
+
fork_root_str = fork_root&.strip
|
|
237
|
+
if fork_root_str && !fork_root_str.empty?
|
|
238
|
+
assignment = assignment_manager.find_active
|
|
239
|
+
if assignment
|
|
240
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
241
|
+
active_in_subtree = state.in_progress_in_subtree(fork_root_str)
|
|
242
|
+
if active_in_subtree.size > 1
|
|
243
|
+
active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
|
|
244
|
+
raise StepErrors::InvalidState, "Cannot advance subtree #{fork_root_str}: multiple steps are in progress (#{active_refs})."
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
if active_in_subtree.empty?
|
|
248
|
+
next_workable = state.next_workable_in_subtree(fork_root_str)
|
|
249
|
+
step_writer.mark_in_progress(next_workable.file_path) if next_workable
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
finish_step(report_content: File.read(report_path), fork_root: fork_root)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Mark current step as failed
|
|
258
|
+
#
|
|
259
|
+
# @param message [String] Error message
|
|
260
|
+
# @return [Hash] Result with updated state
|
|
261
|
+
def fail(message)
|
|
262
|
+
assignment = assignment_manager.find_active
|
|
263
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
264
|
+
|
|
265
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
266
|
+
current = state.current
|
|
267
|
+
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
|
|
268
|
+
|
|
269
|
+
# Mark step as failed
|
|
270
|
+
step_writer.mark_failed(current.file_path, error_message: message)
|
|
271
|
+
|
|
272
|
+
# Update assignment timestamp
|
|
273
|
+
assignment_manager.update(assignment)
|
|
274
|
+
|
|
275
|
+
# Return updated state (no automatic advancement after failure)
|
|
276
|
+
new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
277
|
+
{
|
|
278
|
+
assignment: assignment,
|
|
279
|
+
state: new_state,
|
|
280
|
+
failed: current
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Add a new step dynamically
|
|
285
|
+
#
|
|
286
|
+
# @param name [String] Step name
|
|
287
|
+
# @param instructions [String] Step instructions
|
|
288
|
+
# @param after [String, nil] Insert after this step number (optional)
|
|
289
|
+
# @param as_child [Boolean] Insert as child of 'after' step (default: false, sibling)
|
|
290
|
+
# @return [Hash] Result with new step
|
|
291
|
+
def add(name, instructions, after: nil, as_child: false)
|
|
292
|
+
assignment = assignment_manager.find_active
|
|
293
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
294
|
+
|
|
295
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
296
|
+
existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
|
|
297
|
+
|
|
298
|
+
# Validate --after step exists
|
|
299
|
+
if after && !existing_numbers.include?(after)
|
|
300
|
+
raise StepErrors::NotFound, "Step #{after} not found. Available steps: #{existing_numbers.join(", ")}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
new_number, renumbered = calculate_insertion_point(
|
|
304
|
+
after: after,
|
|
305
|
+
as_child: as_child,
|
|
306
|
+
state: state,
|
|
307
|
+
existing_numbers: existing_numbers
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Renumber existing steps if needed (uses molecule with rollback support)
|
|
311
|
+
if renumbered.any?
|
|
312
|
+
step_renumberer.renumber(assignment.steps_dir, renumbered)
|
|
313
|
+
# Refresh existing numbers after renumbering
|
|
314
|
+
queue_scanner.step_numbers(assignment.steps_dir)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Determine initial status upfront to avoid redundant I/O
|
|
318
|
+
initial_status = state.current ? :pending : :in_progress
|
|
319
|
+
|
|
320
|
+
# Build added_by metadata for audit trail
|
|
321
|
+
added_by = if after && as_child
|
|
322
|
+
"child_of:#{after}"
|
|
323
|
+
elsif after
|
|
324
|
+
"injected_after:#{after}"
|
|
325
|
+
else
|
|
326
|
+
"dynamic"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Create new step file with correct status
|
|
330
|
+
step_writer.create(
|
|
331
|
+
steps_dir: assignment.steps_dir,
|
|
332
|
+
number: new_number,
|
|
333
|
+
name: name,
|
|
334
|
+
instructions: instructions,
|
|
335
|
+
status: initial_status,
|
|
336
|
+
added_by: added_by,
|
|
337
|
+
parent: as_child ? after : nil
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
rebalance_after_child_injection(assignment: assignment, state: state, parent_number: after) if as_child && after
|
|
341
|
+
|
|
342
|
+
# Update assignment timestamp
|
|
343
|
+
assignment_manager.update(assignment)
|
|
344
|
+
|
|
345
|
+
# Return updated state
|
|
346
|
+
new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
347
|
+
new_step = new_state.steps.find { |s| s.number == new_number }
|
|
348
|
+
|
|
349
|
+
{
|
|
350
|
+
assignment: assignment,
|
|
351
|
+
state: new_state,
|
|
352
|
+
added: new_step,
|
|
353
|
+
renumbered: renumbered
|
|
354
|
+
}
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Retry a failed step (creates new step linked to original)
|
|
358
|
+
#
|
|
359
|
+
# @param step_ref [String] Step number or reference to retry
|
|
360
|
+
# @return [Hash] Result with new retry step
|
|
361
|
+
def retry_step(step_ref)
|
|
362
|
+
assignment = assignment_manager.find_active
|
|
363
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
364
|
+
|
|
365
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
366
|
+
|
|
367
|
+
# Find the step to retry
|
|
368
|
+
original = state.find_by_number(step_ref.to_s)
|
|
369
|
+
raise StepErrors::NotFound, "Step #{step_ref} not found in queue" unless original
|
|
370
|
+
|
|
371
|
+
# Get existing numbers
|
|
372
|
+
existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
|
|
373
|
+
|
|
374
|
+
# Insert after all current steps (at end of queue before pending)
|
|
375
|
+
# Find last done or failed step
|
|
376
|
+
base_number = if state.current
|
|
377
|
+
state.current.number
|
|
378
|
+
elsif state.last_done
|
|
379
|
+
state.last_done.number
|
|
380
|
+
else
|
|
381
|
+
original.number
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
new_number = Atoms::NumberGenerator.next_after(base_number, existing_numbers)
|
|
385
|
+
|
|
386
|
+
# Create retry step with link to original
|
|
387
|
+
step_writer.create(
|
|
388
|
+
steps_dir: assignment.steps_dir,
|
|
389
|
+
number: new_number,
|
|
390
|
+
name: original.name,
|
|
391
|
+
instructions: original.instructions,
|
|
392
|
+
status: :pending,
|
|
393
|
+
added_by: "retry_of:#{original.number}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Update assignment timestamp
|
|
397
|
+
assignment_manager.update(assignment)
|
|
398
|
+
|
|
399
|
+
# Return updated state
|
|
400
|
+
new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
401
|
+
retry_step = new_state.steps.find { |s| s.number == new_number }
|
|
402
|
+
|
|
403
|
+
{
|
|
404
|
+
assignment: assignment,
|
|
405
|
+
state: new_state,
|
|
406
|
+
retry: retry_step,
|
|
407
|
+
original: original
|
|
408
|
+
}
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
private
|
|
412
|
+
|
|
413
|
+
# Enrich steps by resolving workflow-level or legacy skill-level assign source metadata.
|
|
414
|
+
#
|
|
415
|
+
# If a step has `workflow: ...` or `skill: ...` and no explicit sub_steps,
|
|
416
|
+
# this resolves the workflow and applies
|
|
417
|
+
# workflow `assign.sub-steps` as step sub_steps for deterministic runtime expansion.
|
|
418
|
+
#
|
|
419
|
+
# @param steps_config [Array<Hash>] Original steps from config
|
|
420
|
+
# @return [Array<Hash>] Enriched steps
|
|
421
|
+
def enrich_declared_sub_steps(steps_config)
|
|
422
|
+
steps_config.map do |step|
|
|
423
|
+
next step unless step.is_a?(Hash)
|
|
424
|
+
|
|
425
|
+
sub_steps = step["sub_steps"] || step["sub-steps"]
|
|
426
|
+
next step if sub_steps.is_a?(Array) && sub_steps.any?
|
|
427
|
+
|
|
428
|
+
assign_config = resolve_step_assign_config(step)
|
|
429
|
+
next step unless assign_config
|
|
430
|
+
|
|
431
|
+
resolved_sub_steps = assign_config[:sub_steps]
|
|
432
|
+
next step unless resolved_sub_steps.is_a?(Array) && resolved_sub_steps.any?
|
|
433
|
+
|
|
434
|
+
enriched = step.merge("sub_steps" => resolved_sub_steps)
|
|
435
|
+
enriched["context"] ||= assign_config[:context] if assign_config[:context]
|
|
436
|
+
enriched
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Expand steps with sub_steps into batch parent + child structure.
|
|
441
|
+
#
|
|
442
|
+
# When a step declares `sub_steps` (from workflow frontmatter), it becomes
|
|
443
|
+
# a batch parent with fork context, and each sub-step becomes a child step.
|
|
444
|
+
# This reuses the existing batch-parent pattern from compose.
|
|
445
|
+
#
|
|
446
|
+
# Numbers are pre-assigned based on the original index position so that
|
|
447
|
+
# subsequent steps keep their expected numbering (e.g., 010, 020, 030)
|
|
448
|
+
# regardless of how many children are expanded.
|
|
449
|
+
#
|
|
450
|
+
# @param steps_config [Array<Hash>] Original steps from config
|
|
451
|
+
# @return [Array<Hash>] Expanded steps with parent-child numbers
|
|
452
|
+
def expand_sub_steps(steps_config)
|
|
453
|
+
# Check if any step has sub_steps; return early if none
|
|
454
|
+
has_sub_steps = steps_config.any? do |step|
|
|
455
|
+
subs = step["sub_steps"] || step["sub-steps"]
|
|
456
|
+
subs.is_a?(Array) && subs.any?
|
|
457
|
+
end
|
|
458
|
+
return steps_config unless has_sub_steps
|
|
459
|
+
|
|
460
|
+
expanded = []
|
|
461
|
+
|
|
462
|
+
steps_config.each_with_index do |step, index|
|
|
463
|
+
sub_steps = step["sub_steps"] || step["sub-steps"]
|
|
464
|
+
parent_number = step["number"] || Atoms::NumberGenerator.from_index(index)
|
|
465
|
+
|
|
466
|
+
if sub_steps.is_a?(Array) && sub_steps.any?
|
|
467
|
+
# Create split parent orchestration node
|
|
468
|
+
parent_context = step["context"] || "fork"
|
|
469
|
+
parent_instructions = step["instructions"]
|
|
470
|
+
parent_step = build_split_parent_step(
|
|
471
|
+
step: step,
|
|
472
|
+
parent_number: parent_number,
|
|
473
|
+
parent_context: parent_context,
|
|
474
|
+
sub_steps: sub_steps
|
|
475
|
+
)
|
|
476
|
+
expanded << parent_step
|
|
477
|
+
|
|
478
|
+
# Create child steps under the parent
|
|
479
|
+
sub_steps.each_with_index do |sub_name, sub_idx|
|
|
480
|
+
child_number = Atoms::NumberGenerator.subtask(parent_number, sub_idx + 1)
|
|
481
|
+
expanded << build_child_sub_step(
|
|
482
|
+
sub_name: sub_name,
|
|
483
|
+
child_number: child_number,
|
|
484
|
+
parent_number: parent_number,
|
|
485
|
+
parent_step: step,
|
|
486
|
+
parent_instructions: parent_instructions,
|
|
487
|
+
parent_context: parent_context
|
|
488
|
+
)
|
|
489
|
+
end
|
|
490
|
+
else
|
|
491
|
+
# Pre-assign number to non-sub-step entries to maintain position
|
|
492
|
+
expanded << step.merge("number" => parent_number)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
expanded
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Build a split parent orchestration step.
|
|
500
|
+
#
|
|
501
|
+
# Parent nodes with sub_steps are subtree delegation roots and should not
|
|
502
|
+
# execute the original skill directly. The parent instructions explain
|
|
503
|
+
# how to drive the subtree; the original goals are preserved for context.
|
|
504
|
+
#
|
|
505
|
+
# @param step [Hash] Original parent step config
|
|
506
|
+
# @param parent_number [String] Parent step number
|
|
507
|
+
# @param parent_context [String] Parent execution context
|
|
508
|
+
# @param sub_steps [Array<String>] Declared sub-step names
|
|
509
|
+
# @return [Hash] Parent step config for runtime queue
|
|
510
|
+
def build_split_parent_step(step:, parent_number:, parent_context:, sub_steps:)
|
|
511
|
+
source_skill = step["skill"]
|
|
512
|
+
original_text = normalize_instructions(step["instructions"]).strip
|
|
513
|
+
definition = find_step_definition("split-subtree-root") || {}
|
|
514
|
+
|
|
515
|
+
lines = split_parent_instruction_lines(
|
|
516
|
+
definition: definition,
|
|
517
|
+
parent_number: parent_number,
|
|
518
|
+
parent_context: parent_context,
|
|
519
|
+
source_skill: source_skill,
|
|
520
|
+
sub_steps: sub_steps
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
unless original_text.empty?
|
|
524
|
+
lines << ""
|
|
525
|
+
lines << (definition["goal_header"] || "Goal to satisfy through child steps:")
|
|
526
|
+
original_text.lines.map(&:strip).reject(&:empty?).each do |line|
|
|
527
|
+
lines << "- #{line}"
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
parent_step = step.merge(
|
|
532
|
+
"number" => parent_number,
|
|
533
|
+
"context" => parent_context,
|
|
534
|
+
"instructions" => lines.join("\n")
|
|
535
|
+
)
|
|
536
|
+
parent_step.delete("sub_steps")
|
|
537
|
+
parent_step.delete("sub-steps")
|
|
538
|
+
parent_step.delete("skill")
|
|
539
|
+
parent_step.delete("workflow")
|
|
540
|
+
parent_step["source_skill"] = source_skill if source_skill
|
|
541
|
+
parent_step["split_step_type"] = definition["name"] || "split-subtree-root"
|
|
542
|
+
parent_step
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Render split parent instructions from catalog with fallback defaults.
|
|
546
|
+
#
|
|
547
|
+
# @param definition [Hash] Catalog definition for split parent step
|
|
548
|
+
# @param parent_number [String] Parent step number
|
|
549
|
+
# @param parent_context [String] Parent execution context
|
|
550
|
+
# @param source_skill [String, nil] Source skill of original parent step
|
|
551
|
+
# @param sub_steps [Array<String>] Child step names
|
|
552
|
+
# @return [Array<String>] Rendered instruction lines
|
|
553
|
+
def split_parent_instruction_lines(definition:, parent_number:, parent_context:, source_skill:, sub_steps:)
|
|
554
|
+
instructions = definition["instructions"].is_a?(Hash) ? definition["instructions"] : {}
|
|
555
|
+
context_key = (parent_context == "fork") ? "fork" : "inline"
|
|
556
|
+
template_lines = Array(instructions["common"]) + Array(instructions[context_key])
|
|
557
|
+
template_lines = default_split_parent_instruction_lines(parent_context) if template_lines.empty?
|
|
558
|
+
|
|
559
|
+
template_lines = template_lines.map(&:to_s)
|
|
560
|
+
template_lines.reject! { |line| line.include?("{{source_skill}}") && source_skill.to_s.strip.empty? }
|
|
561
|
+
|
|
562
|
+
variables = {
|
|
563
|
+
"parent_number" => parent_number,
|
|
564
|
+
"parent_context" => parent_context,
|
|
565
|
+
"source_skill" => source_skill.to_s,
|
|
566
|
+
"sub_steps" => sub_steps.join(", ")
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
template_lines
|
|
570
|
+
.map { |line| interpolate_template_line(line, variables) }
|
|
571
|
+
.map(&:strip)
|
|
572
|
+
.reject(&:empty?)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Default split parent instruction lines used when catalog entry is missing.
|
|
576
|
+
#
|
|
577
|
+
# @param parent_context [String]
|
|
578
|
+
# @return [Array<String>]
|
|
579
|
+
def default_split_parent_instruction_lines(parent_context)
|
|
580
|
+
lines = [
|
|
581
|
+
"Subtree root orchestrator step.",
|
|
582
|
+
"This step is orchestration-only.",
|
|
583
|
+
"Do not execute the parent workflow directly in this step.",
|
|
584
|
+
"Child steps: {{sub_steps}}."
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
if parent_context == "fork"
|
|
588
|
+
lines.concat(
|
|
589
|
+
[
|
|
590
|
+
"Delegate this subtree into forked context:",
|
|
591
|
+
"- ace-assign fork-run --assignment <assignment-id>@{{parent_number}}",
|
|
592
|
+
"Inside the forked agent, continue execution within this subtree scope only."
|
|
593
|
+
]
|
|
594
|
+
)
|
|
595
|
+
else
|
|
596
|
+
lines << "Execute only child steps under this node."
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
lines
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Apply simple {{token}} template substitution.
|
|
603
|
+
#
|
|
604
|
+
# @param line [String]
|
|
605
|
+
# @param variables [Hash]
|
|
606
|
+
# @return [String]
|
|
607
|
+
def interpolate_template_line(line, variables)
|
|
608
|
+
rendered = line.dup
|
|
609
|
+
variables.each do |key, value|
|
|
610
|
+
rendered = rendered.gsub("{{#{key}}}", value.to_s)
|
|
611
|
+
end
|
|
612
|
+
rendered
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Build a concrete child step from a sub-step name.
|
|
616
|
+
#
|
|
617
|
+
# Child steps inherit parent task context in instructions so skills can
|
|
618
|
+
# extract concrete parameters (e.g., task refs) during execution.
|
|
619
|
+
# Skill and context defaults are sourced from the step catalog when available.
|
|
620
|
+
#
|
|
621
|
+
# @param sub_name [String] Child sub-step name
|
|
622
|
+
# @param child_number [String] Generated child step number
|
|
623
|
+
# @param parent_number [String] Parent step number
|
|
624
|
+
# @param parent_step [Hash] Parent step config
|
|
625
|
+
# @param parent_instructions [String, Array<String>, nil] Parent instructions
|
|
626
|
+
# @param parent_context [String, nil] Parent execution context
|
|
627
|
+
# @return [Hash] Child step config
|
|
628
|
+
def build_child_sub_step(sub_name:, child_number:, parent_number:, parent_step:, parent_instructions:, parent_context:)
|
|
629
|
+
step_def = find_step_definition(sub_name)
|
|
630
|
+
parent_task_ref = extract_parent_taskref(parent_step, parent_instructions)
|
|
631
|
+
instructions = if step_def&.dig("skill")
|
|
632
|
+
build_skill_backed_child_notes(sub_name, parent_instructions, task_ref: parent_task_ref)
|
|
633
|
+
else
|
|
634
|
+
build_child_instructions(sub_name, parent_instructions, step_def, task_ref: parent_task_ref)
|
|
635
|
+
end
|
|
636
|
+
child = {
|
|
637
|
+
"number" => child_number,
|
|
638
|
+
"name" => sub_name,
|
|
639
|
+
"instructions" => instructions,
|
|
640
|
+
"parent" => parent_number
|
|
641
|
+
}
|
|
642
|
+
child["taskref"] = parent_task_ref if parent_task_ref
|
|
643
|
+
|
|
644
|
+
if step_def
|
|
645
|
+
child["workflow"] = step_def["workflow"] if step_def["workflow"]
|
|
646
|
+
child["skill"] = step_def["skill"] if step_def["skill"] && !step_def["workflow"]
|
|
647
|
+
|
|
648
|
+
context_default = step_def.dig("context", "default")
|
|
649
|
+
child["context"] = context_default if context_default && parent_context != "fork"
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
child
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Build child instructions with parent context and step focus.
|
|
656
|
+
#
|
|
657
|
+
# @param sub_name [String] Child sub-step name
|
|
658
|
+
# @param parent_instructions [String, Array<String>, nil] Parent instructions
|
|
659
|
+
# @param step_def [Hash, nil] Catalog definition for this sub-step
|
|
660
|
+
# @param task_ref [String, nil] Explicit task reference from parent metadata
|
|
661
|
+
# @return [String] Rendered instructions
|
|
662
|
+
def build_child_instructions(sub_name, parent_instructions, step_def, task_ref: nil)
|
|
663
|
+
parent_text = normalize_instructions(parent_instructions).strip
|
|
664
|
+
focus = (step_def && step_def["description"]) ? step_def["description"] : "Execute #{sub_name} sub-step."
|
|
665
|
+
focus = focus.gsub("<taskref>", task_ref) if task_ref && !task_ref.empty?
|
|
666
|
+
context = compact_task_context(parent_text, task_ref: task_ref)
|
|
667
|
+
action = child_action_instructions(sub_name, parent_text, task_ref: task_ref)
|
|
668
|
+
|
|
669
|
+
sections = []
|
|
670
|
+
sections << "Task context:\n#{context}" unless context.empty?
|
|
671
|
+
sections << "Sub-step focus:\n#{focus}"
|
|
672
|
+
sections << "Action:\n#{action}"
|
|
673
|
+
sections.join("\n\n")
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def build_skill_backed_child_notes(sub_name, parent_instructions, task_ref: nil)
|
|
677
|
+
parent_text = normalize_instructions(parent_instructions).strip
|
|
678
|
+
context = compact_task_context(parent_text, task_ref: task_ref)
|
|
679
|
+
notes = child_specific_notes(sub_name, parent_text)
|
|
680
|
+
|
|
681
|
+
sections = []
|
|
682
|
+
sections << "Task context:\n#{context}" unless context.empty?
|
|
683
|
+
sections << "Assignment-specific context:\n#{notes}" unless notes.empty?
|
|
684
|
+
sections.join("\n\n")
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# Build compact task context for child sub-steps.
|
|
688
|
+
# Avoid copying parent orchestration boilerplate into every child step.
|
|
689
|
+
#
|
|
690
|
+
# @param parent_text [String]
|
|
691
|
+
# @param task_ref [String, nil]
|
|
692
|
+
# @return [String]
|
|
693
|
+
def compact_task_context(parent_text, task_ref: nil)
|
|
694
|
+
unless task_ref.nil? || task_ref.to_s.strip.empty?
|
|
695
|
+
return "Task reference: #{task_ref}"
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
return "" if parent_text.nil? || parent_text.empty?
|
|
699
|
+
|
|
700
|
+
task_refs = parent_text.scan(/\b\d+\.\d+\b/).uniq
|
|
701
|
+
return "Task reference: #{task_refs.join(", ")}" if task_refs.any?
|
|
702
|
+
|
|
703
|
+
relevant_lines = parent_text.lines.map(&:strip).reject(&:empty?).reject do |line|
|
|
704
|
+
line == "Task context:" || line == "Assignment-specific context:"
|
|
705
|
+
end
|
|
706
|
+
first_line = relevant_lines.first
|
|
707
|
+
return "" unless first_line
|
|
708
|
+
|
|
709
|
+
return first_line if first_line.start_with?("Task request:", "Task reference:")
|
|
710
|
+
|
|
711
|
+
"Task request: #{first_line}"
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Build explicit, step-specific action instructions.
|
|
715
|
+
#
|
|
716
|
+
# @param sub_name [String]
|
|
717
|
+
# @param parent_text [String]
|
|
718
|
+
# @param task_ref [String, nil]
|
|
719
|
+
# @return [String]
|
|
720
|
+
def child_action_instructions(sub_name, parent_text, task_ref: nil)
|
|
721
|
+
task_refs = if task_ref && !task_ref.to_s.strip.empty?
|
|
722
|
+
[task_ref.to_s]
|
|
723
|
+
else
|
|
724
|
+
parent_text.to_s.scan(/\b\d+\.\d+\b/).uniq
|
|
725
|
+
end
|
|
726
|
+
task_hint = task_refs.any? ? " for task #{task_refs.join(", ")}" : ""
|
|
727
|
+
|
|
728
|
+
case sub_name
|
|
729
|
+
when "onboard"
|
|
730
|
+
"- Load project context#{task_hint} using the step workflow instructions.\n- Confirm required files and workflow context are available."
|
|
731
|
+
when "plan-task"
|
|
732
|
+
"- Analyze requirements#{task_hint}.\n- Plan against the behavioral spec structure: cover Interface Contract, Error Handling, Edge Cases, and operating modes (dry-run, force, verbose, quiet) where relevant.\n- If the spec is missing details needed for implementation, include them in a \"Behavioral Gaps\" section instead of silently working around omissions.\n- Produce a concrete implementation plan with acceptance checks."
|
|
733
|
+
when "work-on-task"
|
|
734
|
+
"- Implement the required changes#{task_hint}.\n- Verify behavior with relevant checks/tests before reporting completion.\n- Before marking complete, verify working tree is clean (`git status --short`). If dirty, commit remaining changes with `ace-git-commit`."
|
|
735
|
+
when "pre-commit-review"
|
|
736
|
+
pre_commit_review_action_instructions(task_hint: task_hint)
|
|
737
|
+
when "verify-test"
|
|
738
|
+
"- 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."
|
|
739
|
+
when /\Arelease(?:-.+)?\z/
|
|
740
|
+
"- 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."
|
|
741
|
+
when "verify-e2e"
|
|
742
|
+
"- Check change scope: run `git diff origin/main --name-only` to list modified files.\n" \
|
|
743
|
+
"- **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" \
|
|
744
|
+
"- Otherwise: detect modified packages, run E2E scenarios for each package with `test/e2e/` scenarios#{task_hint}.\n" \
|
|
745
|
+
"- If no modified package has E2E scenarios, mark step done with \"skipped: no E2E scenarios for modified packages\"."
|
|
746
|
+
else
|
|
747
|
+
"- Execute the #{sub_name} step."
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def child_specific_notes(sub_name, parent_text)
|
|
752
|
+
return "" if parent_text.nil? || parent_text.empty?
|
|
753
|
+
|
|
754
|
+
lines = parent_text.lines.map(&:strip).reject(&:empty?)
|
|
755
|
+
relevant = lines.filter_map do |line|
|
|
756
|
+
if line.match?(/\AChild #{Regexp.escape(sub_name)}:/i)
|
|
757
|
+
"- #{line.sub(/\AChild #{Regexp.escape(sub_name)}:\s*/i, "")}"
|
|
758
|
+
elsif line.start_with?("Focus:")
|
|
759
|
+
"- #{line}"
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
relevant.join("\n")
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def materialize_skill_backed_steps(steps_config)
|
|
767
|
+
steps_config.map do |step|
|
|
768
|
+
materialize_skill_backed_step(step)
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def materialize_skill_backed_step(step)
|
|
773
|
+
return step unless step.is_a?(Hash)
|
|
774
|
+
return step if step["split_step_type"]
|
|
775
|
+
|
|
776
|
+
rendering = resolve_step_rendering(step)
|
|
777
|
+
return step unless rendering
|
|
778
|
+
|
|
779
|
+
rendered_instructions = render_skill_backed_step_instructions(
|
|
780
|
+
step: step,
|
|
781
|
+
rendering: rendering
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
materialized = step.merge(
|
|
785
|
+
"instructions" => rendered_instructions,
|
|
786
|
+
"workflow" => rendering["workflow"]
|
|
787
|
+
)
|
|
788
|
+
materialized["source_skill"] = rendering["source_skill"] || rendering["skill"] if rendering["source_skill"] || rendering["skill"]
|
|
789
|
+
materialized["source_workflow"] = rendering["workflow"] if rendering["workflow"] && !rendering["workflow"].empty?
|
|
790
|
+
materialized.delete("skill")
|
|
791
|
+
materialized
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def resolve_step_rendering(step)
|
|
795
|
+
explicit_workflow = step["workflow"]&.to_s&.strip
|
|
796
|
+
if explicit_workflow && !explicit_workflow.empty?
|
|
797
|
+
canonical_step = find_step_definition(step["name"]&.to_s)
|
|
798
|
+
source_skill = step["source_skill"]&.to_s&.strip
|
|
799
|
+
source_skill = canonical_step&.dig("source_skill") if source_skill.nil? || source_skill.empty?
|
|
800
|
+
rendering = skill_source_resolver.resolve_workflow_rendering(
|
|
801
|
+
explicit_workflow,
|
|
802
|
+
step_name: step["name"]&.to_s,
|
|
803
|
+
source_skill: source_skill
|
|
804
|
+
)
|
|
805
|
+
return canonical_step ? canonical_step.merge(rendering || {}) : rendering if rendering
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
explicit_skill = step["skill"]&.to_s&.strip
|
|
809
|
+
if explicit_skill && !explicit_skill.empty?
|
|
810
|
+
return skill_source_resolver.resolve_skill_rendering(explicit_skill)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
skill_source_resolver.resolve_step_rendering(step["name"]&.to_s)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def render_skill_backed_step_instructions(step:, rendering:)
|
|
817
|
+
if step_render_mode(rendering) == "step_template"
|
|
818
|
+
return render_step_template_instructions(step: step, rendering: rendering)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
sections = []
|
|
822
|
+
|
|
823
|
+
task_ref = extract_parent_taskref(step, step["instructions"])
|
|
824
|
+
task_context = (task_ref && !task_ref.empty?) ? "Task reference: #{task_ref}" : compact_task_context(normalize_instructions(step["instructions"]), task_ref: task_ref)
|
|
825
|
+
sections << "Task context:\n#{task_context}" unless task_context.empty?
|
|
826
|
+
|
|
827
|
+
body = rendering["body"].to_s.strip
|
|
828
|
+
sections << body unless body.empty?
|
|
829
|
+
|
|
830
|
+
assignment_notes = assignment_specific_notes(
|
|
831
|
+
step_name: step["name"]&.to_s,
|
|
832
|
+
instructions: step["instructions"]
|
|
833
|
+
)
|
|
834
|
+
sections << "Assignment-specific context:\n#{assignment_notes}" unless assignment_notes.empty?
|
|
835
|
+
|
|
836
|
+
sections.join("\n\n")
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def render_step_template_instructions(step:, rendering:)
|
|
840
|
+
sections = []
|
|
841
|
+
|
|
842
|
+
task_ref = extract_parent_taskref(step, step["instructions"])
|
|
843
|
+
task_context = (task_ref && !task_ref.empty?) ? "Task reference: #{task_ref}" : compact_task_context(normalize_instructions(step["instructions"]), task_ref: task_ref)
|
|
844
|
+
sections << "Task context:\n#{task_context}" unless task_context.empty?
|
|
845
|
+
|
|
846
|
+
description = rendering["description"].to_s.strip
|
|
847
|
+
sections << "Step focus:\n#{description}" unless description.empty?
|
|
848
|
+
|
|
849
|
+
steps = render_step_template_steps(rendering["steps"])
|
|
850
|
+
sections << "Steps:\n#{steps}" unless steps.empty?
|
|
851
|
+
|
|
852
|
+
skip_guidance = render_step_template_skip_guidance(rendering["when_to_skip"])
|
|
853
|
+
sections << "Skip when:\n#{skip_guidance}" unless skip_guidance.empty?
|
|
854
|
+
|
|
855
|
+
assignment_notes = assignment_specific_notes(
|
|
856
|
+
step_name: step["name"]&.to_s,
|
|
857
|
+
instructions: step["instructions"]
|
|
858
|
+
)
|
|
859
|
+
sections << "Assignment-specific context:\n#{assignment_notes}" unless assignment_notes.empty?
|
|
860
|
+
|
|
861
|
+
sections.join("\n\n")
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def render_step_template_steps(steps)
|
|
865
|
+
Array(steps).filter_map do |step|
|
|
866
|
+
next unless step.is_a?(Hash)
|
|
867
|
+
|
|
868
|
+
description = step["description"]&.to_s&.strip
|
|
869
|
+
next if description.nil? || description.empty?
|
|
870
|
+
|
|
871
|
+
line = "- #{description}"
|
|
872
|
+
conditional = step["conditional"]&.to_s&.strip
|
|
873
|
+
note = step["note"]&.to_s&.strip
|
|
874
|
+
line += " If #{conditional}." unless conditional.nil? || conditional.empty?
|
|
875
|
+
line += " #{note}" unless note.nil? || note.empty?
|
|
876
|
+
line
|
|
877
|
+
end.join("\n")
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def render_step_template_skip_guidance(conditions)
|
|
881
|
+
Array(conditions).filter_map do |condition|
|
|
882
|
+
text = condition&.to_s&.strip
|
|
883
|
+
next if text.nil? || text.empty?
|
|
884
|
+
|
|
885
|
+
"- #{text}"
|
|
886
|
+
end.join("\n")
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def step_render_mode(rendering)
|
|
890
|
+
mode = rendering["render"]&.to_s&.strip
|
|
891
|
+
return "workflow_body" if mode.nil? || mode.empty?
|
|
892
|
+
|
|
893
|
+
mode
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
def assignment_specific_notes(step_name:, instructions:)
|
|
897
|
+
text = normalize_instructions(instructions).strip
|
|
898
|
+
return "" if text.empty?
|
|
899
|
+
|
|
900
|
+
filtered = text.lines.filter_map do |line|
|
|
901
|
+
normalized = normalize_assignment_overlay_line(line)
|
|
902
|
+
next if normalized.nil? || normalized.empty?
|
|
903
|
+
next if normalized.start_with?("Task reference:", "Task request:")
|
|
904
|
+
|
|
905
|
+
normalized
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
if step_name == "work-on-task"
|
|
909
|
+
filtered = filtered.reject do |line|
|
|
910
|
+
line.start_with?("Implement task ", "When complete, mark the task as done:")
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
filtered = filtered.uniq
|
|
915
|
+
filtered.map { |line| "- #{line}" }.join("\n")
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def normalize_assignment_overlay_line(line)
|
|
919
|
+
stripped = line.to_s.strip
|
|
920
|
+
return nil if stripped.empty?
|
|
921
|
+
return nil if stripped == "Task context:" || stripped == "Assignment-specific context:"
|
|
922
|
+
|
|
923
|
+
stripped = stripped.sub(/\A-\s*/, "")
|
|
924
|
+
stripped = stripped.sub(/\A-\s*/, "")
|
|
925
|
+
stripped.strip
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def resolve_step_assign_config(step)
|
|
929
|
+
explicit_workflow = step["workflow"]&.to_s&.strip
|
|
930
|
+
if explicit_workflow && !explicit_workflow.empty?
|
|
931
|
+
return skill_source_resolver.resolve_workflow_assign_config(
|
|
932
|
+
explicit_workflow,
|
|
933
|
+
step_name: step["name"]&.to_s,
|
|
934
|
+
source_skill: step["source_skill"]&.to_s
|
|
935
|
+
)
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
skill_name = step["skill"]&.to_s
|
|
939
|
+
return nil if skill_name.nil? || skill_name.empty?
|
|
940
|
+
|
|
941
|
+
skill_source_resolver.resolve_assign_config(skill_name)
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def pre_commit_review_action_instructions(task_hint:)
|
|
945
|
+
subtree_cfg = normalized_subtree_config
|
|
946
|
+
allowlist = subtree_cfg[:native_review_clients]
|
|
947
|
+
allowlist_text = allowlist.empty? ? "<none>" : allowlist.join(", ")
|
|
948
|
+
|
|
949
|
+
lines = []
|
|
950
|
+
lines << "- Resolve subtree review config#{task_hint}: pre_commit_review=#{subtree_cfg[:pre_commit_review]}, mode=#{subtree_cfg[:pre_commit_review_provider]}, block=#{subtree_cfg[:pre_commit_review_block]}."
|
|
951
|
+
if subtree_cfg[:pre_commit_review] == false || subtree_cfg[:pre_commit_review_provider] == "skip"
|
|
952
|
+
lines << "- Pre-commit review is disabled by config; mark this step skipped with the config reason and continue."
|
|
953
|
+
return lines.join("\n")
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
lines << "- Detect active client/provider from fork session metadata first (`.ace-local/assign/<assignment-id>/sessions/<fork-root>-session.yml`, key: provider)."
|
|
957
|
+
lines << "- If session metadata is unavailable, fallback to `execution.provider` from assign config."
|
|
958
|
+
lines << "- Allowed native review clients: #{allowlist_text}."
|
|
959
|
+
lines << "- If detected client is allowed and mode is `auto` or `native`, review uncommitted changes and find issues (use the `/review` agent slash command — this is a conversation command, NOT a bash command)."
|
|
960
|
+
lines << "- If the `/review` agent command is not available in the current execution environment, run `ace-lint` on modified files as a fallback quality gate, then continue."
|
|
961
|
+
lines << "- Summarize findings with severity counts and keep raw output when structure is incomplete."
|
|
962
|
+
lines << "- If `pre_commit_review_block` is true and a critical finding is confidently detected, fail this step with evidence to block release."
|
|
963
|
+
lines.join("\n")
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def normalized_subtree_config
|
|
967
|
+
subtree = Ace::Assign.config["subtree"]
|
|
968
|
+
subtree = {} unless subtree.is_a?(Hash)
|
|
969
|
+
|
|
970
|
+
config = {
|
|
971
|
+
pre_commit_review: subtree.key?("pre_commit_review") ? subtree["pre_commit_review"] : true,
|
|
972
|
+
pre_commit_review_provider: (subtree["pre_commit_review_provider"] || "auto").to_s,
|
|
973
|
+
pre_commit_review_block: subtree.key?("pre_commit_review_block") ? subtree["pre_commit_review_block"] : false,
|
|
974
|
+
native_review_clients: Array(subtree["native_review_clients"]).map(&:to_s).map(&:strip).reject(&:empty?)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if config[:pre_commit_review] && config[:native_review_clients].empty?
|
|
978
|
+
warn "[ace-assign] pre_commit_review enabled but native_review_clients is empty - review will always skip"
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
config
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Resolve task reference from explicit metadata first, then parent instruction text.
|
|
985
|
+
#
|
|
986
|
+
# @param parent_step [Hash]
|
|
987
|
+
# @param parent_instructions [String, Array<String>, nil]
|
|
988
|
+
# @return [String, nil]
|
|
989
|
+
def extract_parent_taskref(parent_step, parent_instructions)
|
|
990
|
+
explicit = parent_step["taskref"] || parent_step["task_ref"]
|
|
991
|
+
explicit_value = explicit.to_s.strip
|
|
992
|
+
return explicit_value unless explicit_value.empty?
|
|
993
|
+
|
|
994
|
+
parent_text = normalize_instructions(parent_instructions)
|
|
995
|
+
inferred = parent_text.scan(/\b\d+\.\d+\b/).uniq
|
|
996
|
+
return nil if inferred.empty?
|
|
997
|
+
|
|
998
|
+
inferred.join(", ")
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# Lookup step definition from catalog by step name.
|
|
1002
|
+
#
|
|
1003
|
+
# @param step_name [String] Name of step
|
|
1004
|
+
# @return [Hash, nil] Catalog definition
|
|
1005
|
+
def find_step_definition(step_name)
|
|
1006
|
+
Atoms::CatalogLoader.find_by_name(step_catalog, step_name)
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# Load step catalog from project override or gem defaults.
|
|
1010
|
+
#
|
|
1011
|
+
# @return [Array<Hash>] Loaded step definitions
|
|
1012
|
+
def step_catalog
|
|
1013
|
+
@step_catalog ||= begin
|
|
1014
|
+
project_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
1015
|
+
gem_root = Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
|
|
1016
|
+
|
|
1017
|
+
project_catalog = File.join(project_root, ".ace", "assign", "catalog", "steps")
|
|
1018
|
+
default_catalog = File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps")
|
|
1019
|
+
|
|
1020
|
+
default_steps = Atoms::CatalogLoader.load_all(default_catalog)
|
|
1021
|
+
base_catalog = if File.directory?(project_catalog)
|
|
1022
|
+
project_steps = Atoms::CatalogLoader.load_all(project_catalog)
|
|
1023
|
+
merge_step_catalog(default_steps, project_steps)
|
|
1024
|
+
else
|
|
1025
|
+
default_steps
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
canonical_steps = skill_source_resolver.assign_step_catalog
|
|
1029
|
+
merge_step_catalog(base_catalog, canonical_steps)
|
|
1030
|
+
end
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
# Merge default and project step catalogs by step name.
|
|
1034
|
+
# Later definitions override earlier ones with matching names.
|
|
1035
|
+
#
|
|
1036
|
+
# @param default_steps [Array<Hash>]
|
|
1037
|
+
# @param project_steps [Array<Hash>]
|
|
1038
|
+
# @return [Array<Hash>]
|
|
1039
|
+
def merge_step_catalog(default_steps, project_steps)
|
|
1040
|
+
index = {}
|
|
1041
|
+
order = []
|
|
1042
|
+
|
|
1043
|
+
default_steps.each do |step|
|
|
1044
|
+
name = step["name"]
|
|
1045
|
+
next if name.nil? || name.empty?
|
|
1046
|
+
|
|
1047
|
+
index[name] = step
|
|
1048
|
+
order << name
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
project_steps.each do |step|
|
|
1052
|
+
name = step["name"]
|
|
1053
|
+
next if name.nil? || name.empty?
|
|
1054
|
+
|
|
1055
|
+
order << name unless index.key?(name)
|
|
1056
|
+
index[name] = deep_merge_step_definition(index[name], step)
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
order.map { |name| index[name] }.compact
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def deep_merge_step_definition(base, override)
|
|
1063
|
+
return override unless base.is_a?(Hash)
|
|
1064
|
+
return base unless override.is_a?(Hash)
|
|
1065
|
+
|
|
1066
|
+
merged = base.dup
|
|
1067
|
+
override.each do |key, value|
|
|
1068
|
+
merged[key] =
|
|
1069
|
+
if merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
1070
|
+
deep_merge_step_definition(merged[key], value)
|
|
1071
|
+
else
|
|
1072
|
+
value
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
merged
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
# Archive source config into the task's jobs/ directory.
|
|
1079
|
+
# If config is already in a jobs/ or steps/ directory, keeps it in place.
|
|
1080
|
+
# Otherwise moves job.yaml to <task>/jobs/<assignment_id>-job.yml for provenance.
|
|
1081
|
+
#
|
|
1082
|
+
# @param config_path [String] Path to the original job.yaml
|
|
1083
|
+
# @param assignment_id [String] Assignment identifier for filename prefix
|
|
1084
|
+
# @return [String] Path to archived file
|
|
1085
|
+
def archive_source_config(config_path, assignment_id)
|
|
1086
|
+
expanded_path = File.expand_path(config_path)
|
|
1087
|
+
parent_dir = File.dirname(expanded_path)
|
|
1088
|
+
|
|
1089
|
+
# Keep pre-rendered hidden/job specs and legacy step archives stable.
|
|
1090
|
+
return expanded_path if %w[jobs steps].include?(File.basename(parent_dir))
|
|
1091
|
+
|
|
1092
|
+
# Otherwise, move to task's jobs/ directory.
|
|
1093
|
+
jobs_dir = File.join(parent_dir, "jobs")
|
|
1094
|
+
FileUtils.mkdir_p(jobs_dir)
|
|
1095
|
+
|
|
1096
|
+
dest = File.join(jobs_dir, "#{assignment_id}-job.yml")
|
|
1097
|
+
FileUtils.mv(expanded_path, dest)
|
|
1098
|
+
dest
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
def rebalance_after_child_injection(assignment:, state:, parent_number:)
|
|
1102
|
+
current = state.current
|
|
1103
|
+
return unless current && current.number == parent_number
|
|
1104
|
+
|
|
1105
|
+
step_writer.mark_pending(current.file_path)
|
|
1106
|
+
rebalanced_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
1107
|
+
next_step = rebalanced_state.next_workable_in_subtree(parent_number)
|
|
1108
|
+
step_writer.mark_in_progress(next_step.file_path) if next_step
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# Normalize instructions to a string.
|
|
1112
|
+
# Accepts arrays (joined with newlines) or strings (returned as-is).
|
|
1113
|
+
#
|
|
1114
|
+
# @param instructions [Array<String>, String, nil] Raw instructions from config
|
|
1115
|
+
# @return [String] Normalized instruction text
|
|
1116
|
+
def normalize_instructions(instructions)
|
|
1117
|
+
return "" if instructions.nil?
|
|
1118
|
+
|
|
1119
|
+
instructions.is_a?(Array) ? instructions.join("\n") : instructions.to_s
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
def find_target_step_for_start(state, step_number, fork_root)
|
|
1123
|
+
target = state.find_by_number(step_number)
|
|
1124
|
+
raise StepErrors::NotFound, "Step #{step_number} not found in queue" unless target
|
|
1125
|
+
|
|
1126
|
+
if fork_root && !fork_root.empty?
|
|
1127
|
+
raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
|
|
1128
|
+
raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}." unless state.in_subtree?(fork_root, target.number)
|
|
1129
|
+
end
|
|
1130
|
+
raise StepErrors::InvalidState, "Cannot start step #{target.number}: status is #{target.status}, expected pending." unless target.status == :pending
|
|
1131
|
+
if state.has_incomplete_children?(target.number)
|
|
1132
|
+
raise StepErrors::InvalidState, "Cannot start step #{target.number}: has incomplete children."
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
target
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
def find_target_step_for_finish(state, step_number, fork_root)
|
|
1139
|
+
fork_root = fork_root&.strip
|
|
1140
|
+
if step_number && !step_number.to_s.strip.empty?
|
|
1141
|
+
target = state.find_by_number(step_number)
|
|
1142
|
+
raise StepErrors::NotFound, "Step #{step_number} not found in queue" unless target
|
|
1143
|
+
if fork_root && !fork_root.empty? && !state.in_subtree?(fork_root, target.number)
|
|
1144
|
+
raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}."
|
|
1145
|
+
end
|
|
1146
|
+
raise StepErrors::InvalidState, "Cannot finish step #{target.number}: status is #{target.status}, expected in_progress." unless target.status == :in_progress
|
|
1147
|
+
|
|
1148
|
+
return target
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
current = state.current
|
|
1152
|
+
if fork_root && !fork_root.empty?
|
|
1153
|
+
raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
|
|
1154
|
+
active_in_subtree = state.in_progress_in_subtree(fork_root)
|
|
1155
|
+
if active_in_subtree.size > 1
|
|
1156
|
+
active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
|
|
1157
|
+
raise StepErrors::InvalidState, "Cannot finish in subtree #{fork_root}: multiple steps are in progress (#{active_refs})."
|
|
1158
|
+
end
|
|
1159
|
+
if current.nil? || !state.in_subtree?(fork_root, current.number)
|
|
1160
|
+
current = state.current_in_subtree(fork_root)
|
|
1161
|
+
end
|
|
1162
|
+
return nil if current.nil?
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
current
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# Auto-complete parent steps when all their children are done.
|
|
1169
|
+
# Walks up the hierarchy marking parents as done, handling multi-level
|
|
1170
|
+
# completion in a single pass (grandparents become eligible when parents complete).
|
|
1171
|
+
#
|
|
1172
|
+
# @param state [Models::QueueState] Current queue state
|
|
1173
|
+
# @param assignment [Models::Assignment] Current assignment
|
|
1174
|
+
def auto_complete_parents(state, assignment)
|
|
1175
|
+
completed_any = true
|
|
1176
|
+
# Track completed step numbers in this pass (avoids fragile ivar mutation)
|
|
1177
|
+
completed_this_pass = Set.new
|
|
1178
|
+
|
|
1179
|
+
# Safety guard: max iterations = total steps to prevent infinite loops
|
|
1180
|
+
max_iterations = state.steps.size
|
|
1181
|
+
|
|
1182
|
+
# Loop until no more parents can be completed
|
|
1183
|
+
# This handles multi-level hierarchies where completing a parent
|
|
1184
|
+
# makes the grandparent eligible for completion
|
|
1185
|
+
iterations = 0
|
|
1186
|
+
while completed_any && iterations < max_iterations
|
|
1187
|
+
iterations += 1
|
|
1188
|
+
completed_any = false
|
|
1189
|
+
|
|
1190
|
+
# Find all pending/in_progress parent steps that have children
|
|
1191
|
+
eligible_parents = state.steps.select do |s|
|
|
1192
|
+
(s.status == :pending || s.status == :in_progress) &&
|
|
1193
|
+
!completed_this_pass.include?(s.number)
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
eligible_parents.each do |step|
|
|
1197
|
+
children = state.children_of(step.number)
|
|
1198
|
+
next if children.empty?
|
|
1199
|
+
|
|
1200
|
+
# If all children are done (or completed this pass), mark parent as done too
|
|
1201
|
+
all_done = children.all? do |c|
|
|
1202
|
+
c.status == :done || completed_this_pass.include?(c.number)
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
if all_done
|
|
1206
|
+
step_writer.mark_done(
|
|
1207
|
+
step.file_path,
|
|
1208
|
+
report_content: "Auto-completed: all child steps finished.",
|
|
1209
|
+
reports_dir: assignment.reports_dir
|
|
1210
|
+
)
|
|
1211
|
+
completed_this_pass << step.number
|
|
1212
|
+
completed_any = true
|
|
1213
|
+
end
|
|
1214
|
+
end
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
# Warn if safety limit was reached while still completing parents
|
|
1218
|
+
if iterations >= max_iterations && completed_any
|
|
1219
|
+
warn "[ace-assign] Warning: auto_complete_parents reached iteration limit (#{max_iterations}). " \
|
|
1220
|
+
"Some parent steps may not have been auto-completed."
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
# Find the next step to work on using hierarchical rules.
|
|
1225
|
+
#
|
|
1226
|
+
# @param state [Models::QueueState] Current queue state
|
|
1227
|
+
# @param completed_number [String] Number of just-completed step
|
|
1228
|
+
# @return [Models::Step, nil] Next step to work on
|
|
1229
|
+
def find_next_step(state, completed_number)
|
|
1230
|
+
# First priority: pending children of the completed step
|
|
1231
|
+
children = state.children_of(completed_number)
|
|
1232
|
+
pending_child = children.find { |c| c.status == :pending }
|
|
1233
|
+
return pending_child if pending_child
|
|
1234
|
+
|
|
1235
|
+
# Second priority: next workable step (respects hierarchy)
|
|
1236
|
+
# Uses next_workable to skip parents that have incomplete children
|
|
1237
|
+
state.next_workable
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
# Find next step within a constrained subtree.
|
|
1241
|
+
#
|
|
1242
|
+
# @param state [Models::QueueState] Current queue state
|
|
1243
|
+
# @param completed_number [String] Number of just-completed step
|
|
1244
|
+
# @param root_number [String] Root of fork-scoped subtree
|
|
1245
|
+
# @return [Models::Step, nil] Next step in subtree, or nil when subtree done
|
|
1246
|
+
def find_next_step_in_subtree(state, completed_number, root_number)
|
|
1247
|
+
# First priority: pending direct children of completed step within subtree
|
|
1248
|
+
children = state.children_of(completed_number)
|
|
1249
|
+
pending_child = children.find { |c| c.status == :pending && state.in_subtree?(root_number, c.number) }
|
|
1250
|
+
return pending_child if pending_child
|
|
1251
|
+
|
|
1252
|
+
# Second priority: next workable step within subtree
|
|
1253
|
+
state.next_workable_in_subtree(root_number)
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
# Calculate insertion point for a new step.
|
|
1257
|
+
#
|
|
1258
|
+
# @param after [String, nil] Insert after this step number
|
|
1259
|
+
# @param as_child [Boolean] Insert as child (true) or sibling (false)
|
|
1260
|
+
# @param state [Models::QueueState] Current queue state
|
|
1261
|
+
# @param existing_numbers [Array<String>] Existing step numbers
|
|
1262
|
+
# @return [Array<String, Array>] [new_number, steps_to_renumber]
|
|
1263
|
+
def calculate_insertion_point(after:, as_child:, state:, existing_numbers:)
|
|
1264
|
+
if after
|
|
1265
|
+
if as_child
|
|
1266
|
+
# Insert as first child of 'after'
|
|
1267
|
+
new_number = Atoms::StepNumbering.next_child(after, existing_numbers)
|
|
1268
|
+
[new_number, []]
|
|
1269
|
+
else
|
|
1270
|
+
# Insert as sibling after 'after'
|
|
1271
|
+
new_number = Atoms::StepNumbering.next_sibling(after)
|
|
1272
|
+
|
|
1273
|
+
# Check if this number already exists
|
|
1274
|
+
if existing_numbers.include?(new_number)
|
|
1275
|
+
# Need to renumber
|
|
1276
|
+
renumber_list = Atoms::StepNumbering.steps_to_renumber(new_number, existing_numbers)
|
|
1277
|
+
[new_number, renumber_list]
|
|
1278
|
+
else
|
|
1279
|
+
[new_number, []]
|
|
1280
|
+
end
|
|
1281
|
+
end
|
|
1282
|
+
else
|
|
1283
|
+
# Default behavior: insert after current or last done
|
|
1284
|
+
base_number = if state.current
|
|
1285
|
+
state.current.number
|
|
1286
|
+
elsif state.last_done
|
|
1287
|
+
state.last_done.number
|
|
1288
|
+
else
|
|
1289
|
+
"000" # Will generate 001
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
new_number = Atoms::NumberGenerator.next_after(base_number, existing_numbers)
|
|
1293
|
+
[new_number, []]
|
|
1294
|
+
end
|
|
1295
|
+
end
|
|
1296
|
+
end
|
|
1297
|
+
end
|
|
1298
|
+
end
|
|
1299
|
+
end
|