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
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/tmux"
4
+ require "fileutils"
5
+ require "shellwords"
6
+ require "yaml"
7
+
8
+ module Ace
9
+ module Assign
10
+ module Molecules
11
+ # Shared ace-tmux backed runtime helper for tmux fork launches.
12
+ class TmuxControlSurfaceRunner
13
+ def initialize(executor: Ace::Tmux::Molecules::TmuxExecutor.new, resolver: nil, control_surface: nil, tmux: "tmux", env: ENV)
14
+ @executor = executor
15
+ @tmux = tmux
16
+ @env = env
17
+ @resolver = resolver || Ace::Tmux::Molecules::RuntimeTargetResolver.new(executor: executor, tmux: tmux, env: env)
18
+ @control_surface = control_surface || Ace::Tmux::Organisms::ControlSurface.new(
19
+ executor: executor,
20
+ resolver: @resolver,
21
+ tmux: tmux
22
+ )
23
+ end
24
+
25
+ def tmux_context?
26
+ !current_session.to_s.strip.empty?
27
+ end
28
+
29
+ def current_session
30
+ resolver.resolve_session.session
31
+ rescue Ace::Tmux::TargetResolutionError
32
+ nil
33
+ end
34
+
35
+ def current_window
36
+ explicit = env["ACE_ASSIGN_FORK_WINDOW"].to_s.strip
37
+ return explicit unless explicit.empty?
38
+
39
+ resolver.resolve_window(session: current_session).window
40
+ rescue Ace::Tmux::TargetResolutionError
41
+ nil
42
+ end
43
+
44
+ def current_pane
45
+ explicit = env["ACE_ASSIGN_CALLBACK_PANE"].to_s.strip
46
+ return explicit unless explicit.empty?
47
+
48
+ resolver.resolve_pane(session: current_session, window: current_window).pane_target
49
+ rescue Ace::Tmux::TargetResolutionError
50
+ nil
51
+ end
52
+
53
+ def fork_window_name(base_window)
54
+ base = base_window.to_s.strip.sub(/-fs\z/, "")
55
+ sanitized = Ace::Tmux::Atoms::WindowNameSanitizer.call(base, fallback: "fork")
56
+
57
+ "#{sanitized}-fs"
58
+ end
59
+
60
+ def ensure_window(session:, name:, root:)
61
+ if (window_id = find_window_id(session: session, name: name))
62
+ return {created: false, target: window_id, window_id: window_id, name: name}
63
+ end
64
+
65
+ result = executor.capture(
66
+ Ace::Tmux::Atoms::TmuxCommandBuilder.new_window(
67
+ session,
68
+ name: name,
69
+ root: root,
70
+ print_format: '#{window_id}',
71
+ tmux: tmux
72
+ )
73
+ )
74
+ raise Error, "Failed to create tmux fork window #{name}: #{result.stderr}" unless result.success?
75
+
76
+ window_id = result.stdout.to_s.strip
77
+ raise Error, "Failed to create tmux fork window #{name}: empty window id" if window_id.empty?
78
+
79
+ {created: true, target: window_id, window_id: window_id, name: name}
80
+ end
81
+
82
+ def prepare_pane(session:, window:, root:, keep_existing:, window_target: nil)
83
+ target = window_target || "#{session}:#{window}"
84
+ pane = if keep_existing
85
+ first_pane(target)
86
+ else
87
+ create_pane(target, root)
88
+ end
89
+
90
+ set_pane_remain_on_exit(pane)
91
+ select_layout(target)
92
+ pane
93
+ end
94
+
95
+ def select_window(session:, window:, window_target: nil)
96
+ target = window_target || "#{session}:#{window}"
97
+ run!(
98
+ Ace::Tmux::Atoms::TmuxCommandBuilder.select_window(target, tmux: tmux),
99
+ "select tmux fork window #{window}"
100
+ )
101
+ end
102
+
103
+ def run_invocation_in_pane(pane_target:, command:, env: nil, working_dir: nil, visible_handoff: nil)
104
+ shell_command = build_pane_shell_command(
105
+ command: command,
106
+ env: env,
107
+ working_dir: working_dir,
108
+ visible_handoff: visible_handoff
109
+ )
110
+ control_surface.send_command(pane: pane_target, command: shell_command)
111
+ rescue Ace::Tmux::Error => e
112
+ raise Error, "Failed to send tmux fork command: #{e.message}"
113
+ end
114
+
115
+ def run_script_in_pane(pane_target:, script_path:)
116
+ control_surface.send_command(pane: pane_target, command: "bash #{File.expand_path(script_path).shellescape}")
117
+ rescue Ace::Tmux::Error => e
118
+ raise Error, "Failed to send tmux fork command: #{e.message}"
119
+ end
120
+
121
+ def capture_recent_output(pane_target:, lines: 40)
122
+ control_surface.capture_recent_output(pane: pane_target, lines: lines)
123
+ rescue Ace::Tmux::Error => e
124
+ raise Error, "Failed to capture pane #{pane_target}: #{e.message}"
125
+ end
126
+
127
+ def merge_tmux_metadata(session_meta_file:, session:, window:, pane:, window_id: nil, callback_pane: nil)
128
+ data = if File.exist?(session_meta_file)
129
+ YAML.safe_load_file(session_meta_file) || {}
130
+ else
131
+ {}
132
+ end
133
+ data["launch_mode"] = "tmux"
134
+ data["tmux_session"] = session
135
+ data["tmux_window"] = window
136
+ data["tmux_window_id"] = window_id if window_id
137
+ data["tmux_pane_id"] = pane
138
+ data["callback_pane"] = callback_pane if callback_pane && !callback_pane.empty?
139
+ File.write(session_meta_file, data.to_yaml)
140
+ end
141
+
142
+ private
143
+
144
+ attr_reader :control_surface, :env, :executor, :resolver, :tmux
145
+
146
+ def find_window_id(session:, name:)
147
+ result = executor.capture(
148
+ Ace::Tmux::Atoms::TmuxCommandBuilder.list_windows(
149
+ session,
150
+ format: '#{window_id}' + "\t" + '#{window_name}',
151
+ tmux: tmux
152
+ )
153
+ )
154
+ return nil unless result.success?
155
+
156
+ result.stdout.split("\n").map(&:strip).reject(&:empty?).each do |line|
157
+ window_id, window_name = line.split("\t", 2)
158
+ return window_id if window_name == name && !window_id.to_s.empty?
159
+ end
160
+
161
+ nil
162
+ end
163
+
164
+ def first_pane(target)
165
+ result = executor.capture(
166
+ Ace::Tmux::Atoms::TmuxCommandBuilder.list_panes(target, format: '#{pane_id}', tmux: tmux)
167
+ )
168
+ raise Error, "Failed to inspect panes for #{target}: #{result.stderr}" unless result.success?
169
+
170
+ pane = result.stdout.split("\n").map(&:strip).reject(&:empty?).first
171
+ raise Error, "No panes found for #{target}" if pane.to_s.empty?
172
+
173
+ pane
174
+ end
175
+
176
+ def create_pane(target, root)
177
+ result = executor.capture(
178
+ Ace::Tmux::Atoms::TmuxCommandBuilder.split_window(
179
+ target,
180
+ root: root,
181
+ print_format: '#{pane_id}',
182
+ tmux: tmux
183
+ )
184
+ )
185
+ raise Error, "Failed to create tmux fork pane in #{target}: #{result.stderr}" unless result.success?
186
+
187
+ pane = result.stdout.to_s.strip
188
+ raise Error, "Failed to create tmux fork pane in #{target}: empty pane id" if pane.empty?
189
+
190
+ pane
191
+ end
192
+
193
+ def set_pane_remain_on_exit(pane_target)
194
+ run!(
195
+ Ace::Tmux::Atoms::TmuxCommandBuilder.set_pane_option(pane_target, "remain-on-exit", "on", tmux: tmux),
196
+ "enable remain-on-exit"
197
+ )
198
+ end
199
+
200
+ def select_layout(target)
201
+ run!(
202
+ Ace::Tmux::Atoms::TmuxCommandBuilder.select_layout(target, "tiled", tmux: tmux),
203
+ "apply tiled layout"
204
+ )
205
+ end
206
+
207
+ def run!(cmd, action)
208
+ return if executor.run(cmd)
209
+
210
+ raise Error, "Failed to #{action}"
211
+ end
212
+
213
+ def build_pane_shell_command(command:, env:, working_dir:, visible_handoff:)
214
+ steps = []
215
+ resolved_working_dir = working_dir.to_s.strip
216
+ steps << "cd #{Shellwords.escape(File.expand_path(resolved_working_dir))}" unless resolved_working_dir.empty?
217
+
218
+ handoff = visible_handoff.to_s
219
+ steps << "printf '%s\\n' #{Shellwords.escape(handoff)}" unless handoff.empty?
220
+
221
+ steps << "exec #{build_exec_command(command: command, env: env)}"
222
+ steps.join(" && ")
223
+ end
224
+
225
+ def build_exec_command(command:, env:)
226
+ cmd = Array(command).map { |part| Shellwords.escape(part.to_s) }.join(" ")
227
+ env_hash = env.respond_to?(:to_h) ? env.to_h : {}
228
+ unset_parts = []
229
+ assign_parts = []
230
+
231
+ env_hash.each do |key, value|
232
+ next if key.to_s.strip.empty?
233
+
234
+ if value.nil?
235
+ unset_parts << "-u #{Shellwords.escape(key.to_s)}"
236
+ else
237
+ assign_parts << "#{key}=#{Shellwords.escape(value.to_s)}"
238
+ end
239
+ end
240
+ env_parts = unset_parts + assign_parts
241
+
242
+ return cmd if env_parts.empty?
243
+
244
+ "env #{env_parts.join(' ')} #{cmd}"
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -98,12 +98,6 @@ module Ace
98
98
  )
