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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
  3. data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
  4. data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
  5. data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
  6. data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
  7. data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
  8. data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
  9. data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
  10. data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
  11. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
  12. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
  13. data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
  14. data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
  15. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
  16. data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
  17. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
  18. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
  19. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
  20. data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
  21. data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
  22. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
  23. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
  24. data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
  25. data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
  26. data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
  27. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
  28. data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
  29. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
  30. data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
  31. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
  32. data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
  33. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
  34. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
  35. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
  36. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
  37. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
  38. data/.ace-defaults/assign/config.yml +48 -0
  39. data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
  40. data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
  41. data/.ace-defaults/assign/presets/release-only.yml +35 -0
  42. data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
  43. data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
  44. data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
  45. data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
  46. data/CHANGELOG.md +1415 -0
  47. data/README.md +87 -0
  48. data/Rakefile +16 -0
  49. data/docs/exit-codes.md +61 -0
  50. data/docs/getting-started.md +121 -0
  51. data/docs/handbook.md +40 -0
  52. data/docs/usage.md +224 -0
  53. data/exe/ace-assign +16 -0
  54. data/handbook/guides/fork-context.g.md +231 -0
  55. data/handbook/skills/as-assign-compose/SKILL.md +24 -0
  56. data/handbook/skills/as-assign-create/SKILL.md +23 -0
  57. data/handbook/skills/as-assign-drive/SKILL.md +24 -0
  58. data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
  59. data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
  60. data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
  61. data/handbook/skills/as-assign-start/SKILL.md +25 -0
  62. data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
  63. data/handbook/workflow-instructions/assign/create.wf.md +215 -0
  64. data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
  65. data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
  66. data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
  67. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
  68. data/handbook/workflow-instructions/assign/start.wf.md +46 -0
  69. data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
  70. data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
  71. data/lib/ace/assign/atoms/composition_rules.rb +219 -0
  72. data/lib/ace/assign/atoms/number_generator.rb +110 -0
  73. data/lib/ace/assign/atoms/preset_expander.rb +277 -0
  74. data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
  75. data/lib/ace/assign/atoms/step_numbering.rb +227 -0
  76. data/lib/ace/assign/atoms/step_sorter.rb +66 -0
  77. data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
  78. data/lib/ace/assign/cli/commands/add.rb +102 -0
  79. data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
  80. data/lib/ace/assign/cli/commands/create.rb +63 -0
  81. data/lib/ace/assign/cli/commands/fail.rb +43 -0
  82. data/lib/ace/assign/cli/commands/finish.rb +88 -0
  83. data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
  84. data/lib/ace/assign/cli/commands/list.rb +166 -0
  85. data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
  86. data/lib/ace/assign/cli/commands/select.rb +45 -0
  87. data/lib/ace/assign/cli/commands/start.rb +40 -0
  88. data/lib/ace/assign/cli/commands/status.rb +407 -0
  89. data/lib/ace/assign/cli.rb +144 -0
  90. data/lib/ace/assign/models/assignment.rb +107 -0
  91. data/lib/ace/assign/models/assignment_info.rb +66 -0
  92. data/lib/ace/assign/models/queue_state.rb +326 -0
  93. data/lib/ace/assign/models/step.rb +197 -0
  94. data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
  95. data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
  96. data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
  97. data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
  98. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
  99. data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
  100. data/lib/ace/assign/molecules/step_writer.rb +246 -0
  101. data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
  102. data/lib/ace/assign/version.rb +7 -0
  103. data/lib/ace/assign.rb +141 -0
  104. 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