ace-assign 0.53.4 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
  3. data/.ace-defaults/assign/config.yml +1 -0
  4. data/.ace-defaults/assign/presets/work-on-task.yml +3 -0
  5. data/CHANGELOG.md +15 -0
  6. data/docs/demo/fork-provider.cast +814 -937
  7. data/docs/demo/fork-provider.gif +0 -0
  8. data/docs/demo/fork-provider.recording.json +15 -17
  9. data/docs/demo/fork-provider.tape.yml +16 -4
  10. data/docs/usage.md +30 -7
  11. data/handbook/guides/fork-context.g.md +29 -5
  12. data/handbook/skills/as-assign-drive/SKILL.md +2 -2
  13. data/handbook/workflow-instructions/assign/drive.wf.md +109 -36
  14. data/handbook/workflow-instructions/assign/prepare.wf.md +5 -0
  15. data/lib/ace/assign/atoms/preset_expander.rb +4 -0
  16. data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
  17. data/lib/ace/assign/cli/commands/add.rb +20 -11
  18. data/lib/ace/assign/cli/commands/assignment_target.rb +49 -18
  19. data/lib/ace/assign/cli/commands/create.rb +1 -1
  20. data/lib/ace/assign/cli/commands/fail.rb +1 -1
  21. data/lib/ace/assign/cli/commands/finish.rb +26 -5
  22. data/lib/ace/assign/cli/commands/fork_run.rb +56 -17
  23. data/lib/ace/assign/cli/commands/list.rb +4 -3
  24. data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
  25. data/lib/ace/assign/cli/commands/status.rb +60 -34
  26. data/lib/ace/assign/cli/commands/step.rb +4 -4
  27. data/lib/ace/assign/cli.rb +1 -1
  28. data/lib/ace/assign/models/assignment_info.rb +33 -4
  29. data/lib/ace/assign/models/queue_state.rb +101 -39
  30. data/lib/ace/assign/models/step.rb +13 -3
  31. data/lib/ace/assign/molecules/fork_session_launcher.rb +76 -60
  32. data/lib/ace/assign/molecules/step_writer.rb +3 -3
  33. data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
  34. data/lib/ace/assign/organisms/assignment_executor.rb +132 -82
  35. data/lib/ace/assign/version.rb +1 -1
  36. data/lib/ace/assign.rb +1 -0
  37. metadata +17 -3
  38. data/lib/ace/assign/molecules/tmux_fork_runner.rb +0 -191
@@ -35,11 +35,11 @@ module Ace
35
35
  mode = selected_mode(options)
36
36
  case mode
37
37
  when :yaml
38
- handle_yaml_mode(executor, options)
38
+ handle_yaml_mode(executor, options, target)
39
39
  when :step
40
- handle_step_mode(executor, options)
40
+ handle_step_mode(executor, options, target)
41
41
  when :task
42
- handle_task_mode(executor, options)
42
+ handle_task_mode(executor, options, target)
43
43
  else
44
44
  raise Ace::Support::Cli::Error, "Exactly one of --yaml, --step, or --task is required"
45
45
  end
@@ -77,20 +77,21 @@ module Ace
77
77
  insertion_modes(options).find { |_, value| !value.to_s.strip.empty? }&.first
78
78
  end
79
79
 
80
- def handle_yaml_mode(executor, options)
80
+ def handle_yaml_mode(executor, options, target)
81
81
  yaml_path = options[:yaml].to_s.strip
82
82
  steps = load_steps_from_file(yaml_path)
83
83
  result = executor.add_batch(
84
84
  steps: steps,
85
85
  after: options[:after],
86
86
  as_child: options[:child],
87
- source_file: yaml_path
87
+ source_file: yaml_path,
88
+ fork_root: target.scope
88
89
  )
89
90
  print_yaml_result(result, yaml_path) unless options[:quiet]
90
91
  nil
91
92
  end
92
93
 
93
- def handle_step_mode(executor, options)
94
+ def handle_step_mode(executor, options, target)
94
95
  preset_name = resolve_preset_name(executor, options)
95
96
  preset = Atoms::PresetLoader.load(preset_name)
96
97
  requested = parse_step_names(options[:step])
@@ -111,7 +112,8 @@ module Ace
111
112
  steps: [step],
112
113
  after: options[:child] ? options[:after] : sibling_cursor,
113
114
  as_child: options[:child],
114
- source_file: "preset:#{preset_name}"
115
+ source_file: "preset:#{preset_name}",
116
+ fork_root: target.scope
115
117
  )