99
99
  end
100
100
 
101
- # Mark first workable step as in_progress.
102
- # This skips batch parent containers that have incomplete children.
103
- initial_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
104
- first_workable = initial_state.next_workable
105
- step_writer.mark_in_progress(first_workable.file_path) if first_workable
106
-
107
101
  # Archive source config into task's steps directory and update assignment metadata
108
102
  archived_path = archive_source_config(config_path, assignment.id)
109
103
  assignment = Models::Assignment.new(
@@ -145,7 +139,6 @@ module Ace
145
139
  # Start a pending step.
146
140
  #
147
141
  # Rules:
148
- # - Fails if any step is already in progress (strict mode)
149
142
  # - Starts an explicit pending target when provided
150
143
  # - Otherwise starts the next workable pending step
151
144
  #
@@ -157,7 +150,6 @@ module Ace
157
150
  raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
158
151
 
159
152
  state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
160
- raise StepErrors::InvalidState, "Cannot start: step #{state.current.number} is already in progress. Finish or fail it first." if state.current
161
153
 
162
154
  fork_root = fork_root&.strip
163
155
  target_step = if step_number && !step_number.to_s.strip.empty?
@@ -176,7 +168,8 @@ module Ace
176
168
  raise StepErrors::InvalidState, "No pending workable step found."
177
169
  end
178
170
 
179
- step_writer.mark_in_progress(target_step.file_path)
171
+ validate_start_activation!(state, target_step, fork_root: fork_root)
172
+ step_writer.mark_active(target_step.file_path)
180
173
  assignment_manager.update(assignment)
181
174
 
182
175
  new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
@@ -188,10 +181,10 @@ module Ace
188
181
  }
