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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
- data/.ace-defaults/assign/config.yml +1 -0
- data/.ace-defaults/assign/presets/work-on-task.yml +3 -0
- data/CHANGELOG.md +15 -0
- data/docs/demo/fork-provider.cast +814 -937
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +15 -17
- data/docs/demo/fork-provider.tape.yml +16 -4
- data/docs/usage.md +30 -7
- data/handbook/guides/fork-context.g.md +29 -5
- data/handbook/skills/as-assign-drive/SKILL.md +2 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +109 -36
- data/handbook/workflow-instructions/assign/prepare.wf.md +5 -0
- data/lib/ace/assign/atoms/preset_expander.rb +4 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
- data/lib/ace/assign/cli/commands/add.rb +20 -11
- data/lib/ace/assign/cli/commands/assignment_target.rb +49 -18
- data/lib/ace/assign/cli/commands/create.rb +1 -1
- data/lib/ace/assign/cli/commands/fail.rb +1 -1
- data/lib/ace/assign/cli/commands/finish.rb +26 -5
- data/lib/ace/assign/cli/commands/fork_run.rb +56 -17
- data/lib/ace/assign/cli/commands/list.rb +4 -3
- data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
- data/lib/ace/assign/cli/commands/status.rb +60 -34
- data/lib/ace/assign/cli/commands/step.rb +4 -4
- data/lib/ace/assign/cli.rb +1 -1
- data/lib/ace/assign/models/assignment_info.rb +33 -4
- data/lib/ace/assign/models/queue_state.rb +101 -39
- data/lib/ace/assign/models/step.rb +13 -3
- data/lib/ace/assign/molecules/fork_session_launcher.rb +76 -60
- data/lib/ace/assign/molecules/step_writer.rb +3 -3
- data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +132 -82
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -0
- metadata +17 -3
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
294
|
-
raise Error, "No step currently
|
|
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:
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
"
|
|
644
|
+
"Unscoped driver action: delegate this subtree into forked context:",
|
|
685
645
|
"- ace-assign fork-run --assignment <assignment-id>@{{parent_number}}",
|
|
686
|
-
"
|
|
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
|
-
|
|
1398
|
-
return unless
|
|
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(
|
|
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
|
|
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
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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/
|
|
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 == :
|
|
1837
|
+
(s.status == :pending || s.status == :active) &&
|
|
1788
1838
|
!completed_this_pass.include?(s.number)
|
|
1789
1839
|
end
|
|
1790
1840
|
|
data/lib/ace/assign/version.rb
CHANGED