116
118
 
117
119
  inserted_added = Array(inserted[:added])
@@ -126,7 +128,7 @@ module Ace
126
128
  nil
127
129
  end
128
130
 
129
- def handle_task_mode(executor, options)
131
+ def handle_task_mode(executor, options, target)
130
132
  preset_name = resolve_preset_name(executor, options)
131
133
  preset = Atoms::PresetLoader.load(preset_name)
132
134
  task_ref = options[:task].to_s.strip
@@ -139,7 +141,8 @@ module Ace
139
141
  end
140
142
 
141
143
  parent_step = options[:after].to_s.strip
142
- parent_step = detect_batch_parent(executor) if parent_step.empty?
144
+ parent_step = target.scope.to_s.strip if parent_step.empty? && !target.scope.to_s.strip.empty?
145
+ parent_step = detect_batch_parent(executor, target) if parent_step.empty?
143
146
  if parent_step.to_s.strip.empty?
144
147
  raise Ace::Support::Cli::Error, "No batch parent found. Pass --after <step> to specify."
145
148
  end
@@ -150,7 +153,8 @@ module Ace
150
153
  steps: [task_step],
151
154
  after: parent_step,
152
155
  as_child: insert_as_child,
153
- source_file: "preset:#{preset_name}:task:#{task_ref}"
156
+ source_file: "preset:#{preset_name}:task:#{task_ref}",
157
+ fork_root: target.scope
154
158
  )
155
159
 
156
160
  print_task_result(result, task_ref, parent_step, as_child: insert_as_child) unless options[:quiet]
@@ -235,8 +239,13 @@ module Ace
235
239
  tokens.to_a.sort
236
240
  end
237
241
 
238
- def detect_batch_parent(executor)
242
+ def detect_batch_parent(executor, target)
239
243
  state = executor.status[:state]
244
+ if target.scope && !target.scope.to_s.strip.empty?
245
+ scoped_steps = state.subtree_steps(target.scope.strip)
246
+ scoped_state = Ace::Assign::Models::QueueState.new(steps: scoped_steps, assignment: state.assignment)
247
+ state = scoped_state
248
+ end
240
249
 
241
250
  batch_parent = state.top_level.find { |step| step.name == "batch-tasks" }
242
251
  return batch_parent.number if batch_parent
@@ -10,18 +10,26 @@ module Ace
10
10
  # - <assignment-id>
11
11
  # - <assignment-id>@<step-number>
12
12
  module AssignmentTarget
13
+ DEFAULT_TARGET_ENV = "ACE_ASSIGN_DEFAULT_TARGET"
13
14
  Target = Struct.new(:assignment_id, :scope, keyword_init: true)
14
- View = Struct.new(:assignment, :state, :scoped_state, :current_step, :scope_root, keyword_init: true)
15
+ View = Struct.new(:assignment, :state, :scoped_state, :active_steps, :next_step, :focus_step, :scope_root, keyword_init: true)
15
16
 
16
17
  private
17
18
 
18
19
  def resolve_assignment_target(options)
19
20
  assignment_raw = options[:assignment]
20
- unless assignment_raw.nil? || assignment_raw.to_s.strip.empty?
21
- return parse_assignment_target(assignment_raw)
21
+ explicit_target = unless assignment_raw.nil? || assignment_raw.to_s.strip.empty?
22
+ parse_assignment_target(assignment_raw)
22
23
  end
23
24
 
24
- Target.new(assignment_id: nil, scope: nil)
25
+ env_target = env_assignment_target
26
+ if explicit_target && env_target && target_identity(explicit_target) != target_identity(env_target)
27
+ raise Ace::Support::Cli::Error,
28
+ "Conflicting assignment targets: --assignment #{target_identity(explicit_target)} " \
29
+ "does not match #{DEFAULT_TARGET_ENV}=#{target_identity(env_target)}"
30
+ end
31
+
32
+ explicit_target || env_target || Target.new(assignment_id: nil, scope: nil)
25
33
  end
26
34
 
27
35
  def parse_assignment_target(raw)
@@ -38,6 +46,22 @@ module Ace
38
46
  Target.new(assignment_id: assignment_id, scope: scope)
39
47
  end
40
48
 