189
182
  end
190
183
 
191
- # Finish an in-progress step and advance queue state.
184
+ # Finish an active step and update queue state.
192
185
  #
193
186
  # @param report_content [String] Completion report content
194
- # @param step_number [String, nil] Optional in-progress step number to finish
187
+ # @param step_number [String, nil] Optional active step number to finish
195
188
  # @param fork_root [String, nil] Optional subtree root to constrain advancement
196
189
  # @return [Hash] Result with completed step and updated state
197
190
  def finish_step(report_content:, step_number: nil, fork_root: nil)
@@ -200,7 +193,7 @@ module Ace
200
193
 
201
194
  state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
202
195
  current = find_target_step_for_finish(state, step_number, fork_root)
203
- raise Error, "No step currently in progress. Try 'ace-assign start' or 'ace-assign retry'." unless current
196
+ raise Error, "No step currently active. Try 'ace-assign start' or 'ace-assign retry'." unless current
204
197
 
205
198
  # Enforce hierarchy: cannot mark parent as done with incomplete children
206
199
  if state.has_incomplete_children?(current.number)
@@ -221,18 +214,6 @@ module Ace
221
214
  # Re-scan to get fresh state after auto-completions
222
215
  state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
223
216
 
224
- fork_root = fork_root&.strip
225
- # Find next step to work on using hierarchical rules.
226
- # When fork_root is provided, keep advancement inside that subtree.
227
- next_step = if fork_root && !fork_root.empty? && state.find_by_number(fork_root)
228
- find_next_step_in_subtree(state, current.number, fork_root)
229
- else
230
- find_next_step(state, current.number)
231
- end
232
- if next_step
233
- step_writer.mark_in_progress(next_step.file_path)
234
- end
235
-
236
217
  assignment_manager.update(assignment)
