ace-assign 0.37.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 +7 -0
- data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
- data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
- data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
- data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
- data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
- data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
- data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
- data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
- data/.ace-defaults/assign/config.yml +48 -0
- data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
- data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
- data/.ace-defaults/assign/presets/release-only.yml +35 -0
- data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
- data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
- data/CHANGELOG.md +1415 -0
- data/README.md +87 -0
- data/Rakefile +16 -0
- data/docs/exit-codes.md +61 -0
- data/docs/getting-started.md +121 -0
- data/docs/handbook.md +40 -0
- data/docs/usage.md +224 -0
- data/exe/ace-assign +16 -0
- data/handbook/guides/fork-context.g.md +231 -0
- data/handbook/skills/as-assign-compose/SKILL.md +24 -0
- data/handbook/skills/as-assign-create/SKILL.md +23 -0
- data/handbook/skills/as-assign-drive/SKILL.md +24 -0
- data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
- data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
- data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
- data/handbook/skills/as-assign-start/SKILL.md +25 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
- data/handbook/workflow-instructions/assign/create.wf.md +215 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
- data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
- data/handbook/workflow-instructions/assign/start.wf.md +46 -0
- data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
- data/lib/ace/assign/atoms/composition_rules.rb +219 -0
- data/lib/ace/assign/atoms/number_generator.rb +110 -0
- data/lib/ace/assign/atoms/preset_expander.rb +277 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
- data/lib/ace/assign/atoms/step_numbering.rb +227 -0
- data/lib/ace/assign/atoms/step_sorter.rb +66 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
- data/lib/ace/assign/cli/commands/add.rb +102 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
- data/lib/ace/assign/cli/commands/create.rb +63 -0
- data/lib/ace/assign/cli/commands/fail.rb +43 -0
- data/lib/ace/assign/cli/commands/finish.rb +88 -0
- data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
- data/lib/ace/assign/cli/commands/list.rb +166 -0
- data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
- data/lib/ace/assign/cli/commands/select.rb +45 -0
- data/lib/ace/assign/cli/commands/start.rb +40 -0
- data/lib/ace/assign/cli/commands/status.rb +407 -0
- data/lib/ace/assign/cli.rb +144 -0
- data/lib/ace/assign/models/assignment.rb +107 -0
- data/lib/ace/assign/models/assignment_info.rb +66 -0
- data/lib/ace/assign/models/queue_state.rb +326 -0
- data/lib/ace/assign/models/step.rb +197 -0
- data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
- data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
- data/lib/ace/assign/molecules/step_writer.rb +246 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
- data/lib/ace/assign/version.rb +7 -0
- data/lib/ace/assign.rb +141 -0
- metadata +289 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "ace/b36ts"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
# Manages assignment YAML file operations.
|
|
11
|
+
#
|
|
12
|
+
# Handles creation, loading, and updating of assignment.yaml files.
|
|
13
|
+
# Uses ace-b36ts for assignment ID generation.
|
|
14
|
+
class AssignmentManager
|
|
15
|
+
# @param cache_base [String] Base cache directory
|
|
16
|
+
def initialize(cache_base: nil)
|
|
17
|
+
@cache_base = cache_base || Ace::Assign.cache_dir
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Create a new assignment
|
|
21
|
+
#
|
|
22
|
+
# @param name [String] Assignment name
|
|
23
|
+
# @param description [String, nil] Assignment description
|
|
24
|
+
# @param source_config [String] Path to source config file
|
|
25
|
+
# @param parent [String, nil] Parent assignment ID for hierarchy linking
|
|
26
|
+
# @return [Models::Assignment] Created assignment
|
|
27
|
+
def create(name:, source_config:, description: nil, parent: nil)
|
|
28
|
+
# Ensure cache base directory exists before generate_assignment_id
|
|
29
|
+
FileUtils.mkdir_p(@cache_base)
|
|
30
|
+
|
|
31
|
+
assignment_id = generate_assignment_id
|
|
32
|
+
cache_dir = File.join(@cache_base, assignment_id)
|
|
33
|
+
|
|
34
|
+
# Create directories
|
|
35
|
+
FileUtils.mkdir_p(cache_dir)
|
|
36
|
+
FileUtils.mkdir_p(File.join(cache_dir, "steps"))
|
|
37
|
+
FileUtils.mkdir_p(File.join(cache_dir, "reports"))
|
|
38
|
+
|
|
39
|
+
now = Time.now.utc
|
|
40
|
+
|
|
41
|
+
assignment = Models::Assignment.new(
|
|
42
|
+
id: assignment_id,
|
|
43
|
+
name: name,
|
|
44
|
+
description: description,
|
|
45
|
+
created_at: now,
|
|
46
|
+
updated_at: now,
|
|
47
|
+
source_config: source_config,
|
|
48
|
+
cache_dir: cache_dir,
|
|
49
|
+
parent: parent
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Write assignment.yaml
|
|
53
|
+
write_assignment_file(assignment)
|
|
54
|
+
|
|
55
|
+
# Update .latest symlink for O(1) active assignment lookup
|
|
56
|
+
update_latest_symlink(assignment_id)
|
|
57
|
+
|
|
58
|
+
assignment
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Load an existing assignment by ID
|
|
62
|
+
#
|
|
63
|
+
# @param assignment_id [String] Assignment ID
|
|
64
|
+
# @return [Models::Assignment, nil] Loaded assignment or nil
|
|
65
|
+
def load(assignment_id)
|
|
66
|
+
cache_dir = File.join(@cache_base, assignment_id)
|
|
67
|
+
assignment_file = File.join(cache_dir, "assignment.yaml")
|
|
68
|
+
|
|
69
|
+
return nil unless File.exist?(assignment_file)
|
|
70
|
+
|
|
71
|
+
data = YAML.safe_load_file(assignment_file, permitted_classes: [Time, Date])
|
|
72
|
+
Models::Assignment.from_h(data, cache_dir: cache_dir)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Find the most recent active assignment
|
|
76
|
+
#
|
|
77
|
+
# Uses resolution order:
|
|
78
|
+
# 1. .current symlink (explicit user selection)
|
|
79
|
+
# 2. .latest symlink (auto, most recent)
|
|
80
|
+
# 3. Scan all assignments (fallback)
|
|
81
|
+
#
|
|
82
|
+
# @return [Models::Assignment, nil] Most recent assignment or nil
|
|
83
|
+
def find_active
|
|
84
|
+
return nil unless File.directory?(@cache_base)
|
|
85
|
+
|
|
86
|
+
# Priority 1: use .current symlink if it exists (explicit selection)
|
|
87
|
+
current_symlink = File.join(@cache_base, ".current")
|
|
88
|
+
if File.symlink?(current_symlink)
|
|
89
|
+
assignment_id = File.basename(File.readlink(current_symlink))
|
|
90
|
+
assignment = load(assignment_id)
|
|
91
|
+
return assignment if assignment
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Priority 2: use .latest symlink if it exists
|
|
95
|
+
latest_symlink = File.join(@cache_base, ".latest")
|
|
96
|
+
if File.symlink?(latest_symlink)
|
|
97
|
+
assignment_id = File.basename(File.readlink(latest_symlink))
|
|
98
|
+
assignment = load(assignment_id)
|
|
99
|
+
return assignment if assignment
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Fallback: find all assignment directories
|
|
103
|
+
assignments = Dir.glob(File.join(@cache_base, "*", "assignment.yaml"))
|
|
104
|
+
.map { |f| load_from_file(f) }
|
|
105
|
+
.compact
|
|
106
|
+
.sort_by(&:updated_at)
|
|
107
|
+
.reverse
|
|
108
|
+
|
|
109
|
+
assignments.first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Set current assignment via .current symlink
|
|
113
|
+
#
|
|
114
|
+
# @param assignment_id [String] Assignment ID to set as current
|
|
115
|
+
# @raise [AssignmentErrors::NotFound] if assignment doesn't exist
|
|
116
|
+
def set_current(assignment_id)
|
|
117
|
+
assignment = load(assignment_id)
|
|
118
|
+
raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment
|
|
119
|
+
|
|
120
|
+
current_symlink = File.join(@cache_base, ".current")
|
|
121
|
+
|
|
122
|
+
# Remove old symlink if it exists
|
|
123
|
+
File.delete(current_symlink) if File.symlink?(current_symlink)
|
|
124
|
+
|
|
125
|
+
# Create new symlink
|
|
126
|
+
target_dir = File.join(@cache_base, assignment_id)
|
|
127
|
+
File.symlink(target_dir, current_symlink)
|
|
128
|
+
|
|
129
|
+
assignment
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Clear current assignment selection
|
|
133
|
+
#
|
|
134
|
+
# Removes the .current symlink, falling back to .latest resolution
|
|
135
|
+
def clear_current
|
|
136
|
+
current_symlink = File.join(@cache_base, ".current")
|
|
137
|
+
File.delete(current_symlink) if File.symlink?(current_symlink)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Get the currently selected assignment ID (from .current symlink)
|
|
141
|
+
#
|
|
142
|
+
# @return [String, nil] Current assignment ID or nil
|
|
143
|
+
def current_id
|
|
144
|
+
current_symlink = File.join(@cache_base, ".current")
|
|
145
|
+
return nil unless File.symlink?(current_symlink)
|
|
146
|
+
|
|
147
|
+
File.basename(File.readlink(current_symlink))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Delete an assignment's cache directory and clean up symlinks
|
|
151
|
+
#
|
|
152
|
+
# @param assignment_id [String] Assignment ID to delete
|
|
153
|
+
# @return [Boolean] true if deleted, false if not found
|
|
154
|
+
def delete(assignment_id)
|
|
155
|
+
dir = File.join(@cache_base, assignment_id)
|
|
156
|
+
return false unless File.directory?(dir)
|
|
157
|
+
|
|
158
|
+
cleanup_symlink(".current", assignment_id)
|
|
159
|
+
cleanup_symlink(".latest", assignment_id)
|
|
160
|
+
FileUtils.rm_rf(dir)
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Update assignment metadata
|
|
165
|
+
#
|
|
166
|
+
# @param assignment [Models::Assignment] Assignment to update
|
|
167
|
+
# @return [Models::Assignment] Updated assignment
|
|
168
|
+
def update(assignment)
|
|
169
|
+
# Create new assignment with updated timestamp
|
|
170
|
+
updated = Models::Assignment.new(
|
|
171
|
+
id: assignment.id,
|
|
172
|
+
name: assignment.name,
|
|
173
|
+
description: assignment.description,
|
|
174
|
+
created_at: assignment.created_at,
|
|
175
|
+
updated_at: Time.now.utc,
|
|
176
|
+
source_config: assignment.source_config,
|
|
177
|
+
cache_dir: assignment.cache_dir,
|
|
178
|
+
parent: assignment.parent
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
write_assignment_file(updated)
|
|
182
|
+
|
|
183
|
+
# Update .latest symlink since this assignment was just updated
|
|
184
|
+
update_latest_symlink(assignment.id)
|
|
185
|
+
|
|
186
|
+
updated
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# List all assignments
|
|
190
|
+
#
|
|
191
|
+
# @return [Array<Models::Assignment>] All assignments
|
|
192
|
+
def list
|
|
193
|
+
return [] unless File.directory?(@cache_base)
|
|
194
|
+
|
|
195
|
+
Dir.glob(File.join(@cache_base, "*", "assignment.yaml"))
|
|
196
|
+
.map { |f| load_from_file(f) }
|
|
197
|
+
.compact
|
|
198
|
+
.sort_by(&:updated_at)
|
|
199
|
+
.reverse
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def generate_assignment_id
|
|
205
|
+
base_id = Ace::B36ts.now
|
|
206
|
+
candidate = base_id
|
|
207
|
+
max_attempts = 100
|
|
208
|
+
|
|
209
|
+
# Handle collision by appending suffix
|
|
210
|
+
suffix = 0
|
|
211
|
+
max_attempts.times do
|
|
212
|
+
dir_path = File.join(@cache_base, candidate)
|
|
213
|
+
# Atomic directory creation using Dir.mkdir - fails if exists
|
|
214
|
+
begin
|
|
215
|
+
Dir.mkdir(dir_path)
|
|
216
|
+
return candidate
|
|
217
|
+
rescue Errno::EEXIST
|
|
218
|
+
# Directory already exists, try next candidate
|
|
219
|
+
suffix += 1
|
|
220
|
+
candidate = "#{base_id}#{suffix.to_s(36)}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Max attempts exceeded - this should never happen in practice
|
|
225
|
+
raise Error, "Failed to generate unique assignment ID after #{max_attempts} attempts. Cache directory may be corrupted."
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def write_assignment_file(assignment)
|
|
229
|
+
assignment_file = File.join(assignment.cache_dir, "assignment.yaml")
|
|
230
|
+
File.write(assignment_file, assignment.to_h.to_yaml)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def load_from_file(assignment_file)
|
|
234
|
+
cache_dir = File.dirname(assignment_file)
|
|
235
|
+
data = YAML.safe_load_file(assignment_file, permitted_classes: [Time, Date])
|
|
236
|
+
Models::Assignment.from_h(data, cache_dir: cache_dir)
|
|
237
|
+
rescue => e
|
|
238
|
+
warn "Failed to load assignment from #{assignment_file}: #{e.message}" if Ace::Assign.debug?
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Remove a symlink if it points to the specified assignment
|
|
243
|
+
#
|
|
244
|
+
# @param symlink_name [String] Symlink name (e.g., ".current", ".latest")
|
|
245
|
+
# @param assignment_id [String] Assignment ID to match
|
|
246
|
+
def cleanup_symlink(symlink_name, assignment_id)
|
|
247
|
+
symlink_path = File.join(@cache_base, symlink_name)
|
|
248
|
+
return unless File.symlink?(symlink_path)
|
|
249
|
+
|
|
250
|
+
target = File.basename(File.readlink(symlink_path))
|
|
251
|
+
File.delete(symlink_path) if target == assignment_id
|
|
252
|
+
rescue
|
|
253
|
+
# Non-fatal: continue without cleanup
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Update .latest symlink to point to the specified assignment
|
|
257
|
+
# Provides O(1) active assignment lookup
|
|
258
|
+
#
|
|
259
|
+
# @param assignment_id [String] Assignment ID to link as .latest
|
|
260
|
+
def update_latest_symlink(assignment_id)
|
|
261
|
+
latest_symlink = File.join(@cache_base, ".latest")
|
|
262
|
+
target_dir = File.join(@cache_base, assignment_id)
|
|
263
|
+
|
|
264
|
+
# Remove old symlink if it exists
|
|
265
|
+
File.delete(latest_symlink) if File.symlink?(latest_symlink)
|
|
266
|
+
|
|
267
|
+
# Create new symlink
|
|
268
|
+
File.symlink(target_dir, latest_symlink)
|
|
269
|
+
rescue => e
|
|
270
|
+
warn "Failed to update .latest symlink: #{e.message}" if Ace::Assign.debug?
|
|
271
|
+
# Non-fatal: continue without symlink
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/llm"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# Launches a forked assignment-driving session via CLI LLM providers.
|
|
10
|
+
class ForkSessionLauncher
|
|
11
|
+
DEFAULT_PROVIDER = "claude:sonnet"
|
|
12
|
+
DEFAULT_TIMEOUT = 1800
|
|
13
|
+
|
|
14
|
+
def initialize(config: nil, query_interface: Ace::LLM::QueryInterface)
|
|
15
|
+
@config = config || Ace::Assign.config
|
|
16
|
+
@query_interface = query_interface
|
|
17
|
+
end
|
|
18
|
+
|
|
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)
|
|
29
|
+
resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
|
|
30
|
+
resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
|
|
31
|
+
scoped_assignment = "#{assignment_id}@#{fork_root}"
|
|
32
|
+
last_msg_file = build_last_message_file(cache_dir, fork_root)
|
|
33
|
+
|
|
34
|
+
result = query_interface.query(
|
|
35
|
+
resolved_provider,
|
|
36
|
+
"/as-assign-drive #{scoped_assignment}",
|
|
37
|
+
system: nil,
|
|
38
|
+
cli_args: cli_args,
|
|
39
|
+
timeout: resolved_timeout,
|
|
40
|
+
fallback: false,
|
|
41
|
+
last_message_file: last_msg_file
|
|
42
|
+
)
|
|
43
|
+
|
|
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
|
+
if last_msg_file && result[:text] && !result[:text].strip.empty?
|
|
48
|
+
existing = File.exist?(last_msg_file) ? File.read(last_msg_file).strip : ""
|
|
49
|
+
File.write(last_msg_file, result[:text]) if existing.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
write_session_metadata(last_msg_file, result, prompt: "/as-assign-drive #{scoped_assignment}")
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
rescue Ace::LLM::Error => e
|
|
56
|
+
raise Error, "Fork session execution failed via #{resolved_provider}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
attr_reader :config, :query_interface
|
|
62
|
+
|
|
63
|
+
def write_session_metadata(last_msg_file, result, prompt:)
|
|
64
|
+
return unless last_msg_file
|
|
65
|
+
|
|
66
|
+
session_id = result.dig(:metadata, :session_id)
|
|
67
|
+
|
|
68
|
+
if session_id.nil? || session_id.to_s.strip.empty?
|
|
69
|
+
detected = detect_provider_session(result[:provider], prompt)
|
|
70
|
+
session_id = detected&.dig(:session_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
session_meta_file = last_msg_file.sub(/-last-message\.md$/, "-session.yml")
|
|
74
|
+
meta = {
|
|
75
|
+
"session_id" => session_id,
|
|
76
|
+
"provider" => result[:provider],
|
|
77
|
+
"model" => result[:model],
|
|
78
|
+
"completed_at" => Time.now.utc.iso8601
|
|
79
|
+
}.compact
|
|
80
|
+
File.write(session_meta_file, meta.to_yaml) unless meta.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def detect_provider_session(provider, prompt)
|
|
84
|
+
require "ace/llm/providers/cli/molecules/session_finder"
|
|
85
|
+
Ace::LLM::Providers::CLI::Molecules::SessionFinder.call(
|
|
86
|
+
provider: provider, working_dir: Dir.pwd, prompt: prompt
|
|
87
|
+
)
|
|
88
|
+
rescue LoadError, StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_last_message_file(cache_dir, fork_root)
|
|
93
|
+
return nil unless cache_dir
|
|
94
|
+
|
|
95
|
+
sessions_dir = File.join(cache_dir, "sessions")
|
|
96
|
+
FileUtils.mkdir_p(sessions_dir)
|
|
97
|
+
File.join(sessions_dir, "#{fork_root}-last-message.md")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Molecules
|
|
6
|
+
# Scans and builds queue state from step files.
|
|
7
|
+
#
|
|
8
|
+
# Reconstructs queue by scanning steps/*.md files, sorting them,
|
|
9
|
+
# and parsing each file to build the complete queue state.
|
|
10
|
+
class QueueScanner
|
|
11
|
+
# Scan a steps directory and build queue state
|
|
12
|
+
#
|
|
13
|
+
# @param steps_dir [String] Path to steps directory
|
|
14
|
+
# @param assignment [Models::Assignment] Assignment metadata
|
|
15
|
+
# @return [Models::QueueState] Queue state
|
|
16
|
+
def scan(steps_dir, assignment:)
|
|
17
|
+
return Models::QueueState.new(steps: [], assignment: assignment) unless File.directory?(steps_dir)
|
|
18
|
+
|
|
19
|
+
# Get all step files
|
|
20
|
+
files = Dir.glob(File.join(steps_dir, "*.st.md"))
|
|
21
|
+
|
|
22
|
+
# Sort files
|
|
23
|
+
sorted_files = Atoms::StepSorter.sort(files.map { |f| File.basename(f) })
|
|
24
|
+
.map { |f| File.join(steps_dir, f) }
|
|
25
|
+
|
|
26
|
+
# Parse each file into a Step
|
|
27
|
+
steps = sorted_files.map { |file| parse_step_file(file) }.compact
|
|
28
|
+
|
|
29
|
+
Models::QueueState.new(steps: steps, assignment: assignment)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get current step from queue
|
|
33
|
+
#
|
|
34
|
+
# @param steps_dir [String] Path to steps directory
|
|
35
|
+
# @param assignment [Models::Assignment] Assignment metadata
|
|
36
|
+
# @return [Models::Step, nil] Current in-progress step
|
|
37
|
+
def current(steps_dir, assignment:)
|
|
38
|
+
state = scan(steps_dir, assignment: assignment)
|
|
39
|
+
state.current
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get all step numbers in the queue
|
|
43
|
+
#
|
|
44
|
+
# @param steps_dir [String] Path to steps directory
|
|
45
|
+
# @return [Array<String>] Step numbers
|
|
46
|
+
def step_numbers(steps_dir)
|
|
47
|
+
return [] unless File.directory?(steps_dir)
|
|
48
|
+
|
|
49
|
+
files = Dir.glob(File.join(steps_dir, "*.st.md"))
|
|
50
|
+
files.map do |file|
|
|
51
|
+
parsed = Atoms::StepFileParser.parse_filename(File.basename(file))
|
|
52
|
+
parsed[:number]
|
|
53
|
+
end.compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_step_file(file_path)
|
|
59
|
+
content = File.read(file_path)
|
|
60
|
+
parsed = Atoms::StepFileParser.parse(content)
|
|
61
|
+
fields = Atoms::StepFileParser.extract_fields(parsed)
|
|
62
|
+
|
|
63
|
+
# Extract number and name from filename
|
|
64
|
+
filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
|
|
65
|
+
|
|
66
|
+
# Load report from separate file if it exists
|
|
67
|
+
report = load_report(file_path, filename_info[:number], filename_info[:name])
|
|
68
|
+
|
|
69
|
+
Models::Step.new(
|
|
70
|
+
number: filename_info[:number],
|
|
71
|
+
name: fields[:name] || filename_info[:name],
|
|
72
|
+
status: fields[:status],
|
|
73
|
+
instructions: fields[:instructions],
|
|
74
|
+
report: report,
|
|
75
|
+
error: fields[:error],
|
|
76
|
+
started_at: fields[:started_at],
|
|
77
|
+
completed_at: fields[:completed_at],
|
|
78
|
+
fork_launch_pid: fields[:fork_launch_pid],
|
|
79
|
+
fork_tracked_pids: fields[:fork_tracked_pids],
|
|
80
|
+
fork_pid_updated_at: fields[:fork_pid_updated_at],
|
|
81
|
+
fork_pid_file: fields[:fork_pid_file],
|
|
82
|
+
added_by: fields[:added_by],
|
|
83
|
+
parent: fields[:parent],
|
|
84
|
+
skill: fields[:skill],
|
|
85
|
+
workflow: fields[:workflow],
|
|
86
|
+
context: fields[:context],
|
|
87
|
+
batch_parent: fields[:batch_parent],
|
|
88
|
+
parallel: fields[:parallel],
|
|
89
|
+
max_parallel: fields[:max_parallel],
|
|
90
|
+
fork_retry_limit: fields[:fork_retry_limit],
|
|
91
|
+
stall_reason: fields[:stall_reason],
|
|
92
|
+
file_path: file_path
|
|
93
|
+
)
|
|
94
|
+
rescue ArgumentError => e
|
|
95
|
+
# ArgumentError indicates invalid data (e.g., invalid context value)
|
|
96
|
+
# Surface these errors visibly to help users fix configuration
|
|
97
|
+
warn "Invalid step file #{file_path}: #{e.message}"
|
|
98
|
+
nil
|
|
99
|
+
rescue => e
|
|
100
|
+
warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Load report from the reports/ directory
|
|
105
|
+
# @param step_file_path [String] Path to step file
|
|
106
|
+
# @param number [String] Step number
|
|
107
|
+
# @param name [String] Step name
|
|
108
|
+
# @return [String, nil] Report content or nil
|
|
109
|
+
def load_report(step_file_path, number, name)
|
|
110
|
+
# reports/ is sibling of steps/
|
|
111
|
+
steps_dir = File.dirname(step_file_path)
|
|
112
|
+
cache_dir = File.dirname(steps_dir)
|
|
113
|
+
reports_dir = File.join(cache_dir, "reports")
|
|
114
|
+
|
|
115
|
+
return nil unless File.directory?(reports_dir)
|
|
116
|
+
|
|
117
|
+
report_filename = Atoms::StepFileParser.generate_report_filename(number, name)
|
|
118
|
+
report_path = File.join(reports_dir, report_filename)
|
|
119
|
+
|
|
120
|
+
return nil unless File.exist?(report_path)
|
|
121
|
+
|
|
122
|
+
# Read report file and extract body (skip frontmatter)
|
|
123
|
+
content = File.read(report_path)
|
|
124
|
+
parsed = Atoms::StepFileParser.parse(content)
|
|
125
|
+
parsed[:body].strip
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|