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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
- data/.ace-defaults/assign/config.yml +1 -0
- data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
- data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +6 -16
- data/CHANGELOG.md +216 -0
- data/README.md +20 -43
- data/docs/demo/canonical-skill-source.gif +0 -0
- data/docs/demo/canonical-skill-source.tape.yml +51 -0
- data/docs/demo/fork-provider.cast +834 -0
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +30 -0
- data/docs/demo/fork-provider.tape.yml +77 -20
- data/docs/getting-started.md +5 -2
- data/docs/usage.md +74 -4
- data/handbook/guides/fork-context.g.md +31 -7
- data/handbook/skills/as-assign-drive/SKILL.md +13 -1
- data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
- data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
- data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
- data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
- data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
- data/handbook/workflow-instructions/assign/create.wf.md +6 -3
- data/handbook/workflow-instructions/assign/drive.wf.md +330 -40
- data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +10 -5
- data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
- data/handbook/workflow-instructions/assign/start.wf.md +5 -2
- data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
- data/lib/ace/assign/atoms/preset_expander.rb +4 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +15 -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 +87 -3
- 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 +32 -8
- data/lib/ace/assign/cli/commands/fork_run.rb +58 -16
- data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
- 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/start.rb +9 -3
- data/lib/ace/assign/cli/commands/status.rb +237 -230
- data/lib/ace/assign/cli/commands/step.rb +62 -0
- data/lib/ace/assign/cli.rb +8 -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 +17 -5
- data/lib/ace/assign/molecules/fork_session_launcher.rb +218 -21
- data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
- 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 +355 -106
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -0
- metadata +35 -5
- 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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|