237
218
 
238
219
  new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
@@ -244,13 +225,7 @@ module Ace
244
225
  }
245
226
  end
246
227
 
247
- # Complete current step with report and advance
248
- #
249
- # Legacy bridge: preserves single-call semantics for fork-run callers.
250
- # Previously, advance() auto-started the next step as a side effect.
251
- # The new start/finish split makes this explicit, but advance() retains
252
- # the auto-start behavior for subtree entry so fork-run workflows
253
- # (which call advance() with fork_root) continue to work unchanged.
228
+ # Complete current step with report content from a file.
254
229
  #
255
230
  # @param report_path [String] Path to report file
256
231
  # @param fork_root [String, nil] Optional subtree root to constrain advancement
@@ -258,26 +233,6 @@ module Ace
258
233
  def advance(report_path, fork_root: nil)
259
234
  raise ConfigErrors::NotFound, "Report file not found: #{report_path}" unless File.exist?(report_path)
260
235
 
261
- # Auto-start the next workable subtree step when fork_root is given but
262
- # no step in the subtree is yet in_progress (subtree entry case).
263
- fork_root_str = fork_root&.strip
264
- if fork_root_str && !fork_root_str.empty?
265
- assignment = assignment_manager.find_active
266
- if assignment
267
- state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
268
- active_in_subtree = state.in_progress_in_subtree(fork_root_str)
269
- if active_in_subtree.size > 1
270
- active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
271
- raise StepErrors::InvalidState, "Cannot advance subtree #{fork_root_str}: multiple steps are in progress (#{active_refs})."
272
- end
273
-
274
- if active_in_subtree.empty?
275
- next_workable = state.next_workable_in_subtree(fork_root_str)
276
- step_writer.mark_in_progress(next_workable.file_path) if next_workable
277
- end
278
- end
279
- end
280
-
281
236
  finish_step(report_content: File.read(report_path), fork_root: fork_root)
282
237
  end
283
238
 
@@ -285,13 +240,13 @@ module Ace
285
240
  #
286
241
  # @param message [String] Error message
287
242
  # @return [Hash] Result with updated state
288
- def fail(message)
243
+ def fail(message, fork_root: nil)
289
244
  assignment = assignment_manager.find_active
290
245
  raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
291
246
 
292
247
  state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
293
- current = state.current
294
- raise Error, "No step currently in progress. Try 'ace-assign add' to add a new step or 'ace-assign retry' to retry a failed step." unless current
248
+ current = find_target_step_for_fail(state, fork_root)
249
+ raise Error, "No step currently active. Try 'ace-assign add' to add a new step or 'ace-assign retry' to retry a failed step." unless current
295
250
 
296
251
  # Mark step as failed
297
252
  step_writer.mark_failed(current.file_path, error_message: message)
@@ -315,7 +270,7 @@ module Ace
315
270
  # @param after [String, nil] Insert after this step number (optional)
316
271
  # @param as_child [Boolean] Insert as child of 'after' step (default: false, sibling)
317
272
  # @return [Hash] Result with new step
318
- def add(name, instructions, after: nil, as_child: false, added_by: nil, extra: {})
273
+ def add(name, instructions, after: nil, as_child: false, added_by: nil, extra: {}, fork_root: nil)
319
274
  assignment = assignment_manager.find_active
320
275
  raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
321
276
 
@@ -323,6 +278,7 @@ module Ace
323
278
  raise Error, "Step name cannot be empty." if step_name.empty?
324
279
 
325
280
  state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
281
+ after, as_child = normalize_scoped_insertion_anchor(state, after: after, as_child: as_child, fork_root: fork_root)
326
282
  existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
327
283
 
328
284
  # Validate --after step exists
@@ -344,9 +300,6 @@ module Ace
344
300
  queue_scanner.step_numbers(assignment.steps_dir)
345
301
  end
346
302
 
347
- # Determine initial status upfront to avoid redundant I/O
348
- initial_status = state.current ? :pending : :in_progress
349
-
350
303
  # Build added_by metadata for audit trail
