ace-assign 0.42.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
  4. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
  6. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
  7. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
  8. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
  9. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
  10. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
  11. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
  12. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
  13. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
  14. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
  15. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
  16. data/.ace-defaults/assign/config.yml +1 -0
  17. data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
  18. data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
  19. data/.ace-defaults/assign/presets/work-on-task.yml +6 -16
  20. data/CHANGELOG.md +216 -0
  21. data/README.md +20 -43
  22. data/docs/demo/canonical-skill-source.gif +0 -0
  23. data/docs/demo/canonical-skill-source.tape.yml +51 -0
  24. data/docs/demo/fork-provider.cast +834 -0
  25. data/docs/demo/fork-provider.gif +0 -0
  26. data/docs/demo/fork-provider.recording.json +30 -0
  27. data/docs/demo/fork-provider.tape.yml +77 -20
  28. data/docs/getting-started.md +5 -2
  29. data/docs/usage.md +74 -4
  30. data/handbook/guides/fork-context.g.md +31 -7
  31. data/handbook/skills/as-assign-drive/SKILL.md +13 -1
  32. data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
  33. data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
  34. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
  35. data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
  36. data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
  37. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
  38. data/handbook/workflow-instructions/assign/create.wf.md +6 -3
  39. data/handbook/workflow-instructions/assign/drive.wf.md +330 -40
  40. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
  41. data/handbook/workflow-instructions/assign/prepare.wf.md +10 -5
  42. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
  43. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
  44. data/handbook/workflow-instructions/assign/start.wf.md +5 -2
  45. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
  46. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
  47. data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
  48. data/lib/ace/assign/atoms/preset_expander.rb +4 -0
  49. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  50. data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
  51. data/lib/ace/assign/cli/commands/add.rb +20 -11
  52. data/lib/ace/assign/cli/commands/assignment_target.rb +87 -3
  53. data/lib/ace/assign/cli/commands/create.rb +1 -1
  54. data/lib/ace/assign/cli/commands/fail.rb +1 -1
  55. data/lib/ace/assign/cli/commands/finish.rb +32 -8
  56. data/lib/ace/assign/cli/commands/fork_run.rb +58 -16
  57. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  58. data/lib/ace/assign/cli/commands/list.rb +4 -3
  59. data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
  60. data/lib/ace/assign/cli/commands/start.rb +9 -3
  61. data/lib/ace/assign/cli/commands/status.rb +237 -230
  62. data/lib/ace/assign/cli/commands/step.rb +62 -0
  63. data/lib/ace/assign/cli.rb +8 -1
  64. data/lib/ace/assign/models/assignment_info.rb +33 -4
  65. data/lib/ace/assign/models/queue_state.rb +101 -39
  66. data/lib/ace/assign/models/step.rb +17 -5
  67. data/lib/ace/assign/molecules/fork_session_launcher.rb +218 -21
  68. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  69. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
  70. data/lib/ace/assign/molecules/step_writer.rb +3 -3
  71. data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
  72. data/lib/ace/assign/organisms/assignment_executor.rb +355 -106
  73. data/lib/ace/assign/version.rb +1 -1
  74. data/lib/ace/assign.rb +1 -0
  75. metadata +35 -5
  76. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +0 -42
@@ -10,46 +10,97 @@ module Ace
10
10
  class ForkSessionLauncher
11
11
  DEFAULT_PROVIDER = "claude:sonnet"
12
12
  DEFAULT_TIMEOUT = 1800
13
+ DEFAULT_LAUNCH_MODE = "auto"
14
+ VALID_LAUNCH_MODES = %w[auto headless tmux].freeze
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"
13
19
 
14
- def initialize(config: nil, query_interface: Ace::LLM::QueryInterface)
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
37
+
38
+ def initialize(config: nil, query_interface: Ace::LLM::QueryInterface, tmux_runner: nil, interactive_builder: nil)
15
39
  @config = config || Ace::Assign.config
16
40
  @query_interface = query_interface
41
+ @tmux_runner = tmux_runner || TmuxControlSurfaceRunner.new
42
+ @interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
17
43
  end
18
44
 
