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,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
# Add a new step dynamically
|
|
8
|
+
#
|
|
9
|
+
# Supports hierarchical step injection:
|
|
10
|
+
# - Default: adds after current/last done step
|
|
11
|
+
# - --after: adds as sibling after specified step
|
|
12
|
+
# - --after + --child: adds as child of specified step
|
|
13
|
+
#
|
|
14
|
+
# @example Add step after current
|
|
15
|
+
# ace-assign add fix-tests -i "Fix the failing tests"
|
|
16
|
+
#
|
|
17
|
+
# @example Inject sibling after specific step
|
|
18
|
+
# ace-assign add verify --after 010 -i "Verify initialization"
|
|
19
|
+
# # Creates 011-verify.st.md (renumbers existing 011+ if needed)
|
|
20
|
+
#
|
|
21
|
+
# @example Inject child step
|
|
22
|
+
# ace-assign add verify --after 010 --child -i "Verify"
|
|
23
|
+
# # Creates 010.01-verify.st.md
|
|
24
|
+
class Add < Ace::Support::Cli::Command
|
|
25
|
+
include Ace::Support::Cli::Base
|
|
26
|
+
include AssignmentTarget
|
|
27
|
+
|
|
28
|
+
desc "Add a new step to the queue dynamically"
|
|
29
|
+
|
|
30
|
+
argument :name, required: true, desc: "Step name"
|
|
31
|
+
option :instructions, aliases: ["-i"], desc: "Step instructions"
|
|
32
|
+
option :after, aliases: ["-a"], desc: "Insert after this step number (e.g., 010)"
|
|
33
|
+
option :child, aliases: ["-c"], type: :boolean, default: false, desc: "Insert as child of --after step"
|
|
34
|
+
option :assignment, desc: "Target specific assignment ID"
|
|
35
|
+
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
36
|
+
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
37
|
+
|
|
38
|
+
def call(name:, **options)
|
|
39
|
+
# Validate: --child requires --after
|
|
40
|
+
if options[:child] && !options[:after]
|
|
41
|
+
raise Ace::Support::Cli::Error, "--child requires --after to specify the parent step"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate: adding child would not exceed MAX_DEPTH
|
|
45
|
+
if options[:child] && options[:after]
|
|
46
|
+
parsed = Atoms::StepNumbering.parse(options[:after])
|
|
47
|
+
max_depth = Atoms::StepNumbering::MAX_DEPTH
|
|
48
|
+
if parsed[:depth] >= max_depth
|
|
49
|
+
raise Ace::Support::Cli::Error,
|
|
50
|
+
"Cannot add child: would exceed maximum nesting depth of #{max_depth + 1} levels " \
|
|
51
|
+
"(parent '#{options[:after]}' is at depth #{parsed[:depth]})"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
instructions = options[:instructions] || "Complete this step and finish with: ace-assign finish --message report.md"
|
|
56
|
+
|
|
57
|
+
target = resolve_assignment_target(options)
|
|
58
|
+
executor = build_executor_for_target(target)
|
|
59
|
+
result = executor.add(
|
|
60
|
+
name,
|
|
61
|
+
instructions,
|
|
62
|
+
after: options[:after],
|
|
63
|
+
as_child: options[:child]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
unless options[:quiet]
|
|
67
|
+
added = result[:added]
|
|
68
|
+
puts "Created: steps/#{File.basename(added.file_path)}"
|
|
69
|
+
puts "Number: #{added.number}"
|
|
70
|
+
puts "Status: #{added.status}"
|
|
71
|
+
|
|
72
|
+
if options[:after]
|
|
73
|
+
if options[:child]
|
|
74
|
+
puts "Relationship: child of #{options[:after]}"
|
|
75
|
+
else
|
|
76
|
+
puts "Relationship: sibling after #{options[:after]}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if result[:renumbered]&.any?
|
|
81
|
+
puts
|
|
82
|
+
puts "Renumbered steps:"
|
|
83
|
+
result[:renumbered].each do |old_num|
|
|
84
|
+
new_num = Atoms::StepNumbering.shift_number(old_num, 1)
|
|
85
|
+
puts " #{old_num} -> #{new_num}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if added.status == :in_progress
|
|
90
|
+
puts
|
|
91
|
+
puts "Instructions:"
|
|
92
|
+
puts added.instructions
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
# Shared parsing/helpers for --assignment target.
|
|
8
|
+
#
|
|
9
|
+
# Supported syntax:
|
|
10
|
+
# - <assignment-id>
|
|
11
|
+
# - <assignment-id>@<step-number>
|
|
12
|
+
module AssignmentTarget
|
|
13
|
+
Target = Struct.new(:assignment_id, :scope, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def resolve_assignment_target(options)
|
|
18
|
+
assignment_raw = options[:assignment]
|
|
19
|
+
unless assignment_raw.nil? || assignment_raw.to_s.strip.empty?
|
|
20
|
+
return parse_assignment_target(assignment_raw)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Target.new(assignment_id: nil, scope: nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse_assignment_target(raw)
|
|
27
|
+
value = raw.to_s.strip
|
|
28
|
+
raise Ace::Support::Cli::Error, "Assignment target cannot be empty" if value.empty?
|
|
29
|
+
|
|
30
|
+
assignment_id, scope = value.split("@", 2)
|
|
31
|
+
assignment_id = assignment_id&.strip
|
|
32
|
+
scope = scope&.strip
|
|
33
|
+
|
|
34
|
+
raise Ace::Support::Cli::Error, "Assignment target requires assignment ID before '@'" if assignment_id.nil? || assignment_id.empty?
|
|
35
|
+
raise Ace::Support::Cli::Error, "Assignment target scope after '@' cannot be empty" if value.include?("@") && (scope.nil? || scope.empty?)
|
|
36
|
+
|
|
37
|
+
Target.new(assignment_id: assignment_id, scope: scope)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_executor_for_target(target)
|
|
41
|
+
return Organisms::AssignmentExecutor.new unless target.assignment_id
|
|
42
|
+
|
|
43
|
+
manager = Molecules::AssignmentManager.new
|
|
44
|
+
assignment = manager.load(target.assignment_id)
|
|
45
|
+
raise AssignmentErrors::NotFound, "Assignment '#{target.assignment_id}' not found" unless assignment
|
|
46
|
+
|
|
47
|
+
executor = Organisms::AssignmentExecutor.new
|
|
48
|
+
executor.assignment_manager.define_singleton_method(:find_active) { assignment }
|
|
49
|
+
executor
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
# Create a new workflow assignment from config file
|
|
8
|
+
class Create < Ace::Support::Cli::Command
|
|
9
|
+
include Ace::Support::Cli::Base
|
|
10
|
+
|
|
11
|
+
desc "Create a new workflow assignment from YAML config"
|
|
12
|
+
|
|
13
|
+
argument :config, required: true, desc: "Path to job.yaml config file"
|
|
14
|
+
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
15
|
+
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
16
|
+
|
|
17
|
+
def call(config:, **options)
|
|
18
|
+
executor = Organisms::AssignmentExecutor.new
|
|
19
|
+
result = executor.start(config)
|
|
20
|
+
|
|
21
|
+
unless options[:quiet]
|
|
22
|
+
print_assignment_header(result[:assignment])
|
|
23
|
+
print_step_instructions(result[:current])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def print_assignment_header(assignment)
|
|
30
|
+
puts "Assignment: #{assignment.name} (#{assignment.id})"
|
|
31
|
+
puts "Created: #{display_path(assignment.cache_dir)}/"
|
|
32
|
+
if hidden_spec_path?(assignment.source_config)
|
|
33
|
+
puts "Created from hidden spec: #{display_path(assignment.source_config)}"
|
|
34
|
+
end
|
|
35
|
+
puts
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def hidden_spec_path?(path)
|
|
39
|
+
expanded = File.expand_path(path.to_s).tr("\\", "/")
|
|
40
|
+
expanded.include?("/.ace-local/assign/jobs/")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def display_path(path)
|
|
44
|
+
expanded = File.expand_path(path.to_s).tr("\\", "/")
|
|
45
|
+
cwd = Dir.pwd.tr("\\", "/")
|
|
46
|
+
return expanded unless expanded.start_with?("#{cwd}/")
|
|
47
|
+
|
|
48
|
+
expanded.delete_prefix("#{cwd}/")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def print_step_instructions(step)
|
|
52
|
+
return unless step
|
|
53
|
+
|
|
54
|
+
puts "Step #{step.number}: #{step.name} [#{step.status}]"
|
|
55
|
+
puts
|
|
56
|
+
puts "Instructions:"
|
|
57
|
+
puts step.instructions
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
# Mark current step as failed
|
|
8
|
+
class Fail < Ace::Support::Cli::Command
|
|
9
|
+
include Ace::Support::Cli::Base
|
|
10
|
+
include AssignmentTarget
|
|
11
|
+
|
|
12
|
+
desc "Mark current step as failed"
|
|
13
|
+
|
|
14
|
+
option :message, aliases: ["-m"], required: true, desc: "Error message"
|
|
15
|
+
option :assignment, desc: "Target specific assignment ID"
|
|
16
|
+
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
17
|
+
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
18
|
+
|
|
19
|
+
def call(**options)
|
|
20
|
+
message = options[:message]
|
|
21
|
+
|
|
22
|
+
target = resolve_assignment_target(options)
|
|
23
|
+
executor = build_executor_for_target(target)
|
|
24
|
+
result = executor.fail(message)
|
|
25
|
+
|
|
26
|
+
unless options[:quiet]
|
|
27
|
+
failed = result[:failed]
|
|
28
|
+
puts "Step #{failed.number} (#{failed.name}) marked as failed"
|
|
29
|
+
puts "Updated: #{File.basename(failed.file_path)}"
|
|
30
|
+
puts "Error: #{message}"
|
|
31
|
+
puts
|
|
32
|
+
puts "Options:"
|
|
33
|
+
puts "- ace-assign add \"fix-step\" to add a fix step"
|
|
34
|
+
puts "- ace-assign retry #{failed.number} to retry this step"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
# Complete in-progress step with report content
|
|
8
|
+
class Finish < Ace::Support::Cli::Command
|
|
9
|
+
include Ace::Support::Cli::Base
|
|
10
|
+
include AssignmentTarget
|
|
11
|
+
|
|
12
|
+
desc "Complete in-progress step with report content"
|
|
13
|
+
|
|
14
|
+
argument :step, required: false, desc: "Step number to finish (active assignment only)"
|
|
15
|
+
option :message, aliases: ["-m"], desc: "Report content: string, file path, or pipe stdin"
|
|
16
|
+
option :assignment, desc: "Target specific assignment ID"
|
|
17
|
+
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
18
|
+
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
19
|
+
|
|
20
|
+
def call(step: nil, **options)
|
|
21
|
+
if step && options[:assignment]
|
|
22
|
+
raise Error, "Positional STEP targeting is only supported for active assignment. Use --assignment without STEP for cross-assignment finish."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
target = resolve_assignment_target(options)
|
|
26
|
+
executor = build_executor_for_target(target)
|
|
27
|
+
report_content = resolve_report_content(options)
|
|
28
|
+
result = executor.finish_step(
|
|
29
|
+
report_content: report_content,
|
|
30
|
+
step_number: step,
|
|
31
|
+
fork_root: target.scope
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return if options[:quiet]
|
|
35
|
+
|
|
36
|
+
completed = result[:completed]
|
|
37
|
+
puts "Step #{completed.number} (#{completed.name}) completed"
|
|
38
|
+
|
|
39
|
+
assignment = result[:assignment]
|
|
40
|
+
report_filename = Atoms::StepFileParser.generate_report_filename(completed.number, completed.name)
|
|
41
|
+
report_path = File.join(assignment.reports_dir, report_filename)
|
|
42
|
+
puts "Report saved to: #{report_path}"
|
|
43
|
+
|
|
44
|
+
if result[:current]
|
|
45
|
+
puts "Advancing to step #{result[:current].number}: #{result[:current].name}"
|
|
46
|
+
puts
|
|
47
|
+
puts "Instructions:"
|
|
48
|
+
puts result[:current].instructions
|
|
49
|
+
else
|
|
50
|
+
puts
|
|
51
|
+
fork_root = target.scope&.strip
|
|
52
|
+
if fork_root && result[:state].subtree_complete?(fork_root)
|
|
53
|
+
puts "Fork subtree #{fork_root} completed."
|
|
54
|
+
elsif result[:state].complete?
|
|
55
|
+
puts "Assignment completed! All steps done."
|
|
56
|
+
else
|
|
57
|
+
puts "No active step selected."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def resolve_report_content(options)
|
|
65
|
+
message = options[:message]&.strip
|
|
66
|
+
return File.read(message) if message && !message.empty? && File.exist?(message)
|
|
67
|
+
return message if message && !message.empty?
|
|
68
|
+
|
|
69
|
+
content = read_stdin_if_piped
|
|
70
|
+
|
|
71
|
+
raise Error, "Missing report input: provide --message <string|file> or pipe stdin." if content.nil? || content.strip.empty?
|
|
72
|
+
|
|
73
|
+
content
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_stdin_if_piped
|
|
77
|
+
stdin = $stdin
|
|
78
|
+
return nil unless stdin.respond_to?(:tty?) && !stdin.tty?
|
|
79
|
+
|
|
80
|
+
stdin.read
|
|
81
|
+
rescue IOError, Errno::EBADF
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Prepare and validate a subtree-scoped fork execution session.
|
|
11
|
+
class ForkRun < Ace::Support::Cli::Command
|
|
12
|
+
include Ace::Support::Cli::Base
|
|
13
|
+
include AssignmentTarget
|
|
14
|
+
|
|
15
|
+
STALL_REASON_MAX = 2000
|
|
16
|
+
|
|
17
|
+
desc "Prepare fork execution for an entire subtree"
|
|
18
|
+
|
|
19
|
+
option :root, desc: "Fork subtree root step number (e.g., 010.01)"
|
|
20
|
+
option :assignment, desc: "Target specific assignment ID"
|
|
21
|
+
option :provider, desc: "LLM provider:model override (e.g., codex:gpt-5, claude:sonnet)"
|
|
22
|
+
option :cli_args, desc: "Extra CLI args for provider process"
|
|
23
|
+
option :timeout, type: :integer, desc: "Execution timeout in seconds"
|
|
24
|
+
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
25
|
+
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
26
|
+
|
|
27
|
+
def initialize(launcher: nil)
|
|
28
|
+
super()
|
|
29
|
+
@launcher = launcher || Molecules::ForkSessionLauncher.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(**options)
|
|
33
|
+
target = resolve_assignment_target(options)
|
|
34
|
+
executor = build_executor_for_target(target)
|
|
35
|
+
result = executor.status
|
|
36
|
+
assignment = result[:assignment]
|
|
37
|
+
state = result[:state]
|
|
38
|
+
current = result[:current]
|
|
39
|
+
|
|
40
|
+
root_step = resolve_root_step(state, current, options[:root], target.scope)
|
|
41
|
+
ensure_root_is_fork!(root_step)
|
|
42
|
+
|
|
43
|
+
if state.subtree_complete?(root_step.number)
|
|
44
|
+
puts "Subtree #{root_step.number} is already complete." unless options[:quiet]
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
unless options[:quiet]
|
|
49
|
+
next_step = state.next_workable_in_subtree(root_step.number)
|
|
50
|
+
puts "Starting fork subtree execution: #{root_step.number} - #{root_step.name}"
|
|
51
|
+
puts "Assignment: #{assignment.id}"
|
|
52
|
+
puts "Provider: #{options[:provider] || Ace::Assign.config.dig("execution", "provider") || Molecules::ForkSessionLauncher::DEFAULT_PROVIDER}"
|
|
53
|
+
puts "Timeout: #{options[:timeout] || Ace::Assign.config.dig("execution", "timeout") || Molecules::ForkSessionLauncher::DEFAULT_TIMEOUT}s"
|
|
54
|
+
puts "Next step: #{next_step.number} - #{next_step.name}" if next_step
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
active_in_subtree = state.in_progress_in_subtree(root_step.number)
|
|
58
|
+
if active_in_subtree.size > 1
|
|
59
|
+
active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
|
|
60
|
+
raise StepErrors::InvalidState, "Cannot fork-run subtree #{root_step.number}: multiple steps are already in progress (#{active_refs})."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Mark the next workable step as in_progress only when no subtree step is active.
|
|
64
|
+
# For leaf fork roots, this activates the root itself.
|
|
65
|
+
if active_in_subtree.empty?
|
|
66
|
+
first_workable = state.next_workable_in_subtree(root_step.number)
|
|
67
|
+
if first_workable
|
|
68
|
+
step_writer = Molecules::StepWriter.new
|
|
69
|
+
step_writer.mark_in_progress(first_workable.file_path)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
launch_result = launcher.launch(
|
|
74
|
+
assignment_id: assignment.id,
|
|
75
|
+
fork_root: root_step.number,
|
|
76
|
+
provider: options[:provider],
|
|
77
|
+
cli_args: options[:cli_args],
|
|
78
|
+
timeout: options[:timeout],
|
|
79
|
+
cache_dir: assignment.cache_dir
|
|
80
|
+
)
|
|
81
|
+
record_fork_pid_info(root_step, launch_result)
|
|
82
|
+
|
|
83
|
+
refreshed = executor.status
|
|
84
|
+
refreshed_state = refreshed[:state]
|
|
85
|
+
|
|
86
|
+
if refreshed_state.subtree_failed?(root_step.number)
|
|
87
|
+
failed = refreshed_state.subtree_steps(root_step.number).select { |s| s.status == :failed }
|
|
88
|
+
failed_refs = failed.map { |p| "#{p.number}(#{p.name})" }.join(", ")
|
|
89
|
+
raise Error, "Fork subtree #{root_step.number} failed: #{failed_refs}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
unless refreshed_state.subtree_complete?(root_step.number)
|
|
93
|
+
active = refreshed_state.in_progress_in_subtree(root_step.number).first || refreshed_state.current
|
|
94
|
+
active_msg = active ? " Current step: #{active.number} (#{active.name})." : ""
|
|
95
|
+
last_msg = read_last_message(assignment.cache_dir, root_step.number)
|
|
96
|
+
stall_reason = build_stall_reason(last_msg)
|
|
97
|
+
session_meta = read_session_metadata(assignment.cache_dir, root_step.number)
|
|
98
|
+
|
|
99
|
+
if stall_reason && active
|
|
100
|
+
step_writer = Molecules::StepWriter.new
|
|
101
|
+
step_writer.update_frontmatter(active.file_path, {"stall_reason" => stall_reason})
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
session_info = session_meta&.dig("session_id") ? " Session: #{session_meta["session_id"]}" : ""
|
|
105
|
+
error_msg = "Fork subtree #{root_step.number} did not complete within spawned session.#{active_msg}#{session_info}"
|
|
106
|
+
error_msg += "\n\nAgent's last message:\n#{stall_reason}" if stall_reason
|
|
107
|
+
raise Error, error_msg
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Clear any stale stall_reason left by a previous failed attempt.
|
|
111
|
+
stale_steps = refreshed_state.subtree_steps(root_step.number).select(&:stall_reason)
|
|
112
|
+
if stale_steps.any?
|
|
113
|
+
step_writer = Molecules::StepWriter.new
|
|
114
|
+
stale_steps.each do |step|
|
|
115
|
+
step_writer.update_frontmatter(step.file_path, {"stall_reason" => nil})
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
puts "Fork subtree #{root_step.number} completed successfully." unless options[:quiet]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
attr_reader :launcher
|
|
125
|
+
|
|
126
|
+
def read_session_metadata(cache_dir, fork_root)
|
|
127
|
+
return nil unless cache_dir
|
|
128
|
+
|
|
129
|
+
meta_file = File.join(cache_dir, "sessions", "#{fork_root}-session.yml")
|
|
130
|
+
return nil unless File.exist?(meta_file)
|
|
131
|
+
|
|
132
|
+
YAML.safe_load_file(meta_file)
|
|
133
|
+
rescue SystemCallError, Psych::SyntaxError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def read_last_message(cache_dir, fork_root)
|
|
138
|
+
return nil unless cache_dir
|
|
139
|
+
|
|
140
|
+
last_msg_file = File.join(cache_dir, "sessions", "#{fork_root}-last-message.md")
|
|
141
|
+
return nil unless File.exist?(last_msg_file)
|
|
142
|
+
|
|
143
|
+
content = File.read(last_msg_file).strip
|
|
144
|
+
content.empty? ? nil : content
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_stall_reason(last_msg)
|
|
150
|
+
return nil unless last_msg
|
|
151
|
+
|
|
152
|
+
if last_msg.length > STALL_REASON_MAX
|
|
153
|
+
last_msg[0, STALL_REASON_MAX] + "... (truncated)"
|
|
154
|
+
else
|
|
155
|
+
last_msg
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def resolve_root_step(state, current, explicit_root, scoped_root)
|
|
160
|
+
if explicit_root && scoped_root && explicit_root.strip != scoped_root.strip
|
|
161
|
+
raise Error, "Conflicting subtree roots: --root #{explicit_root.strip} and scope #{scoped_root.strip}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
root_ref = explicit_root&.strip
|
|
165
|
+
root_ref = scoped_root&.strip if root_ref.nil? || root_ref.empty?
|
|
166
|
+
|
|
167
|
+
if root_ref && !root_ref.empty?
|
|
168
|
+
root = state.find_by_number(root_ref)
|
|
169
|
+
raise StepErrors::NotFound, "Step #{root_ref} not found in queue" unless root
|
|
170
|
+
|
|
171
|
+
return root
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Fallback for legacy behavior when no root is explicitly scoped.
|
|
175
|
+
raise Error, "No current step. Use --root <step-number> or --assignment <id>@<step-number>." unless current
|
|
176
|
+
|
|
177
|
+
root = state.nearest_fork_ancestor(current.number)
|
|
178
|
+
raise Error, "Current step is not in a forked subtree. Provide --root or --assignment <id>@<step-number>." unless root
|
|
179
|
+
|
|
180
|
+
root
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def ensure_root_is_fork!(root_step)
|
|
184
|
+
return if root_step.fork?
|
|
185
|
+
|
|
186
|
+
raise Error, "Step #{root_step.number} is not fork-enabled (context: fork missing)."
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def record_fork_pid_info(root_step, launch_result)
|
|
190
|
+
pid_info = launch_result.is_a?(Hash) ? launch_result[:fork_pid_info] : nil
|
|
191
|
+
return unless pid_info
|
|
192
|
+
|
|
193
|
+
pid_file = write_pid_file(root_step, pid_info)
|
|
194
|
+
step_writer = Molecules::StepWriter.new
|
|
195
|
+
step_writer.record_fork_pid_info(
|
|
196
|
+
root_step.file_path,
|
|
197
|
+
launch_pid: pid_info[:launch_pid] || Process.pid,
|
|
198
|
+
tracked_pids: pid_info[:tracked_pids] || [],
|
|
199
|
+
pid_file: pid_file
|
|
200
|
+
)
|
|
201
|
+
rescue
|
|
202
|
+
# Keep fork-run resilient even if telemetry persistence fails.
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def write_pid_file(root_step, pid_info)
|
|
207
|
+
step_dir = File.dirname(root_step.file_path)
|
|
208
|
+
assignment_dir = File.expand_path("..", step_dir)
|
|
209
|
+
pids_dir = File.join(assignment_dir, "pids")
|
|
210
|
+
FileUtils.mkdir_p(pids_dir)
|
|
211
|
+
|
|
212
|
+
step_ref = root_step.number.to_s.gsub(/[^0-9.]/, "")
|
|
213
|
+
file_name = "#{step_ref}.pid.yml"
|
|
214
|
+
pid_file = File.join(pids_dir, file_name)
|
|
215
|
+
payload = {
|
|
216
|
+
"assignment_step" => root_step.number,
|
|
217
|
+
"step_name" => root_step.name,
|
|
218
|
+
"launch_pid" => (pid_info[:launch_pid] || Process.pid).to_i,
|
|
219
|
+
"tracked_pids" => Array(pid_info[:tracked_pids]).map(&:to_i).uniq.sort,
|
|
220
|
+
"captured_at" => Time.now.utc.iso8601
|
|
221
|
+
}
|
|
222
|
+
File.write(pid_file, payload.to_yaml)
|
|
223
|
+
pid_file
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|