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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Models
|
|
6
|
+
# Enriched assignment model combining Assignment + QueueState.
|
|
7
|
+
#
|
|
8
|
+
# Pure data carrier with computed state (ATOM pattern).
|
|
9
|
+
# Wraps an assignment with its queue state to provide
|
|
10
|
+
# computed state, progress, and current step information.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# info = AssignmentInfo.new(assignment: assignment, queue_state: state)
|
|
14
|
+
# info.state # => :running
|
|
15
|
+
# info.progress # => "2/5"
|
|
16
|
+
# info.current_step # => "020-implement"
|
|
17
|
+
class AssignmentInfo
|
|
18
|
+
attr_reader :assignment, :queue_state
|
|
19
|
+
|
|
20
|
+
# @param assignment [Assignment] Assignment metadata
|
|
21
|
+
# @param queue_state [QueueState] Queue state for this assignment
|
|
22
|
+
def initialize(assignment:, queue_state:)
|
|
23
|
+
@assignment = assignment
|
|
24
|
+
@queue_state = queue_state
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Computed assignment state
|
|
28
|
+
#
|
|
29
|
+
# @return [Symbol] :empty, :failed, :completed, :running, or :paused
|
|
30
|
+
def state
|
|
31
|
+
queue_state.assignment_state
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Progress string (done/total)
|
|
35
|
+
#
|
|
36
|
+
# @return [String] Progress display (e.g., "2/5")
|
|
37
|
+
def progress
|
|
38
|
+
s = queue_state.summary
|
|
39
|
+
"#{s[:done]}/#{s[:total]}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Current step display string
|
|
43
|
+
#
|
|
44
|
+
# @return [String] Current step name or "-"
|
|
45
|
+
def current_step
|
|
46
|
+
queue_state.current&.name || "-"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if assignment is completed
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def completed?
|
|
53
|
+
state == :completed
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Delegate common accessors to assignment
|
|
57
|
+
def id = assignment.id
|
|
58
|
+
def name = assignment.name
|
|
59
|
+
def task_ref = assignment.name
|
|
60
|
+
def updated_at = assignment.updated_at
|
|
61
|
+
def created_at = assignment.created_at
|
|
62
|
+
def parent = assignment.parent
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Models
|
|
6
|
+
# Queue state model representing a snapshot of the work queue.
|
|
7
|
+
#
|
|
8
|
+
# Pure data carrier with no business logic (ATOM pattern).
|
|
9
|
+
# Provides convenient accessors for queue analysis.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# state = QueueState.new(steps: steps, assignment: assignment)
|
|
13
|
+
# state.current # => Step with status :in_progress
|
|
14
|
+
# state.pending # => Array of pending steps
|
|
15
|
+
class QueueState
|
|
16
|
+
attr_reader :steps, :assignment
|
|
17
|
+
|
|
18
|
+
# @param steps [Array<Step>] All steps in queue order
|
|
19
|
+
# @param assignment [Assignment] Assignment metadata
|
|
20
|
+
def initialize(steps:, assignment:)
|
|
21
|
+
@steps = steps.freeze
|
|
22
|
+
@assignment = assignment
|
|
23
|
+
@children_index = build_children_index(steps)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get current in-progress step
|
|
27
|
+
# @return [Step, nil] Current step or nil
|
|
28
|
+
def current
|
|
29
|
+
in_progress_steps.first
|
|
30
|
+
end
|
|
31
|
+
|
|
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 }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get all pending steps
|
|
39
|
+
# @return [Array<Step>] Pending steps
|
|
40
|
+
def pending
|
|
41
|
+
steps.select { |s| s.status == :pending }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get all done steps
|
|
45
|
+
# @return [Array<Step>] Completed steps
|
|
46
|
+
def done
|
|
47
|
+
steps.select { |s| s.status == :done }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get all failed steps
|
|
51
|
+
# @return [Array<Step>] Failed steps
|
|
52
|
+
def failed
|
|
53
|
+
steps.select { |s| s.status == :failed }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get next pending step
|
|
57
|
+
# @return [Step, nil] Next step to work on
|
|
58
|
+
def next_pending
|
|
59
|
+
pending.first
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if queue is empty
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def empty?
|
|
65
|
+
steps.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if all steps are complete (no pending or in_progress)
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def complete?
|
|
71
|
+
steps.all?(&:complete?)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get step by number
|
|
75
|
+
# @param number [String] Step number (e.g., "010", "040")
|
|
76
|
+
# @return [Step, nil] Found step
|
|
77
|
+
def find_by_number(number)
|
|
78
|
+
# Normalize to string without leading zeros for comparison
|
|
79
|
+
normalized = number.to_s.sub(/^0+/, "")
|
|
80
|
+
steps.find do |s|
|
|
81
|
+
s.number.sub(/^0+/, "") == normalized || s.number == number.to_s
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get last step in queue
|
|
86
|
+
# @return [Step, nil] Last step
|
|
87
|
+
def last
|
|
88
|
+
steps.last
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get last completed step
|
|
92
|
+
# @return [Step, nil] Last done step
|
|
93
|
+
def last_done
|
|
94
|
+
done.last
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Total step count
|
|
98
|
+
# @return [Integer] Number of steps
|
|
99
|
+
def size
|
|
100
|
+
steps.size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Computed assignment state based on step statuses
|
|
104
|
+
#
|
|
105
|
+
# States (checked in priority order):
|
|
106
|
+
# - :empty - No steps in queue
|
|
107
|
+
# - :completed - All steps complete (done or failed)
|
|
108
|
+
# - :failed - Has failed step(s) but NOT all complete (stuck)
|
|
109
|
+
# - :running - Has in_progress step with recent activity (< 1 hour)
|
|
110
|
+
# - :stalled - Has in_progress step but stale (> 1 hour)
|
|
111
|
+
# - :paused - Has pending but no in_progress (interrupted)
|
|
112
|
+
#
|
|
113
|
+
# @return [Symbol] Assignment state
|
|
114
|
+
def assignment_state
|
|
115
|
+
return :empty if empty?
|
|
116
|
+
return :completed if complete?
|
|
117
|
+
return :failed if failed.any?
|
|
118
|
+
return :running if current && recently_active?
|
|
119
|
+
return :stalled if current
|
|
120
|
+
|
|
121
|
+
:paused
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check if the current in_progress step has recent activity
|
|
125
|
+
# @param threshold [Integer] Seconds since started_at to consider active (default: 1 hour)
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def recently_active?(threshold: 3600)
|
|
128
|
+
return false unless current&.started_at
|
|
129
|
+
|
|
130
|
+
(Time.now - current.started_at) < threshold
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Summary for display
|
|
134
|
+
# @return [Hash] Summary statistics
|
|
135
|
+
def summary
|
|
136
|
+
{
|
|
137
|
+
total: size,
|
|
138
|
+
done: done.size,
|
|
139
|
+
in_progress: in_progress_steps.size,
|
|
140
|
+
pending: pending.size,
|
|
141
|
+
failed: failed.size
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Get all direct children of a step (O(1) via index)
|
|
146
|
+
# @param parent_number [String] Parent step number
|
|
147
|
+
# @return [Array<Step>] Direct child steps
|
|
148
|
+
def children_of(parent_number)
|
|
149
|
+
@children_index[parent_number] || []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Get all descendants (children, grandchildren, etc.) of a step
|
|
153
|
+
# @param parent_number [String] Parent step number
|
|
154
|
+
# @return [Array<Step>] All descendant steps
|
|
155
|
+
def descendants_of(parent_number)
|
|
156
|
+
steps.select { |s| Atoms::StepNumbering.child_of?(s.number, parent_number) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Check whether a step number belongs to a subtree rooted at root_number.
|
|
160
|
+
#
|
|
161
|
+
# @param root_number [String] Subtree root step number
|
|
162
|
+
# @param step_number [String] Candidate step number
|
|
163
|
+
# @return [Boolean] True when candidate is root or descendant of root
|
|
164
|
+
def in_subtree?(root_number, step_number)
|
|
165
|
+
step_number == root_number || Atoms::StepNumbering.child_of?(step_number, root_number)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Get all steps in a subtree (root + descendants), preserving queue order.
|
|
169
|
+
#
|
|
170
|
+
# @param root_number [String] Subtree root step number
|
|
171
|
+
# @return [Array<Step>] Subtree steps in queue order
|
|
172
|
+
def subtree_steps(root_number)
|
|
173
|
+
steps.select { |s| in_subtree?(root_number, s.number) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Check whether all steps in a subtree are complete.
|
|
177
|
+
#
|
|
178
|
+
# @param root_number [String] Subtree root step number
|
|
179
|
+
# @return [Boolean] True when every subtree step is complete
|
|
180
|
+
def subtree_complete?(root_number)
|
|
181
|
+
scoped = subtree_steps(root_number)
|
|
182
|
+
return false if scoped.empty?
|
|
183
|
+
|
|
184
|
+
scoped.all?(&:complete?)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Check whether a subtree has at least one failed step.
|
|
188
|
+
#
|
|
189
|
+
# @param root_number [String] Subtree root step number
|
|
190
|
+
# @return [Boolean] True when any subtree step failed
|
|
191
|
+
def subtree_failed?(root_number)
|
|
192
|
+
subtree_steps(root_number).any? { |s| s.status == :failed }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Get the current in-progress step within a subtree.
|
|
196
|
+
#
|
|
197
|
+
# @param root_number [String] Subtree root step number
|
|
198
|
+
# @return [Step, nil] In-progress step inside subtree, if any
|
|
199
|
+
def current_in_subtree(root_number)
|
|
200
|
+
in_progress_in_subtree(root_number).first
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Get all in-progress steps within a subtree.
|
|
204
|
+
#
|
|
205
|
+
# @param root_number [String] Subtree root step number
|
|
206
|
+
# @return [Array<Step>] In-progress steps inside subtree
|
|
207
|
+
def in_progress_in_subtree(root_number)
|
|
208
|
+
subtree_steps(root_number)
|
|
209
|
+
.select { |s| s.status == :in_progress }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Get next workable step constrained to a subtree.
|
|
213
|
+
#
|
|
214
|
+
# @param root_number [String] Subtree root step number
|
|
215
|
+
# @return [Step, nil] Next pending workable step inside subtree
|
|
216
|
+
def next_workable_in_subtree(root_number)
|
|
217
|
+
subtree_steps(root_number)
|
|
218
|
+
.select { |s| s.status == :pending }
|
|
219
|
+
.reject { |s| has_incomplete_children?(s.number) }
|
|
220
|
+
.first
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Build ancestor chain from closest parent to root.
|
|
224
|
+
#
|
|
225
|
+
# @param number [String] Step number
|
|
226
|
+
# @return [Array<String>] Ancestor numbers, nearest first
|
|
227
|
+
def ancestor_chain(number)
|
|
228
|
+
chain = []
|
|
229
|
+
parent = Atoms::StepNumbering.parent_of(number)
|
|
230
|
+
while parent
|
|
231
|
+
chain << parent
|
|
232
|
+
parent = Atoms::StepNumbering.parent_of(parent)
|
|
233
|
+
end
|
|
234
|
+
chain
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Find nearest ancestor (or self) that has context: fork.
|
|
238
|
+
#
|
|
239
|
+
# @param number [String] Step number
|
|
240
|
+
# @return [Step, nil] Nearest fork-scoped step
|
|
241
|
+
def nearest_fork_ancestor(number)
|
|
242
|
+
step = find_by_number(number)
|
|
243
|
+
return nil unless step
|
|
244
|
+
|
|
245
|
+
return step if step.fork?
|
|
246
|
+
|
|
247
|
+
ancestor_chain(number).each do |ancestor_number|
|
|
248
|
+
ancestor = find_by_number(ancestor_number)
|
|
249
|
+
return ancestor if ancestor&.fork?
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Check if a step has any incomplete children
|
|
256
|
+
# @param parent_number [String] Parent step number
|
|
257
|
+
# @return [Boolean] True if any child is not done
|
|
258
|
+
def has_incomplete_children?(parent_number)
|
|
259
|
+
children_of(parent_number).any? { |s| s.status != :done }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Get next workable step considering hierarchy.
|
|
263
|
+
# A step is workable if it's pending and has no incomplete children.
|
|
264
|
+
# Prefers children of current/recent work.
|
|
265
|
+
# @return [Step, nil] Next step to work on
|
|
266
|
+
def next_workable
|
|
267
|
+
# First, find pending steps
|
|
268
|
+
pending_steps = pending
|
|
269
|
+
|
|
270
|
+
# Filter to steps that don't have incomplete children
|
|
271
|
+
workable = pending_steps.reject { |s| has_incomplete_children?(s.number) }
|
|
272
|
+
|
|
273
|
+
# Return first workable step (already sorted by number)
|
|
274
|
+
workable.first
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Get all step numbers as an array
|
|
278
|
+
# @return [Array<String>] All step numbers
|
|
279
|
+
def all_numbers
|
|
280
|
+
steps.map(&:number)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Get top-level (root) steps only
|
|
284
|
+
# @return [Array<Step>] Steps with no parent
|
|
285
|
+
def top_level
|
|
286
|
+
steps.select { |s| Atoms::StepNumbering.top_level?(s.number) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Build hierarchical structure for display
|
|
290
|
+
# @return [Array<Hash>] Nested structure with :step and :children keys
|
|
291
|
+
def hierarchical
|
|
292
|
+
build_hierarchy(nil)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
# Build index of children by parent number for O(1) lookups
|
|
298
|
+
# @param steps [Array<Step>] All steps
|
|
299
|
+
# @return [Hash<String, Array<Step>>] Parent number => children mapping
|
|
300
|
+
def build_children_index(steps)
|
|
301
|
+
index = Hash.new { |h, k| h[k] = [] }
|
|
302
|
+
steps.each do |step|
|
|
303
|
+
parsed = Atoms::StepNumbering.parse(step.number)
|
|
304
|
+
index[parsed[:parent]] << step if parsed[:parent]
|
|
305
|
+
end
|
|
306
|
+
index
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def build_hierarchy(parent_number)
|
|
310
|
+
parent_steps = if parent_number.nil?
|
|
311
|
+
top_level
|
|
312
|
+
else
|
|
313
|
+
children_of(parent_number)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
parent_steps.map do |step|
|
|
317
|
+
{
|
|
318
|
+
step: step,
|
|
319
|
+
children: build_hierarchy(step.number)
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Models
|
|
6
|
+
# Step data model representing a work queue item.
|
|
7
|
+
#
|
|
8
|
+
# Pure data carrier with no business logic (ATOM pattern).
|
|
9
|
+
# All attributes are immutable after initialization.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# step = Step.new(
|
|
13
|
+
# number: "010",
|
|
14
|
+
# name: "init-project",
|
|
15
|
+
# status: :pending,
|
|
16
|
+
# instructions: "Create project structure..."
|
|
17
|
+
# )
|
|
18
|
+
class Step
|
|
19
|
+
# Valid status values
|
|
20
|
+
STATUSES = %i[pending in_progress done failed].freeze
|
|
21
|
+
|
|
22
|
+
# Valid context values for execution context
|
|
23
|
+
VALID_CONTEXTS = %w[fork].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :number, :name, :status, :instructions, :report, :error,
|
|
26
|
+
:started_at, :completed_at, :added_by, :parent, :file_path, :skill, :context,
|
|
27
|
+
:workflow,
|
|
28
|
+
:batch_parent, :parallel, :max_parallel, :fork_retry_limit,
|
|
29
|
+
:fork_launch_pid, :fork_tracked_pids, :fork_pid_updated_at, :fork_pid_file,
|
|
30
|
+
:stall_reason
|
|
31
|
+
|
|
32
|
+
# @param number [String] Step number (e.g., "010", "010.01")
|
|
33
|
+
# @param name [String] Step name
|
|
34
|
+
# @param status [Symbol] Step status (:pending, :in_progress, :done, :failed)
|
|
35
|
+
# @param instructions [String] Step instructions (markdown)
|
|
36
|
+
# @param report [String, nil] Completion report content
|
|
37
|
+
# @param error [String, nil] Error message (if failed)
|
|
38
|
+
# @param started_at [Time, nil] When work started
|
|
39
|
+
# @param completed_at [Time, nil] When completed/failed
|
|
40
|
+
# @param added_by [String, nil] How step was added (nil, "dynamic", "retry_of:NNN")
|
|
41
|
+
# @param parent [String, nil] Parent step number (for sub-steps)
|
|
42
|
+
# @param file_path [String, nil] Path to step file
|
|
43
|
+
# @param skill [String, nil] Skill reference for this step (e.g., "ace-task-work")
|
|
44
|
+
# @param context [String, nil] Execution context ("fork" for Task tool execution)
|
|
45
|
+
# @param batch_parent [Boolean, nil] Whether step is a batch scheduling parent
|
|
46
|
+
# @param parallel [Boolean, nil] Batch scheduling mode hint (true=parallel, false=sequential)
|
|
47
|
+
# @param max_parallel [Integer, nil] Max concurrent children for parallel batches
|
|
48
|
+
# @param fork_retry_limit [Integer, nil] Retry attempts allowed per failed child
|
|
49
|
+
# @param fork_launch_pid [Integer, nil] PID of the process that launched the fork run
|
|
50
|
+
# @param fork_tracked_pids [Array<Integer>, nil] Observed subprocess/descendant PIDs during fork execution
|
|
51
|
+
# @param fork_pid_updated_at [Time, nil] Timestamp when fork PID metadata was last updated
|
|
52
|
+
# @param fork_pid_file [String, nil] Path to fork PID metadata file
|
|
53
|
+
# @param stall_reason [String, nil] Last agent message captured when fork stalled
|
|
54
|
+
def initialize(number:, name:, status:, instructions:, report: nil, error: nil,
|
|
55
|
+
started_at: nil, completed_at: nil, added_by: nil, parent: nil,
|
|
56
|
+
file_path: nil, skill: nil, workflow: nil, context: nil,
|
|
57
|
+
batch_parent: nil, parallel: nil, max_parallel: nil, fork_retry_limit: nil,
|
|
58
|
+
fork_launch_pid: nil, fork_tracked_pids: nil, fork_pid_updated_at: nil,
|
|
59
|
+
fork_pid_file: nil, stall_reason: nil)
|
|
60
|
+
validate_status!(status)
|
|
61
|
+
validate_context!(context) if context
|
|
62
|
+
validate_boolean!(:batch_parent, batch_parent)
|
|
63
|
+
validate_boolean!(:parallel, parallel)
|
|
64
|
+
validate_positive_integer!(:max_parallel, max_parallel)
|
|
65
|
+
validate_non_negative_integer!(:fork_retry_limit, fork_retry_limit)
|
|
66
|
+
|
|
67
|
+
@number = number.freeze
|
|
68
|
+
@name = name.freeze
|
|
69
|
+
@status = status
|
|
70
|
+
@instructions = instructions.freeze
|
|
71
|
+
@report = report&.freeze
|
|
72
|
+
@error = error&.freeze
|
|
73
|
+
@started_at = started_at
|
|
74
|
+
@completed_at = completed_at
|
|
75
|
+
@added_by = added_by&.freeze
|
|
76
|
+
@parent = parent&.freeze
|
|
77
|
+
@file_path = file_path&.freeze
|
|
78
|
+
@skill = skill&.freeze
|
|
79
|
+
@workflow = workflow&.freeze
|
|
80
|
+
@context = context&.freeze
|
|
81
|
+
@batch_parent = batch_parent.nil? ? nil : !!batch_parent
|
|
82
|
+
@parallel = parallel.nil? ? nil : !!parallel
|
|
83
|
+
@max_parallel = max_parallel&.to_i
|
|
84
|
+
@fork_retry_limit = fork_retry_limit&.to_i
|
|
85
|
+
@fork_launch_pid = fork_launch_pid&.to_i
|
|
86
|
+
@fork_tracked_pids = Array(fork_tracked_pids).map(&:to_i).uniq.sort.freeze
|
|
87
|
+
@fork_pid_updated_at = fork_pid_updated_at
|
|
88
|
+
@fork_pid_file = fork_pid_file&.freeze
|
|
89
|
+
@stall_reason = stall_reason&.freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if step is complete (done or failed)
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def complete?
|
|
95
|
+
%i[done failed].include?(status)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if step can be worked on
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def workable?
|
|
101
|
+
status == :pending || status == :in_progress
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if this is a retry of another step
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def retry?
|
|
107
|
+
added_by&.start_with?("retry_of:")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check if this step should run in a forked context (subagent)
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def fork?
|
|
113
|
+
context == "fork"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get the original step number if this is a retry
|
|
117
|
+
# @return [String, nil] Original step number
|
|
118
|
+
def retry_of
|
|
119
|
+
return nil unless retry?
|
|
120
|
+
|
|
121
|
+
added_by.sub("retry_of:", "")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Convert to frontmatter hash for YAML serialization
|
|
125
|
+
# @return [Hash] Frontmatter data
|
|
126
|
+
def to_frontmatter
|
|
127
|
+
{
|
|
128
|
+
"name" => name,
|
|
129
|
+
"status" => status.to_s,
|
|
130
|
+
"skill" => skill,
|
|
131
|
+
"workflow" => workflow,
|
|
132
|
+
"context" => context,
|
|
133
|
+
"batch_parent" => batch_parent,
|
|
134
|
+
"parallel" => parallel,
|
|
135
|
+
"max_parallel" => max_parallel,
|
|
136
|
+
"fork_retry_limit" => fork_retry_limit,
|
|
137
|
+
"started_at" => started_at&.iso8601,
|
|
138
|
+
"completed_at" => completed_at&.iso8601,
|
|
139
|
+
"fork_launch_pid" => fork_launch_pid,
|
|
140
|
+
"fork_tracked_pids" => fork_tracked_pids,
|
|
141
|
+
"fork_pid_updated_at" => fork_pid_updated_at&.iso8601,
|
|
142
|
+
"fork_pid_file" => fork_pid_file,
|
|
143
|
+
"error" => error,
|
|
144
|
+
"stall_reason" => stall_reason,
|
|
145
|
+
"added_by" => added_by,
|
|
146
|
+
"parent" => parent
|
|
147
|
+
}.compact
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Convert to display row for status table
|
|
151
|
+
# @return [Hash] Display row data
|
|
152
|
+
def to_display_row
|
|
153
|
+
{
|
|
154
|
+
file: File.basename(file_path || "#{number}-#{name}.st.md"),
|
|
155
|
+
status: status.to_s,
|
|
156
|
+
name: name,
|
|
157
|
+
error: error
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def validate_status!(status)
|
|
164
|
+
return if STATUSES.include?(status)
|
|
165
|
+
|
|
166
|
+
raise ArgumentError, "Invalid status: #{status}. Must be one of: #{STATUSES.join(", ")}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_context!(context)
|
|
170
|
+
return if VALID_CONTEXTS.include?(context)
|
|
171
|
+
|
|
172
|
+
raise ArgumentError, "Invalid context '#{context}'. Valid values: #{VALID_CONTEXTS.join(", ")}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_boolean!(field_name, value)
|
|
176
|
+
return if value.nil? || value == true || value == false
|
|
177
|
+
|
|
178
|
+
raise ArgumentError, "Invalid #{field_name}: #{value.inspect}. Must be true, false, or nil"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_positive_integer!(field_name, value)
|
|
182
|
+
return if value.nil?
|
|
183
|
+
return if value.is_a?(Integer) && value.positive?
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "Invalid #{field_name}: #{value.inspect}. Must be an integer > 0"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def validate_non_negative_integer!(field_name, value)
|
|
189
|
+
return if value.nil?
|
|
190
|
+
return if value.is_a?(Integer) && value >= 0
|
|
191
|
+
|
|
192
|
+
raise ArgumentError, "Invalid #{field_name}: #{value.inspect}. Must be an integer >= 0"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Molecules
|
|
6
|
+
# Discovers and enriches assignments with computed state.
|
|
7
|
+
#
|
|
8
|
+
# Combines AssignmentManager (file operations) with QueueScanner
|
|
9
|
+
# (step scanning) to produce AssignmentInfo objects with full
|
|
10
|
+
# state, progress, and current step information.
|
|
11
|
+
class AssignmentDiscoverer
|
|
12
|
+
# @param cache_base [String, nil] Base cache directory
|
|
13
|
+
def initialize(cache_base: nil)
|
|
14
|
+
@cache_base = cache_base || Ace::Assign.cache_dir
|
|
15
|
+
@assignment_manager = AssignmentManager.new(cache_base: @cache_base)
|
|
16
|
+
@queue_scanner = QueueScanner.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Find all assignments with computed state
|
|
20
|
+
#
|
|
21
|
+
# NOTE: Performance — This loads and enriches every assignment before filtering.
|
|
22
|
+
# Acceptable for typical usage (< 50 assignments). If assignment counts grow large,
|
|
23
|
+
# consider filtering by state at the manager level before enrichment to avoid
|
|
24
|
+
# unnecessary QueueScanner.scan calls on completed assignments.
|
|
25
|
+
#
|
|
26
|
+
# @param include_completed [Boolean] Include completed assignments (default: false)
|
|
27
|
+
# @return [Array<Models::AssignmentInfo>] Enriched assignments
|
|
28
|
+
def find_all(include_completed: false)
|
|
29
|
+
@assignment_manager.list
|
|
30
|
+
.map { |a| enrich_assignment(a) }
|
|
31
|
+
.select { |ai| include_completed || !ai.completed? }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Find assignments by task reference (assignment name)
|
|
35
|
+
#
|
|
36
|
+
# @param task_ref [String] Task reference to filter by
|
|
37
|
+
# @param active_only [Boolean] Only return active assignments (default: true)
|
|
38
|
+
# @return [Array<Models::AssignmentInfo>] Matching assignments
|
|
39
|
+
def find_by_task(task_ref:, active_only: true)
|
|
40
|
+
find_all(include_completed: !active_only)
|
|
41
|
+
.select { |ai| ai.assignment.name == task_ref }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Enrich an assignment with queue state to create AssignmentInfo
|
|
47
|
+
#
|
|
48
|
+
# @param assignment [Models::Assignment] Raw assignment
|
|
49
|
+
# @return [Models::AssignmentInfo] Enriched assignment
|
|
50
|
+
def enrich_assignment(assignment)
|
|
51
|
+
state = @queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
52
|
+
Models::AssignmentInfo.new(assignment: assignment, queue_state: state)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|