49
+ def env_assignment_target
50
+ raw = ENV[DEFAULT_TARGET_ENV].to_s.strip
51
+ return nil if raw.empty?
52
+
53
+ parse_assignment_target(raw)
54
+ end
55
+
56
+ def target_identity(target)
57
+ return "" unless target
58
+
59
+ scope = target.scope.to_s.strip
60
+ return target.assignment_id.to_s if scope.empty?
61
+
62
+ "#{target.assignment_id}@#{scope}"
63
+ end
64
+
41
65
  def build_executor_for_target(target)
42
66
  return Organisms::AssignmentExecutor.new unless target.assignment_id
43
67
 
@@ -60,45 +84,52 @@ module Ace
60
84
  assignment: result[:assignment],
61
85
  state: state,
62
86
  scoped_state: scoped[:state],
63
- current_step: scoped[:current],
87
+ active_steps: scoped[:active_steps],
88
+ next_step: scoped[:next_step],
89
+ focus_step: scoped[:focus_step],
64
90
  scope_root: scoped[:root]
65
91
  )
66
92
  end
67
93
 
68
94
  def scoped_status_view(state, scope)
69
- return {state: state, current: state.current || state.next_workable, root: nil} if scope.nil? || scope.strip.empty?
95
+ if scope.nil? || scope.strip.empty?
96
+ active_steps = state.active_steps
97
+ next_step = active_steps.empty? ? state.next_workable : nil
98
+ return {state: state, active_steps: active_steps, next_step: next_step, focus_step: state.current || next_step, root: nil}
99
+ end
70
100
 
71
101
  root = state.find_by_number(scope.strip)
72
102
  raise StepErrors::NotFound, "Step #{scope} not found in queue" unless root
73
103
 
74
104
  scoped_steps = state.subtree_steps(root.number)
75
105
  scoped_state = Models::QueueState.new(steps: scoped_steps, assignment: state.assignment)
76
- current = scoped_state.current || scoped_state.next_workable
106
+ active_steps = scoped_state.active_steps
107
+ next_step = active_steps.empty? ? state.next_workable_in_subtree(root.number) : nil
77
108
 
78
- {state: scoped_state, current: current, root: root.number}
109
+ {state: scoped_state, active_steps: active_steps, next_step: next_step, focus_step: scoped_state.current || next_step, root: root.number}
79
110
  end
80
111
 
81
- def fork_scope_root(state, current_step)
82
- return nil unless current_step
83
- return current_step if current_step.fork?
112
+ def fork_scope_root(state, step)
113
+ return nil unless step
114
+ return step if step.fork?
84
115
 
85
- state.nearest_fork_ancestor(current_step.number)
116
+ state.nearest_fork_ancestor(step.number)
86
117
  end
87
118
 
88
- def scoped_fork_metadata_step(state, current_step, scope, scope_root)
89
- return nil unless current_step
119
+ def scoped_fork_metadata_step(state, step, scope, scope_root)
120
+ return nil unless step
90
121
 
91
122
  if scope && !scope.strip.empty?
92
123
  return state.find_by_number(scope_root || scope.strip)
93
124
  end
94
125
 
95
- fork_scope_root(state, current_step)
126
+ fork_scope_root(state, step)
96
127
  end
97
128
 
98
- def effective_fork_provider_for(current_step, scoped_fork_step)
99
- return nil unless current_step
129
+ def effective_fork_provider_for(step, scoped_fork_step)
130
+ return nil unless step
100
131
 
101
- provider = current_step.fork_provider || scoped_fork_step&.fork_provider
132
+ provider = step.fork_provider || scoped_fork_step&.fork_provider
102
133
  provider.to_s.strip.empty? ? nil : provider
103
134
  end
104
135
  end
@@ -32,7 +32,7 @@ module Ace
32
32
  unless options[:quiet]
33
33
  print_terminal_skip_summary(result[:skipped_terminal])
34
34
  print_assignment_header(result[:assignment])
35
- print_step_instructions(result[:current])
35
+ print_step_instructions(result[:current] || result[:state]&.next_workable)
36
36
  end
37
37
  end
38
38
 
@@ -21,7 +21,7 @@ module Ace
21
21
 
22
22
  target = resolve_assignment_target(options)
23
23
  executor = build_executor_for_target(target)
24
- result = executor.fail(message)
24
+ result = executor.fail(message, fork_root: target.scope)
25
25
 
26
26
  unless options[:quiet]
27
27
  failed = result[:failed]
@@ -4,12 +4,12 @@ module Ace
4
4
  module Assign
5
5
  module CLI
6
6
  module Commands
7
- # Complete in-progress step with report content
7
+ # Complete active step with report content
8
8
  class Finish < Ace::Support::Cli::Command
