ace-assign 0.53.4 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
  3. data/.ace-defaults/assign/config.yml +1 -0
  4. data/.ace-defaults/assign/presets/work-on-task.yml +3 -0
  5. data/CHANGELOG.md +15 -0
  6. data/docs/demo/fork-provider.cast +814 -937
  7. data/docs/demo/fork-provider.gif +0 -0
  8. data/docs/demo/fork-provider.recording.json +15 -17
  9. data/docs/demo/fork-provider.tape.yml +16 -4
  10. data/docs/usage.md +30 -7
  11. data/handbook/guides/fork-context.g.md +29 -5
  12. data/handbook/skills/as-assign-drive/SKILL.md +2 -2
  13. data/handbook/workflow-instructions/assign/drive.wf.md +109 -36
  14. data/handbook/workflow-instructions/assign/prepare.wf.md +5 -0
  15. data/lib/ace/assign/atoms/preset_expander.rb +4 -0
  16. data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
  17. data/lib/ace/assign/cli/commands/add.rb +20 -11
  18. data/lib/ace/assign/cli/commands/assignment_target.rb +49 -18
  19. data/lib/ace/assign/cli/commands/create.rb +1 -1
  20. data/lib/ace/assign/cli/commands/fail.rb +1 -1
  21. data/lib/ace/assign/cli/commands/finish.rb +26 -5
  22. data/lib/ace/assign/cli/commands/fork_run.rb +56 -17
  23. data/lib/ace/assign/cli/commands/list.rb +4 -3
  24. data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
  25. data/lib/ace/assign/cli/commands/status.rb +60 -34
  26. data/lib/ace/assign/cli/commands/step.rb +4 -4
  27. data/lib/ace/assign/cli.rb +1 -1
  28. data/lib/ace/assign/models/assignment_info.rb +33 -4
  29. data/lib/ace/assign/models/queue_state.rb +101 -39
  30. data/lib/ace/assign/models/step.rb +13 -3
  31. data/lib/ace/assign/molecules/fork_session_launcher.rb +76 -60
  32. data/lib/ace/assign/molecules/step_writer.rb +3 -3
  33. data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
  34. data/lib/ace/assign/organisms/assignment_executor.rb +132 -82
  35. data/lib/ace/assign/version.rb +1 -1
  36. data/lib/ace/assign.rb +1 -0
  37. metadata +17 -3
  38. data/lib/ace/assign/molecules/tmux_fork_runner.rb +0 -191
@@ -4,12 +4,12 @@ module Ace
4
4
  module Assign
5
5
  module CLI
6
6
  module Commands
7
- # Print instructions for the current, next, or an explicit step.
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 current, next, or explicit step"
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.current_step
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 current or next workable step"
55
+ "Last done: #{last_done} | No active or next workable step"
56
56
  ].join("\n")
57
57
  end
58
58
  end
@@ -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/tmux_fork_runner"
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 current step information.
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.current_step # => "020-implement"
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
- # Current step display string
42
+ # Active steps in queue order.
43
43
  #
44
- # @return [String] Current step name or "-"
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 # => Step with status :in_progress
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 current in-progress step
27
- # @return [Step, nil] Current step or nil
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
- in_progress_steps.first
34
+ deepest_active
30
35
  end
31
36
 
32
- # Get all in-progress steps
33
- # @return [Array<Step>] In-progress steps
34
- def in_progress_steps
35
- steps.select { |s| s.status == :in_progress }
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 in_progress)
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 in_progress step with recent activity (< 1 hour)
111
- # - :stalled - Has in_progress step but stale (> 1 hour)
112
- # - :paused - Has pending but no in_progress (interrupted)
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 current && recently_active?
120
- return :stalled if current
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 the current in_progress step has recent activity
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
- return false unless current&.started_at
130
-
131
- (Time.now - current.started_at) < threshold
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
- in_progress: in_progress_steps.size,
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 current in-progress step within a subtree.
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] In-progress step inside subtree, if any
211
+ # @return [Step, nil] Deepest active step inside subtree, if any
200
212
  def current_in_subtree(root_number)
201
- in_progress_in_subtree(root_number).first
213
+ deepest_active_in_subtree(root_number)
202
214
  end
203
215
 
204
- # Get all in-progress steps within a subtree.
216
+ # Get all active steps within a subtree.
205
217
  #
206
218
  # @param root_number [String] Subtree root step number
207
- # @return [Array<Step>] In-progress steps inside subtree
208
- def in_progress_in_subtree(root_number)
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 == :in_progress }
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
- subtree_steps(root_number)
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
- # Prefers children of current/recent work.
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
- # First, find pending steps
269
- pending_steps = pending
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
- # Filter to steps that don't have incomplete children
272
- workable = pending_steps.reject { |s| has_incomplete_children?(s.number) }
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
- # Return first workable step (already sorted by number)
275
- workable.first
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 in_progress done failed].freeze
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, :in_progress, :done, :failed)
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 == :in_progress
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 || TmuxForkRunner.new
41
+ @tmux_runner = tmux_runner || TmuxControlSurfaceRunner.new
23
42
  @interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
24
43
  end
25
44
 
26
- # Launch forked subtree execution synchronously.
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
- invocation = interactive_builder.build(
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
- visible_handoff: invocation[:prompt]
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.run_script_in_pane(pane_target: pane_target, script_path: script_path)
150
- tmux_runner.select_window(session: session, window: fork_window) if window_info[:created] && current_window != fork_window
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 build_tmux_wrapper(assignment_id:, fork_root:, provider:, cli_args:, timeout:, session_meta_file:, session:, fork_window:, visible_handoff:)
230
- sessions_dir = File.dirname(session_meta_file)
231
- FileUtils.mkdir_p(sessions_dir)
232
- script_path = File.join(sessions_dir, "#{fork_root}-tmux-launch.sh")
233
-
234
- command = [
235
- "ace-llm", provider, "/as-assign-drive #{assignment_id}@#{fork_root}",
236
- "--interactive"
237
- ]
238
- command.concat(["--cli-args", cli_args]) if cli_args && !cli_args.strip.empty?
239
-
240
- script = <<~BASH
241
- #!/usr/bin/env bash
242
- set -uo pipefail
243
- cd #{Shellwords.escape(Dir.pwd)}
244
- export PROJECT_ROOT_PATH=#{Shellwords.escape(Dir.pwd)}
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 in progress
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 mark_in_progress(file_path)
66
+ def mark_active(file_path)
67
67
  update_frontmatter(file_path, {
68
- "status" => "in_progress",
68
+ "status" => "active",
69
69
  "started_at" => Time.now.utc.iso8601
70
70
  })
71
71
  end