19
- # Launch forked subtree execution synchronously.
20
- #
21
- # @param assignment_id [String] Assignment identifier
22
- # @param fork_root [String] Subtree root step number
23
- # @param provider [String, nil] Optional provider override
24
- # @param cli_args [String, nil] Optional provider CLI args
25
- # @param timeout [Integer, nil] Optional timeout override (seconds)
26
- # @param cache_dir [String, nil] Assignment cache directory for last-message capture
27
- # @return [Hash] QueryInterface response
28
- def launch(assignment_id:, fork_root:, provider: nil, cli_args: nil, timeout: nil, cache_dir: 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)
47
+ resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
48
+ resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
49
+ resolved_mode = resolve_launch_mode(launch_mode)
50
+
51
+ if resolved_mode == "tmux"
52
+ launch_tmux(
53
+ assignment_id: assignment_id,
54
+ fork_root: fork_root,
55
+ provider: resolved_provider,
56
+ cli_args: cli_args,
57
+ timeout: resolved_timeout,
58
+ cache_dir: cache_dir,
59
+ callback_pane: callback_pane
60
+ )
61
+ else
62
+ launch_provider_session(
63
+ assignment_id: assignment_id,
64
+ fork_root: fork_root,
65
+ provider: resolved_provider,
66
+ cli_args: cli_args,
67
+ timeout: resolved_timeout,
68
+ cache_dir: cache_dir
69
+ )
70
+ end
71
+ end
72
+
73
+ def callback_pane
74
+ tmux_runner.current_pane
75
+ end
76
+
77
+ def launch_provider_session(assignment_id:, fork_root:, provider:, cli_args: nil, timeout: nil, cache_dir: nil,
78
+ last_message_file: nil, session_meta_file: nil)
79
+ ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
29
80
  resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
30
81
  resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
31
82
  scoped_assignment = "#{assignment_id}@#{fork_root}"
32
- last_msg_file = build_last_message_file(cache_dir, fork_root)
83
+ prompt = "/as-assign-drive #{scoped_assignment}"
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)
33
86
 
34
87
  result = query_interface.query(
35
88
  resolved_provider,
36
- "/as-assign-drive #{scoped_assignment}",
89
+ prompt,
37
90
  system: nil,
38
91
  cli_args: cli_args,
39
92
  timeout: resolved_timeout,
40
93
  fallback: false,
41
- last_message_file: last_msg_file
94
+ last_message_file: last_msg_file,
95
+ subprocess_env: scope_env
42
96
  )
43
97
 
44
- # Layer 1 write: capture last message for non-Codex providers (or when Codex didn't write).
45
- # Safety: `query` blocks until the subprocess exits, so by this point Layer 2 (Codex
46
- # --output-last-message) has already finished writing. No other writer exists at this point.
47
98
  if last_msg_file && result[:text] && !result[:text].strip.empty?
48
99
  existing = File.exist?(last_msg_file) ? File.read(last_msg_file).strip : ""
49
100
  File.write(last_msg_file, result[:text]) if existing.empty?
50
101
  end
51
102
 
52
- write_session_metadata(last_msg_file, result, prompt: "/as-assign-drive #{scoped_assignment}")
103
+ write_session_metadata(last_msg_file, result, prompt: prompt, session_meta_file: session_meta_file)
53
104
 
54
105
  result
55
106
  rescue Ace::LLM::Error => e
@@ -58,9 +109,105 @@ module Ace
58
109
 
59
110
  private
60
111
 
61
- attr_reader :config, :query_interface
112
+ attr_reader :config, :query_interface, :tmux_runner, :interactive_builder
113
+
114
+ def resolve_launch_mode(explicit_mode)
115
+ mode = explicit_mode.to_s.strip
116
+ mode = config.dig("execution", "launch_mode").to_s.strip if mode.empty?
117
+ mode = DEFAULT_LAUNCH_MODE if mode.empty?
118
+ unless VALID_LAUNCH_MODES.include?(mode)
119
+ raise Error, "Invalid launch mode '#{mode}'. Expected one of: #{VALID_LAUNCH_MODES.join(', ')}"
120
+ end
121
+
122
+ return mode unless mode == "auto"
123
+
124
+ tmux_runner.tmux_context? ? "tmux" : "headless"
125
+ end
126
+
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)
129
+ session = tmux_runner.current_session
130
+ raise Error, "Launch mode tmux requires an active tmux session (TMUX or ACE_TMUX_SESSION)." unless session
131
+ raise Error, "Tmux launch requires assignment cache_dir for subtree polling." if cache_dir.to_s.strip.empty?
132
+
133
+ current_window = tmux_runner.current_window
134
+ raise Error, "Could not resolve current tmux window for fork launch." if current_window.to_s.strip.empty?
135
+
136
+ fork_window = ENV["ACE_ASSIGN_FORK_WINDOW"].to_s.strip
137
+ fork_window = tmux_runner.fork_window_name(current_window) if fork_window.empty?
138
+
139
+ window_info = tmux_runner.ensure_window(session: session, name: fork_window, root: Dir.pwd)
140
+ pane_target = tmux_runner.prepare_pane(
141
+ session: session,
142
+ window: fork_window,
143
+ window_target: window_info[:target],
144
+ root: Dir.pwd,
145
+ keep_existing: window_info[:created]
146
+ )
147
+
148
+ session_meta_file = build_session_meta_file(cache_dir, fork_root)
149
+ prompt = "/as-assign-drive #{assignment_id}@#{fork_root}"
150
+ tmux_env = tmux_subprocess_env(
151
+ assignment_id: assignment_id,
152
+ fork_root: fork_root,
153
+ session: session,
154
+ fork_window: fork_window,
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
163
+ )
164
+
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
+ )
172
+ write_tmux_launch_metadata(
173
+ session_meta_file: session_meta_file,
174
+ provider: invocation[:provider],
175
+ model: invocation[:model],
176
+ prompt: invocation[:prompt]
177
+ )
178
+ tmux_runner.merge_tmux_metadata(
179
+ session_meta_file: session_meta_file,
180
+ session: session,
181
+ window: fork_window,
182
+ pane: pane_target,
183
+ window_id: window_info[:window_id],
184
+ callback_pane: callback_pane
185
+ )
186
+
187
+ return {tmux: true, pane_target: pane_target, callback_mode: true, callback_pane: callback_pane} if callback_pane
188
+
189
+ wait_for_subtree_terminal(
190
+ assignment_id: assignment_id,
191
+ fork_root: fork_root,
192
+ cache_dir: cache_dir,
193
+ timeout: timeout
194
+ )
195
+
196
+ {tmux: true, pane_target: pane_target}
197
+ end
62
198
 