9
9
  include Ace::Support::Cli::Base
10
10
  include AssignmentTarget
11
11
 
12
- desc "Complete in-progress step with report content"
12
+ desc "Complete active step with report content"
13
13
 
14
14
  argument :step, required: false, desc: "Step number to finish (active assignment only)"
15
15
  option :message, aliases: ["-m"], desc: "Report content: string, file path, or pipe stdin"
@@ -41,9 +41,23 @@ module Ace
41
41
  report_path = File.join(assignment.reports_dir, report_filename)
42
42
  puts "Report saved to: #{report_path}"
43
43
 
44
- if result[:current]
45
- puts "Advancing to step #{result[:current].number}: #{result[:current].name}"
46
- puts "Next: ace-assign step#{step_target_suffix(result[:current].number, options[:assignment])}"
44
+ scoped_state = if target.scope && !target.scope.to_s.strip.empty?
45
+ Ace::Assign::Models::QueueState.new(
46
+ steps: result[:state].subtree_steps(target.scope.strip),
47
+ assignment: assignment
48
+ )
49
+ else
50
+ result[:state]
51
+ end
52
+
53
+ active_steps = scoped_state.active_steps
54
+ if active_steps.any?
55
+ focused = scoped_state.current
56
+ puts "Active steps remaining: #{active_steps.map { |step| "#{step.number} #{step.name}" }.join(', ')}"
57
+ puts "Next: ace-assign step#{step_target_suffix(focused.number, options[:assignment])}" if focused
58
+ elsif (next_step = next_pending_step(result[:state], scoped_state, target.scope))
59
+ puts "No active step selected."
60
+ puts "Next pending step: #{next_step.number} - #{next_step.name}"
47
61
  else
48
62
  fork_root = target.scope&.strip
49
63
  if fork_root && result[:state].subtree_complete?(fork_root)
@@ -84,6 +98,13 @@ module Ace
84
98
 