351
304
  added_by ||= if after && as_child
352
305
  "child_of:#{after}"
@@ -364,7 +317,7 @@ module Ace
364
317
  number: new_number,
365
318
  name: step_name,
366
319
  instructions: instructions,
367
- status: initial_status,
320
+ status: :pending,
368
321
  added_by: added_by,
369
322
  parent: as_child ? after : nil,
370
323
  extra: extra_frontmatter
@@ -396,7 +349,7 @@ module Ace
396
349
  # @note Structural validation is performed for the full batch before any writes.
397
350
  # Runtime I/O failures can still interrupt insertion after partial writes.
398
351
  # @return [Hash] Result with added steps and final state
399
- def add_batch(steps:, after: nil, as_child: false, source_file: nil)
352
+ def add_batch(steps:, after: nil, as_child: false, source_file: nil, fork_root: nil)
400
353
  unless steps.is_a?(Array) && steps.any?
401
354
  source_label = source_file.to_s.strip.empty? ? "batch input" : source_file
402
355
  raise Error, "No steps defined in #{source_label}"
@@ -408,6 +361,12 @@ module Ace
408
361
 
409
362
  prevalidate_batch_trees!(steps)
410
363
 
364
+ assignment = assignment_manager.find_active
365
+ raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
366
+
367
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
368
+ after, as_child = normalize_scoped_insertion_anchor(state, after: after, as_child: as_child, fork_root: fork_root)
369
+
411
370
  added_steps = []
412
371
  renumbered = []
413
372
  sibling_cursor = after
@@ -425,7 +384,6 @@ module Ace
425
384
  sibling_cursor = inserted[:root_number] unless as_child
426
385
  end
427
386
 
428
- assignment = assignment_manager.find_active
429
387
  state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
430
388
  {
431
389
  assignment: assignment,
@@ -439,7 +397,7 @@ module Ace
439
397
  #
440
398
  # @param step_ref [String] Step number or reference to retry
441
399
  # @return [Hash] Result with new retry step
442
- def retry_step(step_ref)
400
+ def retry_step(step_ref, fork_root: nil)
443
401
  assignment = assignment_manager.find_active
444
402
  raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
445
403
 
@@ -448,16 +406,18 @@ module Ace
448
406
  # Find the step to retry
449
407
  original = state.find_by_number(step_ref.to_s)
450
408
  raise StepErrors::NotFound, "Step #{step_ref} not found in queue" unless original
409
+ validate_retry_scope!(state, original, fork_root)
451
410
 
452
411
  # Get existing numbers
453
412
  existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
454
413
 
455
414
  # Insert after all current steps (at end of queue before pending)
456
415
  # Find last done or failed step
457
- base_number = if state.current
458
- state.current.number
459
- elsif state.last_done
460
- state.last_done.number
416
+ scoped_state = scoped_retry_state(state, assignment: assignment, fork_root: fork_root)
417
+ base_number = if scoped_state.current
418
+ scoped_state.current.number
419
+ elsif scoped_state.last_done
420
+ scoped_state.last_done.number
461
421
  else
462
422
  original.number
463
423
  end
@@ -681,9 +641,11 @@ module Ace
681
641
  if parent_context == "fork"
682
642
  lines.concat(
683
643
  [
684
- "Delegate this subtree into forked context:",
644
+ "Unscoped driver action: delegate this subtree into forked context:",
685
645
  "- ace-assign fork-run --assignment <assignment-id>@{{parent_number}}",
686
- "Inside the forked agent, continue execution within this subtree scope only."
646
+ "Scoped forked agent action: do not call fork-run again for {{parent_number}}.",
647
+ "If this scoped root is active and no child step is active yet: ace-assign start --assignment <assignment-id>@{{parent_number}}",
648
+ "After a child step becomes active, continue execution within this subtree scope only."
687
649
  ]
688
650
  )
689
651
  else
@@ -1394,13 +1356,11 @@ module Ace
1394
1356
  end
1395
1357
 
1396
1358
  def rebalance_after_child_injection(assignment:, state:, parent_number:)
1397
- current = state.current
1398
- return unless current && current.number == parent_number
1359
+ parent = state.find_by_number(parent_number)
1360
+ return unless parent&.status == :active
1361
+ return if parent.fork?
1399
1362
 
1400
- step_writer.mark_pending(current.file_path)
1401
- rebalanced_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
1402
- next_step = rebalanced_state.next_workable_in_subtree(parent_number)
1403
- step_writer.mark_in_progress(next_step.file_path) if next_step
1363
+ step_writer.mark_pending(parent.file_path)
1404
1364
  end
1405
1365
 
1406
1366
  # Normalize instructions to a string.
@@ -1714,6 +1674,28 @@ module Ace
1714
1674
  DEFAULT_DYNAMIC_STEP_INSTRUCTIONS
1715
1675
  end
1716
1676
 
1677
+ def targeted_batch_parent_startable?(target, fork_root:)
1678
+ return false unless fork_root.nil? || fork_root.empty?
1679
+
1680
+ target.batch_parent == true
1681
+ end
1682
+
1683
+ def find_target_step_for_fail(state, fork_root)
1684
+ fork_root = fork_root&.strip
1685
+ current = state.current
1686
+ return current if fork_root.nil? || fork_root.empty?
1687
+
1688
+ raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
1689
+ if state.active_branch_conflict_in_subtree?(fork_root)
1690
+ active_refs = state.active_in_subtree(fork_root).map { |step| "#{step.number}(#{step.name})" }.join(", ")
1691
+ raise StepErrors::InvalidState, "Cannot fail in subtree #{fork_root}: multiple active branches exist (#{active_refs})."
1692
+ end
1693
+
1694
+ return current if current && state.in_subtree?(fork_root, current.number)
1695
+
1696
+ state.current_in_subtree(fork_root)
1697
+ end
1698
+
1717
1699
  def find_target_step_for_start(state, step_number, fork_root)
1718
1700
  target = state.find_by_number(step_number)
1719
1701
  raise StepErrors::NotFound, "Step #{step_number} not found in queue" unless target
@@ -1723,13 +1705,40 @@ module Ace
1723
1705
  raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}." unless state.in_subtree?(fork_root, target.number)
