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
|
@@ -4,12 +4,12 @@ module Ace
|
|
|
4
4
|
module Assign
|
|
5
5
|
module CLI
|
|
6
6
|
module Commands
|
|
7
|
-
# Print instructions for the
|
|
7
|
+
# Print instructions for the focused active, next, or an explicit step.
|
|
8
8
|
class Step < Ace::Support::Cli::Command
|
|
9
9
|
include Ace::Support::Cli::Base
|
|
10
10
|
include AssignmentTarget
|
|
11
11
|
|
|
12
|
-
desc "Show instructions for the
|
|
12
|
+
desc "Show instructions for the active, next, or explicit step"
|
|
13
13
|
|
|
14
14
|
argument :step, required: false, desc: "Exact step number to inspect"
|
|
15
15
|
option :assignment, desc: "Target specific assignment ID"
|
|
@@ -44,7 +44,7 @@ module Ace
|
|
|
44
44
|
return step
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
view.
|
|
47
|
+
view.focus_step
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def no_work_message(view)
|
|
@@ -52,7 +52,7 @@ module Ace
|
|
|
52
52
|
last_done = view.scoped_state.last_done ? "#{view.scoped_state.last_done.number} #{view.scoped_state.last_done.name}" : "none"
|
|
53
53
|
[
|
|
54
54
|
"Assignment: #{view.assignment.id} | Status: #{state_label} | Progress: #{view.scoped_state.done.size}/#{view.scoped_state.size} done",
|
|
55
|
-
"Last done: #{last_done} | No
|
|
55
|
+
"Last done: #{last_done} | No active or next workable step"
|
|
56
56
|
].join("\n")
|
|
57
57
|
end
|
|
58
58
|
end
|
data/lib/ace/assign/cli.rb
CHANGED
|
@@ -31,7 +31,7 @@ require_relative "molecules/step_writer"
|
|
|
31
31
|
require_relative "molecules/step_renumberer"
|
|
32
32
|
require_relative "molecules/skill_assign_source_resolver"
|
|
33
33
|
require_relative "molecules/fork_session_launcher"
|
|
34
|
-
require_relative "molecules/
|
|
34
|
+
require_relative "molecules/tmux_control_surface_runner"
|
|
35
35
|
require_relative "molecules/preset_inferrer"
|
|
36
36
|
|
|
37
37
|
# Organisms
|
|
@@ -7,13 +7,13 @@ module Ace
|
|
|
7
7
|
#
|
|
8
8
|
# Pure data carrier with computed state (ATOM pattern).
|
|
9
9
|
# Wraps an assignment with its queue state to provide
|
|
10
|
-
# computed state, progress, and
|
|
10
|
+
# computed state, progress, and active step information.
|
|
11
11
|
#
|
|
12
12
|
# @example
|
|
13
13
|
# info = AssignmentInfo.new(assignment: assignment, queue_state: state)
|
|
14
14
|
# info.state # => :running
|
|
15
15
|
# info.progress # => "2/5"
|
|
16
|
-
# info.
|
|
16
|
+
# info.step_focus # => "implement"
|
|
17
17
|
class AssignmentInfo
|
|
18
18
|
attr_reader :assignment, :queue_state
|
|
19
19
|
|
|
@@ -39,13 +39,42 @@ module Ace
|
|
|
39
39
|
"#{s[:done]}/#{s[:total]}"
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
#
|
|
42
|
+
# Active steps in queue order.
|
|
43
43
|
#
|
|
44
|
-
# @return [
|
|
44
|
+
# @return [Array<Step>] Active steps
|
|
45
|
+
def active_steps
|
|
46
|
+
queue_state.active_steps
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Next pending workable step when nothing is active.
|
|
50
|
+
#
|
|
51
|
+
# @return [Step, nil] Next pending step or nil
|
|
52
|
+
def next_step
|
|
53
|
+
return nil unless active_steps.empty?
|
|
54
|
+
|
|
55
|
+
queue_state.next_workable
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Deterministic active step name kept for internal callers.
|
|
59
|
+
#
|
|
60
|
+
# @return [String] Focused active step name or "-"
|
|
45
61
|
def current_step
|
|
46
62
|
queue_state.current&.name || "-"
|
|
47
63
|
end
|
|
48
64
|
|
|
65
|
+
# Human-friendly active/next summary for compact tables.
|
|
66
|
+
#
|
|
67
|
+
# @return [String] Active step names or next-step hint
|
|
68
|
+
def step_focus
|
|
69
|
+
if active_steps.any?
|
|
70
|
+
active_steps.map(&:name).join(", ")
|
|
71
|
+
elsif next_step
|
|
72
|
+
"next: #{next_step.name}"
|
|
73
|
+
else
|
|
74
|
+
"-"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
49
78
|
# Check if assignment is completed
|
|
50
79
|
#
|
|
51
80
|
# @return [Boolean]
|
|
@@ -10,7 +10,7 @@ module Ace
|
|
|
10
10
|
#
|
|
11
11
|
# @example
|
|
12
12
|
# state = QueueState.new(steps: steps, assignment: assignment)
|
|
13
|
-
# state.current # =>
|
|
13
|
+
# state.current # => Deepest active step
|
|
14
14
|
# state.pending # => Array of pending steps
|
|
15
15
|
class QueueState
|
|
16
16
|
attr_reader :steps, :assignment
|
|
@@ -23,16 +23,21 @@ module Ace
|
|
|
23
23
|
@children_index = build_children_index(steps)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# Get
|
|
27
|
-
#
|
|
26
|
+
# Get the deterministic single-step active target.
|
|
27
|
+
#
|
|
28
|
+
# Returns the deepest active step in queue order. When several active
|
|
29
|
+
# branches exist globally, this is an internal convenience only; public
|
|
30
|
+
# status should use active_steps.
|
|
31
|
+
#
|
|
32
|
+
# @return [Step, nil] Deepest active step or nil
|
|
28
33
|
def current
|
|
29
|
-
|
|
34
|
+
deepest_active
|
|
30
35
|
end
|
|
31
36
|
|
|
32
|
-
# Get all in
|
|
33
|
-
# @return [Array<Step>]
|
|
34
|
-
def
|
|
35
|
-
steps.select { |s| s.status == :
|
|
37
|
+
# Get all active steps in queue order.
|
|
38
|
+
# @return [Array<Step>] Active steps
|
|
39
|
+
def active_steps
|
|
40
|
+
steps.select { |s| s.status == :active }
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
# Get all pending steps
|
|
@@ -65,7 +70,7 @@ module Ace
|
|
|
65
70
|
steps.empty?
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
# Check if all steps are complete (no pending or
|
|
73
|
+
# Check if all steps are complete (no pending or active)
|
|
69
74
|
# @return [Boolean]
|
|
70
75
|
def complete?
|
|
71
76
|
steps.all?(&:complete?)
|
|
@@ -107,28 +112,28 @@ module Ace
|
|
|
107
112
|
# - :empty - No steps in queue
|
|
108
113
|
# - :completed - All steps complete (done or failed)
|
|
109
114
|
# - :failed - Has failed step(s) but NOT all complete (stuck)
|
|
110
|
-
# - :running - Has
|
|
111
|
-
# - :stalled - Has
|
|
112
|
-
# - :paused - Has pending but no
|
|
115
|
+
# - :running - Has active step(s) with recent activity (< 1 hour)
|
|
116
|
+
# - :stalled - Has active step(s) but all are stale (> 1 hour)
|
|
117
|
+
# - :paused - Has pending but no active step (interrupted)
|
|
113
118
|
#
|
|
114
119
|
# @return [Symbol] Assignment state
|
|
115
120
|
def assignment_state
|
|
116
121
|
return :empty if empty?
|
|
117
122
|
return :completed if complete?
|
|
118
123
|
return :failed if failed.any?
|
|
119
|
-
return :running if
|
|
120
|
-
return :stalled if
|
|
124
|
+
return :running if active_steps.any? && recently_active?
|
|
125
|
+
return :stalled if active_steps.any?
|
|
121
126
|
|
|
122
127
|
:paused
|
|
123
128
|
end
|
|
124
129
|
|
|
125
|
-
# Check if
|
|
130
|
+
# Check if any active step has recent activity.
|
|
126
131
|
# @param threshold [Integer] Seconds since started_at to consider active (default: 1 hour)
|
|
127
132
|
# @return [Boolean]
|
|
128
133
|
def recently_active?(threshold: 3600)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
active_steps.any? do |step|
|
|
135
|
+
step.started_at && ((Time.now - step.started_at) < threshold)
|
|
136
|
+
end
|
|
132
137
|
end
|
|
133
138
|
|
|
134
139
|
# Summary for display
|
|
@@ -137,12 +142,19 @@ module Ace
|
|
|
137
142
|
{
|
|
138
143
|
total: size,
|
|
139
144
|
done: done.size,
|
|
140
|
-
|
|
145
|
+
active: active_steps.size,
|
|
141
146
|
pending: pending.size,
|
|
142
147
|
failed: failed.size
|
|
143
148
|
}
|
|
144
149
|
end
|
|
145
150
|
|
|
151
|
+
# Get deepest active step in queue order.
|
|
152
|
+
#
|
|
153
|
+
# @return [Step, nil] Deepest active step
|
|
154
|
+
def deepest_active
|
|
155
|
+
deepest_active_from(active_steps)
|
|
156
|
+
end
|
|
157
|
+
|
|
146
158
|
# Get all direct children of a step (O(1) via index)
|
|
147
159
|
# @param parent_number [String] Parent step number
|
|
148
160
|
# @return [Array<Step>] Direct child steps
|
|
@@ -193,21 +205,29 @@ module Ace
|
|
|
193
205
|
subtree_steps(root_number).any? { |s| s.status == :failed }
|
|
194
206
|
end
|
|
195
207
|
|
|
196
|
-
# Get the
|
|
208
|
+
# Get the deterministic active target within a subtree.
|
|
197
209
|
#
|
|
198
210
|
# @param root_number [String] Subtree root step number
|
|
199
|
-
# @return [Step, nil]
|
|
211
|
+
# @return [Step, nil] Deepest active step inside subtree, if any
|
|
200
212
|
def current_in_subtree(root_number)
|
|
201
|
-
|
|
213
|
+
deepest_active_in_subtree(root_number)
|
|
202
214
|
end
|
|
203
215
|
|
|
204
|
-
# Get all
|
|
216
|
+
# Get all active steps within a subtree.
|
|
205
217
|
#
|
|
206
218
|
# @param root_number [String] Subtree root step number
|
|
207
|
-
# @return [Array<Step>]
|
|
208
|
-
def
|
|
219
|
+
# @return [Array<Step>] Active steps inside subtree
|
|
220
|
+
def active_in_subtree(root_number)
|
|
209
221
|
subtree_steps(root_number)
|
|
210
|
-
.select { |s| s.status == :
|
|
222
|
+
.select { |s| s.status == :active }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Get the deepest active step within a subtree.
|
|
226
|
+
#
|
|
227
|
+
# @param root_number [String] Subtree root step number
|
|
228
|
+
# @return [Step, nil] Deepest active step in subtree
|
|
229
|
+
def deepest_active_in_subtree(root_number)
|
|
230
|
+
deepest_active_from(active_in_subtree(root_number))
|
|
211
231
|
end
|
|
212
232
|
|
|
213
233
|
# Get next workable step constrained to a subtree.
|
|
@@ -215,10 +235,7 @@ module Ace
|
|
|
215
235
|
# @param root_number [String] Subtree root step number
|
|
216
236
|
# @return [Step, nil] Next pending workable step inside subtree
|
|
217
237
|
def next_workable_in_subtree(root_number)
|
|
218
|
-
|
|
219
|
-
.select { |s| s.status == :pending }
|
|
220
|
-
.reject { |s| has_incomplete_children?(s.number) }
|
|
221
|
-
.first
|
|
238
|
+
next_workable(scope_root: root_number)
|
|
222
239
|
end
|
|
223
240
|
|
|
224
241
|
# Build ancestor chain from closest parent to root.
|
|
@@ -260,19 +277,41 @@ module Ace
|
|
|
260
277
|
children_of(parent_number).any? { |s| s.status != :done }
|
|
261
278
|
end
|
|
262
279
|
|
|
263
|
-
# Get next workable step considering hierarchy.
|
|
280
|
+
# Get next workable pending step considering hierarchy and active fork ownership.
|
|
264
281
|
# A step is workable if it's pending and has no incomplete children.
|
|
265
|
-
#
|
|
282
|
+
#
|
|
283
|
+
# Pending descendants under an active fork root are hidden unless the caller
|
|
284
|
+
# is explicitly scoped inside that root.
|
|
285
|
+
#
|
|
286
|
+
# @param scope_root [String, nil] Optional subtree scope
|
|
266
287
|
# @return [Step, nil] Next step to work on
|
|
267
|
-
def next_workable
|
|
268
|
-
|
|
269
|
-
|
|
288
|
+
def next_workable(scope_root: nil)
|
|
289
|
+
candidate_steps = scope_root ? subtree_steps(scope_root) : steps
|
|
290
|
+
candidate_steps
|
|
291
|
+
.select { |s| s.status == :pending }
|
|
292
|
+
.reject { |s| has_incomplete_children?(s.number) && !runnable_delegation_parent?(s, scope_root: scope_root) }
|
|
293
|
+
.reject { |s| hidden_by_active_fork_root?(s.number, scope_root: scope_root) }
|
|
294
|
+
.first
|
|
295
|
+
end
|
|
270
296
|
|
|
271
|
-
|
|
272
|
-
|
|
297
|
+
# Get active fork roots in queue order.
|
|
298
|
+
#
|
|
299
|
+
# @return [Array<Step>] Active fork-scoped steps
|
|
300
|
+
def active_fork_roots
|
|
301
|
+
active_steps.select(&:fork?)
|
|
302
|
+
end
|
|
273
303
|
|
|
274
|
-
|
|
275
|
-
|
|
304
|
+
# Check whether active steps inside a subtree fan out across more than one branch.
|
|
305
|
+
#
|
|
306
|
+
# @param root_number [String] Subtree root step number
|
|
307
|
+
# @param extra_active [Array<String>] Additional active step numbers to include
|
|
308
|
+
# @return [Boolean] True when active steps inside the subtree are not on a single path
|
|
309
|
+
def active_branch_conflict_in_subtree?(root_number, extra_active: [])
|
|
310
|
+
active_numbers = active_in_subtree(root_number).map(&:number) + Array(extra_active).map(&:to_s)
|
|
311
|
+
unique_active = active_numbers.uniq
|
|
312
|
+
unique_active.combination(2).any? do |left, right|
|
|
313
|
+
!(in_subtree?(left, right) || in_subtree?(right, left))
|
|
314
|
+
end
|
|
276
315
|
end
|
|
277
316
|
|
|
278
317
|
# Get all step numbers as an array
|
|
@@ -295,6 +334,12 @@ module Ace
|
|
|
295
334
|
|
|
296
335
|
private
|
|
297
336
|
|
|
337
|
+
def runnable_delegation_parent?(step, scope_root:)
|
|
338
|
+
return false unless scope_root.nil? || scope_root.to_s.strip.empty?
|
|
339
|
+
|
|
340
|
+
step.batch_parent == true || step.fork?
|
|
341
|
+
end
|
|
342
|
+
|
|
298
343
|
# Build index of children by parent number for O(1) lookups
|
|
299
344
|
# @param steps [Array<Step>] All steps
|
|
300
345
|
# @return [Hash<String, Array<Step>>] Parent number => children mapping
|
|
@@ -321,6 +366,23 @@ module Ace
|
|
|
321
366
|
}
|
|
322
367
|
end
|
|
323
368
|
end
|
|
369
|
+
|
|
370
|
+
def deepest_active_from(active)
|
|
371
|
+
return nil if active.empty?
|
|
372
|
+
|
|
373
|
+
max_depth = active.map { |step| step.number.to_s.split(".").length }.max
|
|
374
|
+
active.find { |step| step.number.to_s.split(".").length == max_depth }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def hidden_by_active_fork_root?(step_number, scope_root:)
|
|
378
|
+
active_fork_roots.any? do |root|
|
|
379
|
+
next false if step_number == root.number
|
|
380
|
+
next false unless in_subtree?(root.number, step_number)
|
|
381
|
+
next false if scope_root && in_subtree?(root.number, scope_root)
|
|
382
|
+
|
|
383
|
+
true
|
|
384
|
+
end
|
|
385
|
+
end
|
|
324
386
|
end
|
|
325
387
|
end
|
|
326
388
|
end
|
|
@@ -17,7 +17,7 @@ module Ace
|
|
|
17
17
|
# )
|
|
18
18
|
class Step
|
|
19
19
|
# Valid status values
|
|
20
|
-
STATUSES = %i[pending
|
|
20
|
+
STATUSES = %i[pending active done failed].freeze
|
|
21
21
|
|
|
22
22
|
# Valid context values for execution context
|
|
23
23
|
VALID_CONTEXTS = %w[fork].freeze
|
|
@@ -31,7 +31,7 @@ module Ace
|
|
|
31
31
|
|
|
32
32
|
# @param number [String] Step number (e.g., "010", "010.01")
|
|
33
33
|
# @param name [String] Step name
|
|
34
|
-
# @param status [Symbol] Step status (:pending, :
|
|
34
|
+
# @param status [Symbol] Step status (:pending, :active, :done, :failed)
|
|
35
35
|
# @param instructions [String] Step instructions (markdown)
|
|
36
36
|
# @param report [String, nil] Completion report content
|
|
37
37
|
# @param error [String, nil] Error message (if failed)
|
|
@@ -103,7 +103,7 @@ module Ace
|
|
|
103
103
|
# Check if step can be worked on
|
|
104
104
|
# @return [Boolean]
|
|
105
105
|
def workable?
|
|
106
|
-
status == :pending || status == :
|
|
106
|
+
status == :pending || status == :active
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
# Check if this is a retry of another step
|
|
@@ -128,6 +128,16 @@ module Ace
|
|
|
128
128
|
provider.empty? ? nil : provider
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
# Resolve per-step launch-mode override from fork options.
|
|
132
|
+
# @return [String, nil]
|
|
133
|
+
def fork_mode
|
|
134
|
+
return nil unless fork_options
|
|
135
|
+
|
|
136
|
+
mode = fork_options["mode"]
|
|
137
|
+
mode = mode.to_s.strip
|
|
138
|
+
mode.empty? ? nil : mode
|
|
139
|
+
end
|
|
140
|
+
|
|
131
141
|
# Get the original step number if this is a retry
|
|
132
142
|
# @return [String, nil] Original step number
|
|
133
143
|
def retry_of
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "ace/llm"
|
|
4
4
|
require "fileutils"
|
|
5
|
-
require "rbconfig"
|
|
6
|
-
require "shellwords"
|
|
7
5
|
|
|
8
6
|
module Ace
|
|
9
7
|
module Assign
|
|
@@ -15,25 +13,37 @@ module Ace
|
|
|
15
13
|
DEFAULT_LAUNCH_MODE = "auto"
|
|
16
14
|
VALID_LAUNCH_MODES = %w[auto headless tmux].freeze
|
|
17
15
|
TMUX_POLL_INTERVAL = 0.5
|
|
16
|
+
DEFAULT_TARGET_ENV = "ACE_ASSIGN_DEFAULT_TARGET"
|
|
17
|
+
CURRENT_ASSIGNMENT_ID_ENV = "ACE_ASSIGN_CURRENT_ASSIGNMENT_ID"
|
|
18
|
+
CURRENT_FORK_ROOT_ENV = "ACE_ASSIGN_CURRENT_FORK_ROOT"
|
|
19
|
+
|
|
20
|
+
def self.fork_scope_env(assignment_id:, fork_root:)
|
|
21
|
+
scoped_target = "#{assignment_id}@#{fork_root}"
|
|
22
|
+
{
|
|
23
|
+
DEFAULT_TARGET_ENV => scoped_target,
|
|
24
|
+
CURRENT_ASSIGNMENT_ID_ENV => assignment_id.to_s,
|
|
25
|
+
CURRENT_FORK_ROOT_ENV => fork_root.to_s
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.same_scoped_refork?(assignment_id:, fork_root:, env: ENV)
|
|
30
|
+
assignment_ref = assignment_id.to_s.strip
|
|
31
|
+
root_ref = fork_root.to_s.strip
|
|
32
|
+
return false if assignment_ref.empty? || root_ref.empty?
|
|
33
|
+
|
|
34
|
+
env[CURRENT_ASSIGNMENT_ID_ENV].to_s.strip == assignment_ref &&
|
|
35
|
+
env[CURRENT_FORK_ROOT_ENV].to_s.strip == root_ref
|
|
36
|
+
end
|
|
18
37
|
|
|
19
38
|
def initialize(config: nil, query_interface: Ace::LLM::QueryInterface, tmux_runner: nil, interactive_builder: nil)
|
|
20
39
|
@config = config || Ace::Assign.config
|
|
21
40
|
@query_interface = query_interface
|
|
22
|
-
@tmux_runner = tmux_runner ||
|
|
41
|
+
@tmux_runner = tmux_runner || TmuxControlSurfaceRunner.new
|
|
23
42
|
@interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
|
|
24
43
|
end
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# @param assignment_id [String] Assignment identifier
|
|
29
|
-
# @param fork_root [String] Subtree root step number
|
|
30
|
-
# @param provider [String, nil] Optional provider override
|
|
31
|
-
# @param cli_args [String, nil] Optional provider CLI args
|
|
32
|
-
# @param timeout [Integer, nil] Optional timeout override (seconds)
|
|
33
|
-
# @param cache_dir [String, nil] Assignment cache directory for last-message capture
|
|
34
|
-
# @param launch_mode [String, nil] Launch mode override (auto|headless|tmux)
|
|
35
|
-
# @return [Hash] QueryInterface response
|
|
36
|
-
def launch(assignment_id:, fork_root:, provider: nil, cli_args: nil, timeout: nil, cache_dir: nil, launch_mode: nil)
|
|
45
|
+
def launch(assignment_id:, fork_root:, provider: nil, cli_args: nil, timeout: nil, cache_dir: nil, launch_mode: nil, callback_pane: nil)
|
|
46
|
+
ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
|
|
37
47
|
resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
|
|
38
48
|
resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
|
|
39
49
|
resolved_mode = resolve_launch_mode(launch_mode)
|
|
@@ -45,7 +55,8 @@ module Ace
|
|
|
45
55
|
provider: resolved_provider,
|
|
46
56
|
cli_args: cli_args,
|
|
47
57
|
timeout: resolved_timeout,
|
|
48
|
-
cache_dir: cache_dir
|
|
58
|
+
cache_dir: cache_dir,
|
|
59
|
+
callback_pane: callback_pane
|
|
49
60
|
)
|
|
50
61
|
else
|
|
51
62
|
launch_provider_session(
|
|
@@ -59,13 +70,19 @@ module Ace
|
|
|
59
70
|
end
|
|
60
71
|
end
|
|
61
72
|
|
|
73
|
+
def callback_pane
|
|
74
|
+
tmux_runner.current_pane
|
|
75
|
+
end
|
|
76
|
+
|
|
62
77
|
def launch_provider_session(assignment_id:, fork_root:, provider:, cli_args: nil, timeout: nil, cache_dir: nil,
|
|
63
78
|
last_message_file: nil, session_meta_file: nil)
|
|
79
|
+
ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
|
|
64
80
|
resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
|
|
65
81
|
resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
|
|
66
82
|
scoped_assignment = "#{assignment_id}@#{fork_root}"
|
|
67
83
|
prompt = "/as-assign-drive #{scoped_assignment}"
|
|
68
84
|
last_msg_file = last_message_file || build_last_message_file(cache_dir, fork_root)
|
|
85
|
+
scope_env = self.class.fork_scope_env(assignment_id: assignment_id, fork_root: fork_root)
|
|
69
86
|
|
|
70
87
|
result = query_interface.query(
|
|
71
88
|
resolved_provider,
|
|
@@ -74,12 +91,10 @@ module Ace
|
|
|
74
91
|
cli_args: cli_args,
|
|
75
92
|
timeout: resolved_timeout,
|
|
76
93
|
fallback: false,
|
|
77
|
-
last_message_file: last_msg_file
|
|
94
|
+
last_message_file: last_msg_file,
|
|
95
|
+
subprocess_env: scope_env
|
|
78
96
|
)
|
|
79
97
|
|
|
80
|
-
# Layer 1 write: capture last message for non-Codex providers (or when Codex didn't write).
|
|
81
|
-
# Safety: `query` blocks until the subprocess exits, so by this point Layer 2 (Codex
|
|
82
|
-
# --output-last-message) has already finished writing. No other writer exists at this point.
|
|
83
98
|
if last_msg_file && result[:text] && !result[:text].strip.empty?
|
|
84
99
|
existing = File.exist?(last_msg_file) ? File.read(last_msg_file).strip : ""
|
|
85
100
|
File.write(last_msg_file, result[:text]) if existing.empty?
|
|
@@ -98,6 +113,7 @@ module Ace
|
|
|
98
113
|
|
|
99
114
|
def resolve_launch_mode(explicit_mode)
|
|
100
115
|
mode = explicit_mode.to_s.strip
|
|
116
|
+
mode = config.dig("execution", "launch_mode").to_s.strip if mode.empty?
|
|
101
117
|
mode = DEFAULT_LAUNCH_MODE if mode.empty?
|
|
102
118
|
unless VALID_LAUNCH_MODES.include?(mode)
|
|
103
119
|
raise Error, "Invalid launch mode '#{mode}'. Expected one of: #{VALID_LAUNCH_MODES.join(', ')}"
|
|
@@ -108,7 +124,8 @@ module Ace
|
|
|
108
124
|
tmux_runner.tmux_context? ? "tmux" : "headless"
|
|
109
125
|
end
|
|
110
126
|
|
|
111
|
-
def launch_tmux(assignment_id:, fork_root:, provider:, cli_args:, timeout:, cache_dir:)
|
|
127
|
+
def launch_tmux(assignment_id:, fork_root:, provider:, cli_args:, timeout:, cache_dir:, callback_pane: nil)
|
|
128
|
+
ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
|
|
112
129
|
session = tmux_runner.current_session
|
|
113
130
|
raise Error, "Launch mode tmux requires an active tmux session (TMUX or ACE_TMUX_SESSION)." unless session
|
|
114
131
|
raise Error, "Tmux launch requires assignment cache_dir for subtree polling." if cache_dir.to_s.strip.empty?
|
|
@@ -123,31 +140,35 @@ module Ace
|
|
|
123
140
|
pane_target = tmux_runner.prepare_pane(
|
|
124
141
|
session: session,
|
|
125
142
|
window: fork_window,
|
|
143
|
+
window_target: window_info[:target],
|
|
126
144
|
root: Dir.pwd,
|
|
127
145
|
keep_existing: window_info[:created]
|
|
128
146
|
)
|
|
129
147
|
|
|
130
148
|
session_meta_file = build_session_meta_file(cache_dir, fork_root)
|
|
131
149
|
prompt = "/as-assign-drive #{assignment_id}@#{fork_root}"
|
|
132
|
-
|
|
133
|
-
provider_model: provider,
|
|
134
|
-
prompt: prompt,
|
|
135
|
-
cli_args: cli_args
|
|
136
|
-
)
|
|
137
|
-
script_path = build_tmux_wrapper(
|
|
150
|
+
tmux_env = tmux_subprocess_env(
|
|
138
151
|
assignment_id: assignment_id,
|
|
139
152
|
fork_root: fork_root,
|
|
140
|
-
provider: provider,
|
|
141
|
-
cli_args: cli_args,
|
|
142
|
-
timeout: timeout,
|
|
143
|
-
session_meta_file: session_meta_file,
|
|
144
153
|
session: session,
|
|
145
154
|
fork_window: fork_window,
|
|
146
|
-
|
|
155
|
+
callback_pane: callback_pane
|
|
156
|
+
)
|
|
157
|
+
invocation = interactive_builder.build(
|
|
158
|
+
provider_model: provider,
|
|
159
|
+
prompt: prompt,
|
|
160
|
+
cli_args: cli_args,
|
|
161
|
+
working_dir: Dir.pwd,
|
|
162
|
+
subprocess_env: tmux_env
|
|
147
163
|
)
|
|
148
164
|
|
|
149
|
-
tmux_runner.
|
|
150
|
-
|
|
165
|
+
tmux_runner.run_invocation_in_pane(
|
|
166
|
+
pane_target: pane_target,
|
|
167
|
+
command: invocation[:command],
|
|
168
|
+
env: invocation[:env],
|
|
169
|
+
working_dir: invocation[:working_dir],
|
|
170
|
+
visible_handoff: invocation[:prompt]
|
|
171
|
+
)
|
|
151
172
|
write_tmux_launch_metadata(
|
|
152
173
|
session_meta_file: session_meta_file,
|
|
153
174
|
provider: invocation[:provider],
|
|
@@ -158,8 +179,13 @@ module Ace
|
|
|
158
179
|
session_meta_file: session_meta_file,
|
|
159
180
|
session: session,
|
|
160
181
|
window: fork_window,
|
|
161
|
-
pane: pane_target
|
|
182
|
+
pane: pane_target,
|
|
183
|
+
window_id: window_info[:window_id],
|
|
184
|
+
callback_pane: callback_pane
|
|
162
185
|
)
|
|
186
|
+
|
|
187
|
+
return {tmux: true, pane_target: pane_target, callback_mode: true, callback_pane: callback_pane} if callback_pane
|
|
188
|
+
|
|
163
189
|
wait_for_subtree_terminal(
|
|
164
190
|
assignment_id: assignment_id,
|
|
165
191
|
fork_root: fork_root,
|
|
@@ -226,32 +252,22 @@ module Ace
|
|
|
226
252
|
File.join(sessions_dir, "#{fork_root}-session.yml")
|
|
227
253
|
end
|
|
228
254
|
|
|
229
|
-
def
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
export ACE_TMUX_SESSION=#{Shellwords.escape(session)}
|
|
246
|
-
export ACE_ASSIGN_LAUNCH_MODE=tmux
|
|
247
|
-
export ACE_ASSIGN_FORK_WINDOW=#{Shellwords.escape(fork_window)}
|
|
248
|
-
printf '%s\n' #{Shellwords.escape(visible_handoff.to_s)}
|
|
249
|
-
exec #{command.map { |part| Shellwords.escape(part) }.join(" ")}
|
|
250
|
-
BASH
|
|
251
|
-
|
|
252
|
-
File.write(script_path, script)
|
|
253
|
-
FileUtils.chmod(0o755, script_path)
|
|
254
|
-
script_path
|
|
255
|
+
def ensure_not_same_scoped_refork!(assignment_id:, fork_root:)
|
|
256
|
+
return unless self.class.same_scoped_refork?(assignment_id: assignment_id, fork_root: fork_root)
|
|
257
|
+
|
|
258
|
+
raise Error,
|
|
259
|
+
"Cannot fork-run subtree #{assignment_id}@#{fork_root}: already running inside that scoped subtree. Continue inline instead of calling fork-run again."
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def tmux_subprocess_env(assignment_id:, fork_root:, session:, fork_window:, callback_pane: nil)
|
|
263
|
+
env = self.class.fork_scope_env(assignment_id: assignment_id, fork_root: fork_root).merge(
|
|
264
|
+
"PROJECT_ROOT_PATH" => Dir.pwd,
|
|
265
|
+
"ACE_TMUX_SESSION" => session,
|
|
266
|
+
"ACE_ASSIGN_LAUNCH_MODE" => "tmux",
|
|
267
|
+
"ACE_ASSIGN_FORK_WINDOW" => fork_window
|
|
268
|
+
)
|
|
269
|
+
env["ACE_ASSIGN_CALLBACK_PANE"] = callback_pane if callback_pane && !callback_pane.empty?
|
|
270
|
+
env
|
|
255
271
|
end
|
|
256
272
|
|
|
257
273
|
def wait_for_subtree_terminal(assignment_id:, fork_root:, cache_dir:, timeout:)
|
|
@@ -59,13 +59,13 @@ module Ace
|
|
|
59
59
|
file_path
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
# Mark step as
|
|
62
|
+
# Mark step as active
|
|
63
63
|
#
|
|
64
64
|
# @param file_path [String] Path to step file
|
|
65
65
|
# @return [String] Updated file path
|
|
66
|
-
def
|
|
66
|
+
def mark_active(file_path)
|
|
67
67
|
update_frontmatter(file_path, {
|
|
68
|
-
"status" => "
|
|
68
|
+
"status" => "active",
|
|
69
69
|
"started_at" => Time.now.utc.iso8601
|
|
70
70
|
})
|
|
71
71
|
end
|