63
- def write_session_metadata(last_msg_file, result, prompt:)
199
+ def write_tmux_launch_metadata(session_meta_file:, provider:, model:, prompt:)
200
+ detected = detect_provider_session(provider, prompt)
201
+ meta = {}
202
+ meta = YAML.safe_load_file(session_meta_file) || {} if File.exist?(session_meta_file)
203
+ meta["provider"] ||= provider
204
+ meta["model"] ||= model
205
+ meta["session_id"] ||= detected&.dig(:session_id)
206
+ meta["launched_at"] ||= Time.now.utc.iso8601
207
+ File.write(session_meta_file, meta.to_yaml) unless meta.empty?
208
+ end
209
+
210
+ def write_session_metadata(last_msg_file, result, prompt:, session_meta_file: nil)
64
211
  return unless last_msg_file
65
212
 
66
213
  session_id = result.dig(:metadata, :session_id)
@@ -70,7 +217,7 @@ module Ace
70
217
  session_id = detected&.dig(:session_id)
71
218
  end
72
219
 
73
- session_meta_file = last_msg_file.sub(/-last-message\.md$/, "-session.yml")
220
+ session_meta_file ||= last_msg_file.sub(/-last-message\.md$/, "-session.yml")
74
221
  meta = {
75
222
  "session_id" => session_id,
76
223
  "provider" => result[:provider],
@@ -96,6 +243,56 @@ module Ace
96
243
  FileUtils.mkdir_p(sessions_dir)
97
244
  File.join(sessions_dir, "#{fork_root}-last-message.md")
98
245
  end
246
+
247
+ def build_session_meta_file(cache_dir, fork_root)
248
+ return nil unless cache_dir
249
+
250
+ sessions_dir = File.join(cache_dir, "sessions")
251
+ FileUtils.mkdir_p(sessions_dir)
252
+ File.join(sessions_dir, "#{fork_root}-session.yml")
253
+ end
254
+
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
271
+ end
272
+
273
+ def wait_for_subtree_terminal(assignment_id:, fork_root:, cache_dir:, timeout:)
274
+ assignment = Models::Assignment.new(
275
+ id: assignment_id,
276
+ name: "fork",
277
+ created_at: Time.now.utc,
278
+ source_config: "fork-run",
279
+ cache_dir: cache_dir
280
+ )
281
+ scanner = QueueScanner.new
282
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_i
283
+
284
+ loop do
285
+ state = scanner.scan(assignment.steps_dir, assignment: assignment)
286
+ return state if state.subtree_complete?(fork_root)
287
+ raise Error, "Fork session execution failed in tmux subtree #{fork_root}." if state.subtree_failed?(fork_root)
288
+
289
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
290
+ raise Error, "Timed out waiting for tmux fork subtree #{fork_root} to reach a terminal state."
291
+ end
292
+
293
+ sleep(TMUX_POLL_INTERVAL)
294
+ end
295
+ end
99
296
  end
100
297
  end
101
298
  end
@@ -81,6 +81,7 @@ module Ace
81
81
  fork_pid_file: fields[:fork_pid_file],
82
82
  added_by: fields[:added_by],
83
83
  parent: fields[:parent],
84
+ source: fields[:source],
84
85
  skill: fields[:skill],
85
86
  workflow: fields[:workflow],
86
87
  context: fields[:context],