1724
1706
  end
1725
1707
  raise StepErrors::InvalidState, "Cannot start step #{target.number}: status is #{target.status}, expected pending." unless target.status == :pending
1726
- if state.has_incomplete_children?(target.number)
1708
+ if state.has_incomplete_children?(target.number) && !targeted_batch_parent_startable?(target, fork_root: fork_root)
1727
1709
  raise StepErrors::InvalidState, "Cannot start step #{target.number}: has incomplete children."
1728
1710
  end
1729
1711
 
1730
1712
  target
1731
1713
  end
1732
1714
 
1715
+ def validate_start_activation!(state, target, fork_root:)
1716
+ root_ref = fork_root if fork_root && !fork_root.empty?
1717
+ active_fork_root = if root_ref
1718
+ state.find_by_number(root_ref)
1719
+ else
1720
+ state.nearest_fork_ancestor(target.number)
1721
+ end
1722
+
1723
+ if active_fork_root &&
1724
+ target.number != active_fork_root.number &&
1725
+ active_fork_root.status != :active
1726
+ raise StepErrors::InvalidState,
1727
+ "Cannot start step #{target.number}: fork root #{active_fork_root.number} is not active."
1728
+ end
1729
+
1730
+ return unless active_fork_root
1731
+
1732
+ if state.active_branch_conflict_in_subtree?(active_fork_root.number, extra_active: [target.number])
1733
+ active_refs = (
1734
+ state.active_in_subtree(active_fork_root.number).map { |step| "#{step.number}(#{step.name})" } +
1735
+ ["#{target.number}(#{target.name})"]
1736
+ ).uniq.join(", ")
1737
+ raise StepErrors::InvalidState,
1738
+ "Cannot start step #{target.number}: subtree #{active_fork_root.number} already has multiple active branches (#{active_refs})."
1739
+ end
1740
+ end
1741
+
1733
1742
  def find_target_step_for_finish(state, step_number, fork_root)
1734
1743
  fork_root = fork_root&.strip
1735
1744
  if step_number && !step_number.to_s.strip.empty?
@@ -1738,7 +1747,7 @@ module Ace
1738
1747
  if fork_root && !fork_root.empty? && !state.in_subtree?(fork_root, target.number)
1739
1748
  raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}."
1740
1749
  end
1741
- raise StepErrors::InvalidState, "Cannot finish step #{target.number}: status is #{target.status}, expected in_progress." unless target.status == :in_progress
1750
+ raise StepErrors::InvalidState, "Cannot finish step #{target.number}: status is #{target.status}, expected active." unless target.status == :active
1742
1751
 
