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
|
@@ -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 =
|
|
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, :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
82
|
-
return nil unless
|
|
83
|
-
return
|
|
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(
|
|
116
|
+
state.nearest_fork_ancestor(step.number)
|
|
86
117
|
end
|
|
87
118
|
|
|
88
|
-
def scoped_fork_metadata_step(state,
|
|
89
|
-
return nil unless
|
|
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,
|
|
126
|
+
fork_scope_root(state, step)
|
|
96
127
|
end
|
|
97
128
|
|
|
98
|
-
def effective_fork_provider_for(
|
|
99
|
-
return nil unless
|
|
129
|
+
def effective_fork_provider_for(step, scoped_fork_step)
|
|
130
|
+
return nil unless step
|
|
100
131
|
|
|
101
|
-
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
|
|
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
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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: #{
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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, "
|
|
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", "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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,
|
|
132
|
+
def compact_summary_lines(assignment, state, active_steps, next_step)
|
|
134
133
|
lines = [
|
|
135
|
-
compact_assignment_line(assignment, state,
|
|
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,
|
|
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
|
|
156
|
-
details << "
|
|
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 << "
|
|
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,
|
|
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
|
|
173
|
-
details << "
|
|
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[
|
|
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 :
|
|
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.
|
|
236
|
-
|
|
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 "
|
|
240
|
-
|
|
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", "
|
|
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.
|
|
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
|