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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
  3. data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
  4. data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
  5. data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
  6. data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
  7. data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
  8. data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
  9. data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
  10. data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
  11. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
  12. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
  13. data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
  14. data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
  15. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
  16. data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
  17. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
  18. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
  19. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
  20. data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
  21. data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
  22. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
  23. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
  24. data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
  25. data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
  26. data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
  27. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
  28. data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
  29. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
  30. data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
  31. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
  32. data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
  33. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
  34. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
  35. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
  36. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
  37. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
  38. data/.ace-defaults/assign/config.yml +48 -0
  39. data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
  40. data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
  41. data/.ace-defaults/assign/presets/release-only.yml +35 -0
  42. data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
  43. data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
  44. data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
  45. data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
  46. data/CHANGELOG.md +1415 -0
  47. data/README.md +87 -0
  48. data/Rakefile +16 -0
  49. data/docs/exit-codes.md +61 -0
  50. data/docs/getting-started.md +121 -0
  51. data/docs/handbook.md +40 -0
  52. data/docs/usage.md +224 -0
  53. data/exe/ace-assign +16 -0
  54. data/handbook/guides/fork-context.g.md +231 -0
  55. data/handbook/skills/as-assign-compose/SKILL.md +24 -0
  56. data/handbook/skills/as-assign-create/SKILL.md +23 -0
  57. data/handbook/skills/as-assign-drive/SKILL.md +24 -0
  58. data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
  59. data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
  60. data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
  61. data/handbook/skills/as-assign-start/SKILL.md +25 -0
  62. data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
  63. data/handbook/workflow-instructions/assign/create.wf.md +215 -0
  64. data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
  65. data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
  66. data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
  67. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
  68. data/handbook/workflow-instructions/assign/start.wf.md +46 -0
  69. data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
  70. data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
  71. data/lib/ace/assign/atoms/composition_rules.rb +219 -0
  72. data/lib/ace/assign/atoms/number_generator.rb +110 -0
  73. data/lib/ace/assign/atoms/preset_expander.rb +277 -0
  74. data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
  75. data/lib/ace/assign/atoms/step_numbering.rb +227 -0
  76. data/lib/ace/assign/atoms/step_sorter.rb +66 -0
  77. data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
  78. data/lib/ace/assign/cli/commands/add.rb +102 -0
  79. data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
  80. data/lib/ace/assign/cli/commands/create.rb +63 -0
  81. data/lib/ace/assign/cli/commands/fail.rb +43 -0
  82. data/lib/ace/assign/cli/commands/finish.rb +88 -0
  83. data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
  84. data/lib/ace/assign/cli/commands/list.rb +166 -0
  85. data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
  86. data/lib/ace/assign/cli/commands/select.rb +45 -0
  87. data/lib/ace/assign/cli/commands/start.rb +40 -0
  88. data/lib/ace/assign/cli/commands/status.rb +407 -0
  89. data/lib/ace/assign/cli.rb +144 -0
  90. data/lib/ace/assign/models/assignment.rb +107 -0
  91. data/lib/ace/assign/models/assignment_info.rb +66 -0
  92. data/lib/ace/assign/models/queue_state.rb +326 -0
  93. data/lib/ace/assign/models/step.rb +197 -0
  94. data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
  95. data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
  96. data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
  97. data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
  98. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
  99. data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
  100. data/lib/ace/assign/molecules/step_writer.rb +246 -0
  101. data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
  102. data/lib/ace/assign/version.rb +7 -0
  103. data/lib/ace/assign.rb +141 -0
  104. 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