1743
1752
  return target
1744
1753
  end
@@ -1746,10 +1755,9 @@ module Ace
1746
1755
  current = state.current
1747
1756
  if fork_root && !fork_root.empty?
1748
1757
  raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
1749
- active_in_subtree = state.in_progress_in_subtree(fork_root)
1750
- if active_in_subtree.size > 1
1751
- active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
1752
- raise StepErrors::InvalidState, "Cannot finish in subtree #{fork_root}: multiple steps are in progress (#{active_refs})."
1758
+ if state.active_branch_conflict_in_subtree?(fork_root)
1759
+ active_refs = state.active_in_subtree(fork_root).map { |step| "#{step.number}(#{step.name})" }.join(", ")
1760
+ raise StepErrors::InvalidState, "Cannot finish in subtree #{fork_root}: multiple active branches exist (#{active_refs})."
1753
1761
  end
1754
1762
  if current.nil? || !state.in_subtree?(fork_root, current.number)
1755
1763
  current = state.current_in_subtree(fork_root)
@@ -1760,6 +1768,48 @@ module Ace
1760
1768
  current
1761
1769
  end
1762
1770
 
1771
+ def scoped_retry_state(state, assignment:, fork_root:)
1772
+ fork_root = fork_root&.strip
1773
+ return state if fork_root.nil? || fork_root.empty?
1774
+
1775
+ scoped_steps = state.subtree_steps(fork_root)
1776
+ Models::QueueState.new(steps: scoped_steps, assignment: assignment)
1777
+ end
1778
+
1779
+ def validate_retry_scope!(state, original, fork_root)
1780
+ fork_root = fork_root&.strip
1781
+ return if fork_root.nil? || fork_root.empty?
1782
+
1783
+ raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
1784
+ return if state.in_subtree?(fork_root, original.number)
1785
+
1786
+ raise StepErrors::InvalidState, "Step #{original.number} is outside scoped subtree #{fork_root}."
1787
+ end
1788
+
1789
+ def normalize_scoped_insertion_anchor(state, after:, as_child:, fork_root:)
1790
+ fork_root = fork_root&.strip
1791
+ return [after, as_child] if fork_root.nil? || fork_root.empty?
1792
+
1793
+ root = state.find_by_number(fork_root)
1794
+ raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless root
1795
+
1796
+ after_ref = after&.to_s&.strip
1797
+ if after_ref && !after_ref.empty?
1798
+ unless state.in_subtree?(fork_root, after_ref)
1799
+ raise StepErrors::InvalidState, "Step #{after_ref} is outside scoped subtree #{fork_root}."
1800
+ end
1801
+
1802
+ return [after_ref, as_child]
1803
+ end
1804
+
1805
+ subtree = state.subtree_steps(fork_root)
1806
+ last_subtree_step = subtree.last
1807
+ return [fork_root, true] unless last_subtree_step
1808
+ return [fork_root, true] if last_subtree_step.number == fork_root
1809
+
1810
+ [last_subtree_step.number, false]
1811
+ end
1812
+
1763
1813
  # Auto-complete parent steps when all their children are done.
1764
1814
  # Walks up the hierarchy marking parents as done, handling multi-level
1765
1815
  # completion in a single pass (grandparents become eligible when parents complete).
@@ -1782,9 +1832,9 @@ module Ace
1782
1832
  iterations += 1
1783
1833
  completed_any = false
1784
1834
 
1785
- # Find all pending/in_progress parent steps that have children
1835
+ # Find all pending/active parent steps that have children
1786
1836
  eligible_parents = state.steps.select do |s|
1787
- (s.status == :pending || s.status == :in_progress) &&
1837
+ (s.status == :pending || s.status == :active) &&
1788
1838
  !completed_this_pass.include?(s.number)
1789
1839
  end
1790
1840
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = '0.53.4'
5
+ VERSION = '0.55.0'
6
6
  end
7
7
  end
data/lib/ace/assign.rb CHANGED
@@ -5,6 +5,7 @@ require "ace/support/config"
5
5
  require "ace/support/fs"
6
6
  require "ace/support/nav"
7
7
  require "ace/support/cli"
8
+ require "ace/tmux"
8
9
  require "pathname"
9
10
 
10
11
  # CLI and commands