85
99
  %( #{step_number} --assignment "#{assignment_target}")
86
100
  end
101
+
102
+ def next_pending_step(state, scoped_state, scope_root)
103
+ scope_ref = scope_root.to_s.strip
104
+ return scoped_state.next_workable if scope_ref.empty?
105
+
106
+ state.next_workable_in_subtree(scope_ref)
107
+ end
87
108
  end
88
109
  end
89
110
  end
@@ -22,6 +22,7 @@ module Ace
22
22
  option :cli_args, desc: "Extra CLI args for provider process"
23
23
  option :timeout, type: :integer, desc: "Execution timeout in seconds"
24
24
  option :launch_mode, desc: "Launch mode: auto, headless, or tmux"
25
+ option :callback, type: :boolean, default: false, desc: "Capture the origin tmux pane and let the forked agent send a final callback message there"
25
26
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
26
27
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
27
28
 
@@ -41,36 +42,35 @@ module Ace
41
42
  root_step = resolve_root_step(state, current, options[:root], target.scope)
42
43
  ensure_root_is_fork!(root_step)
43
44
  resolved_provider = resolved_provider_for(root_step, options[:provider])
45
+ resolved_launch_mode = resolved_launch_mode_for(root_step, options[:launch_mode])
46
+ callback_pane = resolve_callback_pane(options[:callback], resolved_launch_mode)
44
47
 
45
48
  if state.subtree_complete?(root_step.number)
46
49
  puts "Subtree #{root_step.number} is already complete." unless options[:quiet]
47
50
  return
48
51
  end
49
52
 
53
+ ensure_not_same_scoped_refork!(assignment_id: assignment.id, fork_root: root_step.number)
54
+
50
55
  unless options[:quiet]
51
56
  next_step = state.next_workable_in_subtree(root_step.number)
52
57
  puts "Starting fork subtree execution: #{root_step.number} - #{root_step.name}"
53
58
  puts "Assignment: #{assignment.id}"
54
59
  puts "Provider: #{resolved_provider}"
55
- puts "Launch mode: #{options[:launch_mode] || Molecules::ForkSessionLauncher::DEFAULT_LAUNCH_MODE}"
60
+ puts "Launch mode: #{resolved_launch_mode}"
61
+ puts "Callback pane: #{callback_pane}" if callback_pane
56
62
  puts "Timeout: #{options[:timeout] || Ace::Assign.config.dig("execution", "timeout") || Molecules::ForkSessionLauncher::DEFAULT_TIMEOUT}s"
57
63
  puts "Next step: #{next_step.number} - #{next_step.name}" if next_step
58
64
  end
59
65
 
60
- active_in_subtree = state.in_progress_in_subtree(root_step.number)
61
- if active_in_subtree.size > 1
62
- active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
63
- raise StepErrors::InvalidState, "Cannot fork-run subtree #{root_step.number}: multiple steps are already in progress (#{active_refs})."
66
+ if state.active_branch_conflict_in_subtree?(root_step.number)
67
+ active_refs = state.active_in_subtree(root_step.number).map { |step| "#{step.number}(#{step.name})" }.join(", ")
68
+ raise StepErrors::InvalidState, "Cannot fork-run subtree #{root_step.number}: multiple active branches already exist (#{active_refs})."
64
69
  end
65
70
 
66
- # Mark the next workable step as in_progress only when no subtree step is active.
67
- # For leaf fork roots, this activates the root itself.
68
- if active_in_subtree.empty?
69
- first_workable = state.next_workable_in_subtree(root_step.number)
70
- if first_workable
71
- step_writer = Molecules::StepWriter.new
72
- step_writer.mark_in_progress(first_workable.file_path)
73
- end
71
+ if root_step.status == :pending
72
+ step_writer = Molecules::StepWriter.new
73
+ step_writer.mark_active(root_step.file_path)
74
74
  end
75
75
 
76
76
  launch_result = launcher.launch(
@@ -80,10 +80,16 @@ module Ace
80
80
  cli_args: options[:cli_args],
81
81
  timeout: options[:timeout],
82
82
  cache_dir: assignment.cache_dir,
83
- launch_mode: options[:launch_mode]
83
+ launch_mode: resolved_launch_mode,
84
+ callback_pane: callback_pane
84
85
  )
85
86
  record_fork_pid_info(root_step, launch_result)
86
87
 
88
+ if callback_pane
89
+ puts "Fork subtree #{root_step.number} launched in callback mode." unless options[:quiet]
90
+ return
91
+ end
92
+
87
93
  refreshed = executor.status
88
94
  refreshed_state = refreshed[:state]
89
95
 
@@ -94,7 +100,7 @@ module Ace
94
100
  end
95
101
 
96
102
  unless refreshed_state.subtree_complete?(root_step.number)
97
- active = refreshed_state.in_progress_in_subtree(root_step.number).first || refreshed_state.current
103
+ active = refreshed_state.current_in_subtree(root_step.number) || refreshed_state.current
98
104
  active_msg = active ? " Current step: #{active.number} (#{active.name})." : ""
99
105
  last_msg = read_last_message(assignment.cache_dir, root_step.number)
100
106
  stall_reason = build_stall_reason(last_msg)
@@ -176,10 +182,10 @@ module Ace
176
182
  end
177
183
 
178
184
  # Fallback for legacy behavior when no root is explicitly scoped.
179
- raise Error, "No current step. Use --root <step-number> or --assignment <id>@<step-number>." unless current
185
+ raise Error, "No active step. Use --root <step-number> or --assignment <id>@<step-number>." unless current
180
186
 
181
187
  root = state.nearest_fork_ancestor(current.number)
182
- raise Error, "Current step is not in a forked subtree. Provide --root or --assignment <id>@<step-number>." unless root
188
+ raise Error, "Active step is not in a forked subtree. Provide --root or --assignment <id>@<step-number>." unless root
183
189
 
184
190
  root
185
191
  end
@@ -197,6 +203,39 @@ module Ace
197
203
  root_step.fork_provider || Ace::Assign.config.dig("execution", "provider") || Molecules::ForkSessionLauncher::DEFAULT_PROVIDER
198
204
  end
199
205
 
206
+ def ensure_not_same_scoped_refork!(assignment_id:, fork_root:)
207
+ return unless Molecules::ForkSessionLauncher.same_scoped_refork?(
208
+ assignment_id: assignment_id,
209
+ fork_root: fork_root
210
+ )
211
+
212
+ raise Error,
213
+ "Cannot fork-run subtree #{assignment_id}@#{fork_root}: already running inside that scoped subtree. Continue inline instead of calling fork-run again."
214
+ end
215
+
216
+ def resolved_launch_mode_for(root_step, cli_launch_mode)
217
+ explicit = cli_launch_mode&.to_s&.strip
218
+ return explicit unless explicit.nil? || explicit.empty?
219
+
220
+ root_step.fork_mode ||
221
+ Ace::Assign.config.dig("execution", "launch_mode") ||
222
+ Molecules::ForkSessionLauncher::DEFAULT_LAUNCH_MODE
223
+ end
224
+
225
+ def resolve_callback_pane(callback_enabled, launch_mode)
226
+ return nil unless callback_enabled
227
+
228
+ unless launch_mode == "tmux"
229
+ raise Error, "--callback requires tmux launch mode so the origin pane can be addressed."
230
+ end
231
+
232
+ pane = launcher.respond_to?(:callback_pane) ? launcher.callback_pane : nil
233
+ pane = pane.to_s.strip
234
+ raise Error, "--callback requires a resolvable origin tmux pane." if pane.empty?
235
+
236
+ pane
237
+ end
238
+
200
239
  def record_fork_pid_info(root_step, launch_result)
201
240
  pid_info = launch_result.is_a?(Hash) ? launch_result[:fork_pid_info] : nil
202
241
  return unless pid_info
@@ -93,7 +93,7 @@ module Ace
93
93
  # Header
94
94
  puts format(
95
95
  "%-#{COL_ID}s %-#{COL_NAME}s %-#{COL_STATUS}s %-#{COL_PROGRESS}s %-#{COL_STEP}s %s",
96
- "ID", "NAME", "STATUS", "PROGRESS", "CURRENT STEP", "UPDATED"
96
+ "ID", "NAME", "STATUS", "PROGRESS", "ACTIVE/NEXT", "UPDATED"
97
97
  )
98
98
  puts "-" * 95
99
99
 
@@ -104,7 +104,7 @@ module Ace
104
104
 
105
105
  name_display = truncate(info.name.to_s, COL_NAME - 1)
106
106
  state_display = STATE_LABELS[info.state] || info.state.to_s
107
- step_display = truncate(info.current_step, COL_STEP - 1)
107
+ step_display = truncate(info.step_focus, COL_STEP - 1)
108
108
  updated_display = format_relative_time(info.updated_at)
109
109
 
110
110
  puts format(
@@ -130,7 +130,8 @@ module Ace
130
130
  name: info.name,
131
131
  state: info.state.to_s,
132
132
  progress: info.progress,
133
- current_step: info.current_step,
133
+ active_steps: info.active_steps.map(&:name),
134
+ next_step: info.next_step&.name,
134
135
  updated_at: info.updated_at.iso8601,
135
136
  is_current: info.id == current_id
136
137
  }
@@ -19,7 +19,7 @@ module Ace
19
19
  def call(step_ref:, **options)
20
20
  target = resolve_assignment_target(options)
21
21
  executor = build_executor_for_target(target)
22
- result = executor.retry_step(step_ref)
22
+ result = executor.retry_step(step_ref, fork_root: target.scope)
23
23
 
24
24
  unless options[:quiet]
25
25
  retry_step = result[:retry]
@@ -13,7 +13,7 @@ module Ace
13
13
 
14
14
  STATUS_ICONS = {
15
15
  done: "✓ Done",
16
- in_progress: "▶ Active",
16
+ active: "▶ Active",
17
17
  pending: "○ Pending",
18
18
  failed: "✗ Failed"
19
19
  }.freeze
@@ -51,9 +51,8 @@ module Ace
51
51
  return if options[:quiet]
52
52
 
53
53
  if options[:format] == "json"
54
- scoped_fork_step = scoped_fork_metadata_step(view.state, view.current_step, target.scope, view.scope_root)
55
54
  puts JSON.pretty_generate(
56
- status_to_h(view.assignment, view.scoped_state, view.current_step, scoped_fork_step: scoped_fork_step)
55
+ status_to_h(view.assignment, view.scoped_state, view.active_steps, view.next_step, target: target, scope_root: view.scope_root)
57
56
  )
58
57
  return
59
58
  end
@@ -64,7 +63,7 @@ module Ace
64
63
 
65
64
  case mode
66
65
  when "progress"
67
- puts progress_summary_line(view.assignment, view.scoped_state, view.current_step)
66
+ puts progress_summary_line(view.assignment, view.scoped_state, view.active_steps, view.next_step)
68
67
  when "full"
69
68
  print_full_status(view, target, flat: options[:flat], include_completed: options[:all])
70
69
  else
@@ -83,20 +82,20 @@ module Ace
83
82
  mode
84
83
  end
85
84
 
86
- def status_to_h(assignment, state, current_step, scoped_fork_step: nil)
87
- {
85
+ def status_to_h(assignment, state, active_steps, next_step, target:, scope_root:)
86
+ payload = {
88
87
  assignment: {
89
88
  id: assignment.id,
90
89
  name: assignment.name,
91
90
  state: state.assignment_state.to_s
92
91
  },
93
92
  steps: state.steps.map { |step| step_to_h(step) },
94
- current_step: step_to_h(
95
- current_step,
96
- effective_fork_provider: effective_fork_provider_for(current_step, scoped_fork_step)
97
- ),
93
+ active_steps: active_steps.map { |step| step_to_h(step, effective_fork_provider: effective_fork_provider_for(step, scoped_fork_metadata_step(state, step, target.scope, scope_root))) },
98
94
  progress: "#{state.done.size}/#{state.size} done"
99
95
  }
96
+
97
+ payload[:next_step] = step_to_h(next_step, effective_fork_provider: effective_fork_provider_for(next_step, scoped_fork_metadata_step(state, next_step, target.scope, scope_root))) if active_steps.empty? && next_step
98
+ payload
100
99
  end
101
100
 
102
101
  def step_to_h(step, effective_fork_provider: nil)
@@ -120,7 +119,7 @@ module Ace
120
119
 
121
120
  def print_compact_status(view, target, include_completed:)
122
121
  lines = []
123
- lines.concat(compact_summary_lines(view.assignment, view.scoped_state, view.current_step))
122
+ lines.concat(compact_summary_lines(view.assignment, view.scoped_state, view.active_steps, view.next_step))
124
123
 
125
124
  unless target.assignment_id
126
125
  other_line = compact_other_assignments_line(view.assignment.id, include_completed: include_completed)
@@ -130,9 +129,9 @@ module Ace
130
129
  puts lines.take(10).join("\n")
131
130
  end
132
131
 
133
- def compact_summary_lines(assignment, state, current_step)
132
+ def compact_summary_lines(assignment, state, active_steps, next_step)
134
133
  lines = [
135
- compact_assignment_line(assignment, state, current_step),
134
+ compact_assignment_line(assignment, state, active_steps, next_step),
136
135
  compact_last_done_line(state)
137
136
  ]
138
137
 
@@ -148,14 +147,16 @@ module Ace
148
147
  lines
149
148
  end
150
149
 
151
- def progress_summary_line(assignment, state, current_step)
150
+ def progress_summary_line(assignment, state, active_steps, next_step)
152
151
  state_label = STATE_LABELS[state.assignment_state] || state.assignment_state.to_s
153
152
  details = ["State: #{state_label}", "Progress: #{state.done.size}/#{state.size} done"]
154
153
 
155
- if current_step
156
- details << "Current: #{current_step.number} #{current_step.name}"
154
+ if active_steps.any?
155
+ details << "Active: #{step_refs(active_steps)}"
156
+ elsif next_step
157
+ details << "Next: #{next_step.number} #{next_step.name}"
157
158
  elsif state.complete?
158
- details << "Current: complete"
159
+ details << "Next: complete"
159
160
  end
160
161
 
161
162
  if state.last_done
@@ -165,12 +166,14 @@ module Ace
165
166
  details.join(" | ")
166
167
  end
167
168
 
168
- def compact_assignment_line(assignment, state, current_step)
169
+ def compact_assignment_line(assignment, state, active_steps, next_step)
169
170
  state_label = STATE_LABELS[state.assignment_state] || state.assignment_state.to_s
170
171
  details = ["Assignment: #{assignment.id} #{compact_assignment_name(assignment.name)}", "Status: #{state_label}"]
171
172
 
172
- if current_step
173
- details << "Current: #{current_step.number} #{current_step.name}"
173
+ if active_steps.any?
174
+ details << "Active: #{step_refs(active_steps)}"
175
+ elsif next_step
176
+ details << "Next: #{next_step.number} #{next_step.name}"
174
177
  end
175
178
  details.join(" | ")
176
179
  end
@@ -191,7 +194,7 @@ module Ace
191
194
  end
192
195
 
193
196
  def compact_preview(state)
194
- active_or_pending = state.steps.select { |step| %i[in_progress pending].include?(step.status) }.first(PREVIEW_LIMIT)
197
+ active_or_pending = state.steps.select { |step| %i[active pending].include?(step.status) }.first(PREVIEW_LIMIT)
195
198
  return ["Pending steps:", active_or_pending] unless active_or_pending.empty?
196
199
 
197
200
  failed_preview = state.failed.first(PREVIEW_LIMIT)
@@ -202,7 +205,7 @@ module Ace
202
205
 
203
206
  def preview_step_line(step)
204
207
  status = case step.status
205
- when :in_progress then "active"
208
+ when :active then "active"
206
209
  when :pending then "next"
207
210
  when :failed then "failed"
208
211
  else step.status.to_s
@@ -229,21 +232,43 @@ module Ace
229
232
  "other assignments: #{others.size} total | active: #{active} paused: #{pending} failed: #{failed}"
230
233
  end
231
234
 
235
+ def step_refs(steps, limit: 3)
236
+ refs = steps.first(limit).map { |step| "#{step.number} #{step.name}" }
237
+ refs << "+#{steps.size - limit} more" if steps.size > limit
238
+ refs.join(" | ")
239
+ end
240
+
232
241
  def print_full_status(view, target, flat:, include_completed:)
233
242
  print_queue_status(view.assignment, view.scoped_state, flat: flat, root_number: view.scope_root)
234
243
 
235
- if view.current_step
236
- scoped_fork_step = scoped_fork_metadata_step(view.state, view.current_step, target.scope, view.scope_root)
244
+ if view.active_steps.any?
245
+ puts
246
+ puts "Active Steps:"
247
+ view.active_steps.each do |step|
248
+ puts " #{step.number} - #{step.name}"
249
+ end
250
+
251
+ if view.focus_step
252
+ scoped_fork_step = scoped_fork_metadata_step(view.state, view.focus_step, target.scope, view.scope_root)
253
+
254
+ puts
255
+ puts "Focused Step: #{view.focus_step.number} - #{view.focus_step.name}"
256
+ puts "Focused Status: #{view.focus_step.status}"
257
+ print_stall_details(view.focus_step)
258
+ puts "Workflow: #{view.focus_step.workflow}" if view.focus_step.workflow
259
+ puts "Skill: #{view.focus_step.skill}" if !view.focus_step.workflow && view.focus_step.skill
260
+ puts "Context: #{view.focus_step.context}" if view.focus_step.context
261
+
262
+ effective_fork_provider = effective_fork_provider_for(view.focus_step, scoped_fork_step)
263
+ puts "Fork Provider: #{effective_fork_provider}" if effective_fork_provider
264
+ print_scoped_fork_pid_info(scoped_fork_step)
265
+ end
266
+ elsif view.next_step
267
+ scoped_fork_step = scoped_fork_metadata_step(view.state, view.next_step, target.scope, view.scope_root)
237
268
 
238
269
  puts
239
- puts "Current Step: #{view.current_step.number} - #{view.current_step.name}"
240
- puts "Current Status: #{view.current_step.status}"
241
- print_stall_details(view.current_step)
242
- puts "Workflow: #{view.current_step.workflow}" if view.current_step.workflow
243
- puts "Skill: #{view.current_step.skill}" if !view.current_step.workflow && view.current_step.skill
244
- puts "Context: #{view.current_step.context}" if view.current_step.context
245
-
246
- effective_fork_provider = effective_fork_provider_for(view.current_step, scoped_fork_step)
270
+ puts "Next Step: #{view.next_step.number} - #{view.next_step.name}"
271
+ effective_fork_provider = effective_fork_provider_for(view.next_step, scoped_fork_step)
247
272
  puts "Fork Provider: #{effective_fork_provider}" if effective_fork_provider
248
273
  print_scoped_fork_pid_info(scoped_fork_step)
249
274
  elsif view.scoped_state.complete?
@@ -397,12 +422,13 @@ module Ace
397
422
  col_progress = 10
398
423
  col_step = 20
399
424
  puts format("%-#{col_id}s %-#{col_status}s %-#{col_progress}s %-#{col_step}s %s",
400
- "ASSIGNMENT", "STATUS", "PROGRESS", "CURRENT STEP", "UPDATED")
425
+ "ASSIGNMENT", "STATUS", "PROGRESS", "ACTIVE/NEXT", "UPDATED")
401
426
 
402
427
  others.each do |info|
403
428
  state_label = STATE_LABELS[info.state] || info.state.to_s
404
429
  updated = format_relative_time(info.updated_at)
405
- step = info.current_step.length > col_step ? "#{info.current_step[0..col_step - 4]}..." : info.current_step
430
+ step = info.step_focus
431
+ step = step.length > col_step ? "#{step[0..col_step - 4]}..." : step
406
432
  puts format("%-#{col_id}s %-#{col_status}s %-#{col_progress}s %-#{col_step}s %s",
407
433
  info.id, state_label, info.progress, step, updated)
408
434
  end