ace-assign 0.42.4 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
  4. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
  6. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
  7. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
  8. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
  9. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
  10. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
  11. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
  12. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
  13. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
  14. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
  15. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
  16. data/.ace-defaults/assign/config.yml +1 -0
  17. data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
  18. data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
  19. data/.ace-defaults/assign/presets/work-on-task.yml +6 -16
  20. data/CHANGELOG.md +216 -0
  21. data/README.md +20 -43
  22. data/docs/demo/canonical-skill-source.gif +0 -0
  23. data/docs/demo/canonical-skill-source.tape.yml +51 -0
  24. data/docs/demo/fork-provider.cast +834 -0
  25. data/docs/demo/fork-provider.gif +0 -0
  26. data/docs/demo/fork-provider.recording.json +30 -0
  27. data/docs/demo/fork-provider.tape.yml +77 -20
  28. data/docs/getting-started.md +5 -2
  29. data/docs/usage.md +74 -4
  30. data/handbook/guides/fork-context.g.md +31 -7
  31. data/handbook/skills/as-assign-drive/SKILL.md +13 -1
  32. data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
  33. data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
  34. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
  35. data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
  36. data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
  37. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
  38. data/handbook/workflow-instructions/assign/create.wf.md +6 -3
  39. data/handbook/workflow-instructions/assign/drive.wf.md +330 -40
  40. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
  41. data/handbook/workflow-instructions/assign/prepare.wf.md +10 -5
  42. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
  43. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
  44. data/handbook/workflow-instructions/assign/start.wf.md +5 -2
  45. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
  46. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
  47. data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
  48. data/lib/ace/assign/atoms/preset_expander.rb +4 -0
  49. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  50. data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
  51. data/lib/ace/assign/cli/commands/add.rb +20 -11
  52. data/lib/ace/assign/cli/commands/assignment_target.rb +87 -3
  53. data/lib/ace/assign/cli/commands/create.rb +1 -1
  54. data/lib/ace/assign/cli/commands/fail.rb +1 -1
  55. data/lib/ace/assign/cli/commands/finish.rb +32 -8
  56. data/lib/ace/assign/cli/commands/fork_run.rb +58 -16
  57. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  58. data/lib/ace/assign/cli/commands/list.rb +4 -3
  59. data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
  60. data/lib/ace/assign/cli/commands/start.rb +9 -3
  61. data/lib/ace/assign/cli/commands/status.rb +237 -230
  62. data/lib/ace/assign/cli/commands/step.rb +62 -0
  63. data/lib/ace/assign/cli.rb +8 -1
  64. data/lib/ace/assign/models/assignment_info.rb +33 -4
  65. data/lib/ace/assign/models/queue_state.rb +101 -39
  66. data/lib/ace/assign/models/step.rb +17 -5
  67. data/lib/ace/assign/molecules/fork_session_launcher.rb +218 -21
  68. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  69. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
  70. data/lib/ace/assign/molecules/step_writer.rb +3 -3
  71. data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
  72. data/lib/ace/assign/organisms/assignment_executor.rb +355 -106
  73. data/lib/ace/assign/version.rb +1 -1
  74. data/lib/ace/assign.rb +1 -0
  75. metadata +35 -5
  76. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +0 -42
@@ -0,0 +1,12 @@
1
+ # mark-task-done-internal
2
+
3
+ ## Purpose
4
+
5
+ Mark a task as done and verify the state transition persisted.
6
+
7
+ ## Steps
8
+
9
+ 1. Run `ace-task update <taskref> --set status=done --move-to archive --git-commit`.
10
+ 2. Verify with `ace-task show <taskref>` and confirm `status: done`.
11
+ 3. If the task has a parent, check whether siblings are all done; when true, mark the parent done and verify.
12
+ 4. Repeat upward only while all siblings remain done.
@@ -141,6 +141,11 @@ For `--taskrefs 148,149,150`, expansion generates:
141
141
  030 finalize
142
142
  ```
143
143
 
144
+ Generated batch parents should carry scheduler metadata:
145
+
146
+ - `010 batch-tasks (parent, auto-completes, batch_parent: true, parallel: false)`
147
+ - `fork_retry_limit: 1`
148
+
144
149
  The hierarchical numbering enables:
145
150
  - Parent auto-completion when all children are done
146
151
  - Parent-only fork markers for subtree delegation
@@ -336,7 +341,7 @@ session:
336
341
 
337
342
  steps:
338
343
  - name: <step-name>
339
- skill: <skill-reference> # If present in preset
344
+ source: <source-reference> # Canonical: skill://... or wfi://...
340
345
  instructions:
341
346
  - <resolved instruction line>
342
347
  # ... more steps
@@ -344,7 +349,7 @@ steps:
344
349
 
345
350
  ### 8. Output Result
346
351
 
347
- Default output: `<task>/jobs/<timestamp>-job.yml` (e.g., `.ace-taskflow/v.0.9.0/tasks/229-xxx/jobs/k5abc123-job.yml`)
352
+ Default output: `<task>/jobs/<timestamp>-job.yml` (e.g., `.ace-task/v.0.9.0/tasks/229-xxx/jobs/k5abc123-job.yml`)
348
353
 
349
354
  Custom output: Use `--output path/to/custom.yaml`
350
355
 
@@ -379,19 +384,19 @@ session:
379
384
 
380
385
  steps:
381
386
  - name: onboard
382
- skill: as-onboard
387
+ source: skill://as-onboard
383
388
  instructions:
384
389
  - Onboard yourself to the codebase.
385
390
  - Load context and understand the project structure.
386
391
 
387
392
  - name: work-on-task
388
- skill: as-task-work
393
+ source: skill://as-task-work
389
394
  instructions:
390
395
  - Work on task 123.
391
396
  - Implement the required changes following project conventions.
392
397
 
393
398
  - name: create-pr
394
- skill: as-github-pr-create
399
+ source: skill://as-github-pr-create
395
400
  instructions:
396
401
  - Create a pull request for the changes.
397
402
  - Capture the PR number for subsequent review steps.
@@ -0,0 +1,14 @@
1
+ # reflect-and-refactor-internal
2
+
3
+ ## Purpose
4
+
5
+ Run architecture reflection and bounded refactoring before release/closeout.
6
+
7
+ ## Steps
8
+
9
+ 1. Validate implementation/demo state.
10
+ 2. Run architecture-focused review on the active diff.
11
+ 3. Categorize findings (refactor/accept/skip).
12
+ 4. Execute bounded refactoring for selected findings only.
13
+ 5. Commit refactor changes separately.
14
+ 6. If a replan trigger is hit, inject follow-up implementation steps and rerun once.
@@ -3,7 +3,7 @@ doc-type: workflow
3
3
  title: Run In Batches Workflow
4
4
  purpose: workflow instruction for reusable repeated-item orchestration with deterministic assignment creation
5
5
  ace-docs:
6
- last-updated: 2026-03-18
6
+ last-updated: 2026-04-07
7
7
  last-checked: 2026-03-21
8
8
  ---
9
9
 
@@ -163,6 +163,8 @@ If `--run` is present:
163
163
  /as-assign-drive <assignment-id>
164
164
  ```
165
165
 
166
+ This handoff must continue through the full batch execution, including all child fork subtrees, until the assignment is complete or an explicit blocker/failure stop condition is reached. Child completion is not a valid stop boundary.
167
+
166
168
  If no workable step is available, keep creation successful and report why drive did not continue.
167
169
 
168
170
  ### 7. Report Result
@@ -203,6 +205,7 @@ Show:
203
205
  - Parent/child metadata reflects scheduler intent (`parallel`, `max_parallel`, `fork_retry_limit`)
204
206
  - `{{item}}` substitution and `Target item:` fallback are deterministic
205
207
  - Optional `--run` handoff delegates to `/as-assign-drive`
208
+ - Optional `--run` handoff preserves drive's run-until-complete-or-blocked semantics across the whole batch
206
209
 
207
210
  ## Verification
208
211
 
@@ -3,7 +3,7 @@ doc-type: workflow
3
3
  title: Start Assignment Workflow (Legacy Compatibility)
4
4
  purpose: preserve compatibility for as-assign-start while routing to public assign/create + assign/drive flow
5
5
  ace-docs:
6
- last-updated: 2026-03-18
6
+ last-updated: 2026-04-07
7
7
  last-checked: 2026-03-21
8
8
  ---
9
9
 
@@ -39,8 +39,11 @@ Primary public UX remains:
39
39
  /as-assign-drive <assignment-id>
40
40
  ```
41
41
 
42
+ When this handoff occurs, `/as-assign-drive` must continue until the assignment is complete or explicitly blocked. It is not a single-step progress probe.
43
+
42
44
  ## Success Criteria
43
45
 
44
46
  - Preserves compatibility entrypoint for orchestration examples.
45
47
  - Delegates behavior to `assign/create` and `assign/drive`.
46
- - Does not redefine the public assignment flow.
48
+ - Does not redefine the public assignment flow.
49
+ - Preserves drive's run-until-complete-or-blocked semantics.
@@ -0,0 +1,12 @@
1
+ # task-load-internal
2
+
3
+ ## Purpose
4
+
5
+ Load task behavioral specification and dependency context for assignment execution.
6
+
7
+ ## Steps
8
+
9
+ 1. Run `ace-bundle task://<taskref>` for the target task reference.
10
+ 2. If task dependencies are declared, run `ace-bundle task://<dep-ref>` for each dependency.
11
+ 3. Review relevant dependency reports under `.ace-local/assign/` so the plan/work steps build on prior implementation.
12
+ 4. Confirm context is loaded before proceeding.
@@ -0,0 +1,36 @@
1
+ ---
2
+ doc-type: workflow
3
+ title: Assign Verify Test Suite Workflow
4
+ purpose: Narrow deterministic verification contract for ace-assign orchestration
5
+ ace-docs:
6
+ last-updated: 2026-04-13
7
+ last-checked: 2026-04-13
8
+ ---
9
+
10
+ # Verify Test Suite Workflow
11
+
12
+ ## Purpose
13
+
14
+ Run the deterministic verification contract used by `ace-assign`:
15
+ - package-scoped `ace-test <package> all --profile 6` for modified packages
16
+ - monorepo verification with `ace-test-suite --target all`
17
+
18
+ Do not run `ace-test-e2e` or `ace-test-e2e-suite` from this workflow.
19
+
20
+ ## Steps
21
+
22
+ 1. Detect whether modified files affect runnable code.
23
+ 2. If changes are docs-only or otherwise non-runnable, skip with a clear reason.
24
+ 3. Detect modified packages from the current diff or working tree.
25
+ 4. Run `ace-test <package> all --profile 6` for each modified package.
26
+ 5. Run `ace-test-suite --target all`.
27
+
28
+ ## Skip Guidance
29
+
30
+ Skip only when all modified files are documentation, retrospectives, task specs, or similarly non-runnable metadata.
31
+
32
+ ## Success Criteria
33
+
34
+ - All modified packages pass `ace-test <package> all --profile 6`
35
+ - `ace-test-suite --target all` passes
36
+ - No E2E commands are used as part of this step
@@ -21,13 +21,19 @@ module Ace
21
21
  # Load all step definitions from a catalog directory.
22
22
  #
23
23
  # @param steps_dir [String] Path to catalog/steps/ directory
24
+ # @param canonical_steps [Array<Hash>, Symbol, Boolean, nil] Canonical
25
+ # step metadata to merge. `:auto` (default) loads from skill sources.
26
+ # `false` disables canonical merge and returns raw YAML definitions.
24
27
  # @return [Array<Hash>] Array of step definition hashes
25
- def self.load_all(steps_dir)
28
+ def self.load_all(steps_dir, canonical_steps: :auto)
26
29
  return [] unless File.directory?(steps_dir)
27
30
 
28
- Dir.glob(File.join(steps_dir, "*.step.yml")).sort.filter_map do |path|
31
+ yaml_steps = Dir.glob(File.join(steps_dir, "*.step.yml")).sort.filter_map do |path|
29
32
  parse_step_file(path)
30
33
  end
34
+ return [] if yaml_steps.empty?
35
+
36
+ merge_step_catalog(yaml_steps, resolve_canonical_steps(canonical_steps))
31
37
  end
32
38
 
33
39
  # Find a step definition by name.
@@ -94,6 +100,103 @@ module Ace
94
100
  warn "Warning: Failed to parse step file #{path}: #{e.message}"
95
101
  nil
96
102
  end
103
+
104
+ def self.resolve_canonical_steps(canonical_steps)
105
+ case canonical_steps
106
+ when false
107
+ []
108
+ when :auto, nil
109
+ begin
110
+ require_relative "../molecules/skill_assign_source_resolver"
111
+ Molecules::SkillAssignSourceResolver.new.assign_step_catalog
112
+ rescue LoadError, StandardError
113
+ []
114
+ end
115
+ when Array
116
+ canonical_steps
117
+ else
118
+ []
119
+ end
120
+ end
121
+ private_class_method :resolve_canonical_steps
122
+
123
+ def self.merge_step_catalog(base_steps, override_steps)
124
+ index = {}
125
+ order = []
126
+
127
+ Array(base_steps).each do |step|
128
+ name = step["name"]
129
+ next if name.nil? || name.empty?
130
+
131
+ index[name] = step
132
+ order << name
133
+ end
134
+
135
+ Array(override_steps).each do |step|
136
+ name = step["name"]
137
+ next if name.nil? || name.empty?
138
+
139
+ order << name unless index.key?(name)
140
+ index[name] = deep_merge_step_definition(index[name], step)
141
+ end
142
+
143
+ order.map { |name| index[name] }.compact
144
+ end
145
+ private_class_method :merge_step_catalog
146
+
147
+ def self.deep_merge_step_definition(base, override)
148
+ return override unless base.is_a?(Hash)
149
+ return base unless override.is_a?(Hash)
150
+
151
+ merged = base.dup
152
+ override.each do |key, value|
153
+ if runtime_binding_override_key?(key, base, override)
154
+ merged[key] = base[key]
155
+ next
156
+ end
157
+
158
+ merged[key] =
159
+ if merged[key].is_a?(Hash) && value.is_a?(Hash)
160
+ deep_merge_step_definition(merged[key], value)
161
+ else
162
+ value
163
+ end
164
+ end
165
+ merged
166
+ end
167
+ private_class_method :deep_merge_step_definition
168
+
169
+ def self.runtime_binding_override_key?(key, base, override)
170
+ return false unless %w[source workflow skill source_skill].include?(key)
171
+ return false unless local_runtime_binding_present?(base)
172
+ canonical_binding_present?(override)
173
+ end
174
+ private_class_method :runtime_binding_override_key?
175
+
176
+ def self.local_runtime_binding_present?(entry)
177
+ entry.is_a?(Hash) && (
178
+ present_string?(entry["source"]) ||
179
+ present_string?(entry["workflow"]) ||
180
+ present_string?(entry["skill"])
181
+ )
182
+ end
183
+ private_class_method :local_runtime_binding_present?
184
+
185
+ def self.canonical_binding_present?(entry)
186
+ entry.is_a?(Hash) && (
187
+ present_string?(entry["source"]) ||
188
+ present_string?(entry["workflow"]) ||
189
+ present_string?(entry["skill"]) ||
190
+ present_string?(entry["source_skill"])
191
+ )
192
+ end
193
+ private_class_method :canonical_binding_present?
194
+
195
+ def self.present_string?(value)
196
+ value.is_a?(String) && !value.strip.empty?
197
+ end
198
+ private_class_method :present_string?
199
+
97
200
  private_class_method :parse_step_file
98
201
  end
99
202
  end
@@ -192,6 +192,10 @@ module Ace
192
192
 
193
193
  step["skill"] = config["skill"] if config["skill"]
194
194
  step["context"] = config["context"] if config["context"]
195
+ step["batch_parent"] = config["batch_parent"] unless config["batch_parent"].nil?
196
+ step["parallel"] = config["parallel"] unless config["parallel"].nil?
197
+ step["max_parallel"] = config["max_parallel"] if config["max_parallel"]
198
+ step["fork_retry_limit"] = config["fork_retry_limit"] unless config["fork_retry_limit"].nil?
195
199
 
196
200
  step
197
201
  end
@@ -49,6 +49,7 @@ module Ace
49
49
  {
50
50
  name: fm["name"],
51
51
  status: (fm["status"] || "pending").to_sym,
52
+ source: normalize_source(fm["source"], fm["workflow"], fm["skill"]),
52
53
  skill: fm["skill"],
53
54
  workflow: fm["workflow"],
54
55
  context: context, # "fork" triggers Task tool execution
@@ -212,6 +213,20 @@ module Ace
212
213
  parsed
213
214
  end
214
215
  private_class_method :parse_non_negative_integer
216
+
217
+ def self.normalize_source(source, workflow, skill)
218
+ source_ref = source.to_s.strip
219
+ return source_ref unless source_ref.empty?
220
+
221
+ workflow_ref = workflow.to_s.strip
222
+ return workflow_ref unless workflow_ref.empty?
223
+
224
+ skill_ref = skill.to_s.strip
225
+ return nil if skill_ref.empty?
226
+
227
+ "skill://#{skill_ref}"
228
+ end
229
+ private_class_method :normalize_source
215
230
  end
216
231
  end
217
232
  end
@@ -14,14 +14,14 @@ module Ace
14
14
  # # +-- onboard (completed)
15
15
  # # +-- work-on-task (running)
16
16
  # # | +-- onboard (completed)
17
- # # | +-- implement (in_progress)
17
+ # # | +-- implement (active)
18
18
  # # | \-- verify-tests (pending)
19
19
  # # \-- review-pr (pending)
20
20
  module TreeFormatter
21
21
  # State display labels matching list command
22
22
  STATE_LABELS = {
23
23
  pending: "pending",
24
- in_progress: "in_progress",
24
+ active: "active",
25
25
  running: "running",
26
26
  paused: "paused",
27
27
  completed: "completed",
@@ -35,11 +35,11 @@ module Ace
35
35
  mode = selected_mode(options)
36
36
  case mode
37
37
  when :yaml
38
- handle_yaml_mode(executor, options)
38
+ handle_yaml_mode(executor, options, target)
39
39
  when :step
40
- handle_step_mode(executor, options)
40
+ handle_step_mode(executor, options, target)
41
41
  when :task
42
- handle_task_mode(executor, options)
42
+ handle_task_mode(executor, options, target)
43
43
  else
44
44
  raise Ace::Support::Cli::Error, "Exactly one of --yaml, --step, or --task is required"
45
45
  end
@@ -77,20 +77,21 @@ module Ace
77
77
  insertion_modes(options).find { |_, value| !value.to_s.strip.empty? }&.first
78
78
  end
79
79
 
80
- def handle_yaml_mode(executor, options)
80
+ def handle_yaml_mode(executor, options, target)
81
81
  yaml_path = options[:yaml].to_s.strip
82
82
  steps = load_steps_from_file(yaml_path)
83
83
  result = executor.add_batch(
84
84
  steps: steps,
85
85
  after: options[:after],
86
86
  as_child: options[:child],
87
- source_file: yaml_path
87
+ source_file: yaml_path,
88
+ fork_root: target.scope
88
89
  )
89
90
  print_yaml_result(result, yaml_path) unless options[:quiet]
90
91
  nil
91
92
  end
92
93
 
93
- def handle_step_mode(executor, options)
94
+ def handle_step_mode(executor, options, target)
94
95
  preset_name = resolve_preset_name(executor, options)
95
96
  preset = Atoms::PresetLoader.load(preset_name)
96
97
  requested = parse_step_names(options[:step])
@@ -111,7 +112,8 @@ module Ace
111
112
  steps: [step],
112
113
  after: options[:child] ? options[:after] : sibling_cursor,
113
114
  as_child: options[:child],
114
- source_file: "preset:#{preset_name}"
115
+ source_file: "preset:#{preset_name}",
116
+ fork_root: target.scope
115
117
  )
116
118
 
117
119
  inserted_added = Array(inserted[:added])
@@ -126,7 +128,7 @@ module Ace
126
128
  nil
127
129
  end
128
130
 
129
- def handle_task_mode(executor, options)
131
+ def handle_task_mode(executor, options, target)
130
132
  preset_name = resolve_preset_name(executor, options)
131
133
  preset = Atoms::PresetLoader.load(preset_name)
132
134
  task_ref = options[:task].to_s.strip
@@ -139,7 +141,8 @@ module Ace
139
141
  end
140
142
 
141
143
  parent_step = options[:after].to_s.strip
142
- parent_step = detect_batch_parent(executor) if parent_step.empty?
144
+ parent_step = target.scope.to_s.strip if parent_step.empty? && !target.scope.to_s.strip.empty?
145
+ parent_step = detect_batch_parent(executor, target) if parent_step.empty?
143
146
  if parent_step.to_s.strip.empty?
144
147
  raise Ace::Support::Cli::Error, "No batch parent found. Pass --after <step> to specify."
145
148
  end
@@ -150,7 +153,8 @@ module Ace
150
153
  steps: [task_step],
151
154
  after: parent_step,
152
155
  as_child: insert_as_child,
153
- source_file: "preset:#{preset_name}:task:#{task_ref}"
156
+ source_file: "preset:#{preset_name}:task:#{task_ref}",
157
+ fork_root: target.scope
154
158
  )
155
159
 
156
160
  print_task_result(result, task_ref, parent_step, as_child: insert_as_child) unless options[:quiet]
@@ -235,8 +239,13 @@ module Ace
235
239
  tokens.to_a.sort
236
240
  end
237
241
 
238
- def detect_batch_parent(executor)
242
+ def detect_batch_parent(executor, target)
239
243
  state = executor.status[:state]
244
+ if target.scope && !target.scope.to_s.strip.empty?
245
+ scoped_steps = state.subtree_steps(target.scope.strip)
246
+ scoped_state = Ace::Assign::Models::QueueState.new(steps: scoped_steps, assignment: state.assignment)
247
+ state = scoped_state
248
+ end
240
249
 
241
250
  batch_parent = state.top_level.find { |step| step.name == "batch-tasks" }
242
251
  return batch_parent.number if batch_parent
@@ -10,17 +10,26 @@ module Ace
10
10
  # - <assignment-id>
11
11
  # - <assignment-id>@<step-number>
12
12
  module AssignmentTarget
13
+ DEFAULT_TARGET_ENV = "ACE_ASSIGN_DEFAULT_TARGET"
13
14
  Target = Struct.new(:assignment_id, :scope, keyword_init: true)
15
+ View = Struct.new(:assignment, :state, :scoped_state, :active_steps, :next_step, :focus_step, :scope_root, keyword_init: true)
14
16
 
15
17
  private
16
18
 
17
19
  def resolve_assignment_target(options)
18
20
  assignment_raw = options[:assignment]
19
- unless assignment_raw.nil? || assignment_raw.to_s.strip.empty?
20
- return parse_assignment_target(assignment_raw)
21
+ explicit_target = unless assignment_raw.nil? || assignment_raw.to_s.strip.empty?
22
+ parse_assignment_target(assignment_raw)
21
23
  end
22
24
 
23
- Target.new(assignment_id: nil, scope: nil)
25
+ env_target = env_assignment_target
26
+ if explicit_target && env_target && target_identity(explicit_target) != target_identity(env_target)
27
+ raise Ace::Support::Cli::Error,
28
+ "Conflicting assignment targets: --assignment #{target_identity(explicit_target)} " \
29
+ "does not match #{DEFAULT_TARGET_ENV}=#{target_identity(env_target)}"
30
+ end
31
+
32
+ explicit_target || env_target || Target.new(assignment_id: nil, scope: nil)
24
33
  end
25
34
 
26
35
  def parse_assignment_target(raw)
@@ -37,6 +46,22 @@ module Ace
37
46
  Target.new(assignment_id: assignment_id, scope: scope)
38
47
  end
39
48
 
49
+ def env_assignment_target
50
+ raw = ENV[DEFAULT_TARGET_ENV].to_s.strip
51
+ return nil if raw.empty?
52
+
53
+ parse_assignment_target(raw)
54
+ end
55
+
56
+ def target_identity(target)
57
+ return "" unless target
58
+
59
+ scope = target.scope.to_s.strip
60
+ return target.assignment_id.to_s if scope.empty?
61
+
62
+ "#{target.assignment_id}@#{scope}"
63
+ end
64
+
40
65
  def build_executor_for_target(target)
41
66
  return Organisms::AssignmentExecutor.new unless target.assignment_id
42
67
 
@@ -48,6 +73,65 @@ module Ace
48
73
  executor.assignment_manager.define_singleton_method(:find_active) { assignment }
49
74
  executor
50
75
  end
76
+
77
+ def resolve_assignment_view(target)
78
+ executor = build_executor_for_target(target)
79
+ result = executor.status
80
+ state = result[:state]
81
+ scoped = scoped_status_view(state, target.scope)
82
+
83
+ View.new(
84
+ assignment: result[:assignment],
85
+ state: state,
86
+ scoped_state: scoped[:state],
87
+ active_steps: scoped[:active_steps],
88
+ next_step: scoped[:next_step],
89
+ focus_step: scoped[:focus_step],
90
+ scope_root: scoped[:root]
91
+ )
92
+ end
93
+
94
+ def scoped_status_view(state, scope)
95
+ if scope.nil? || scope.strip.empty?
96
+ active_steps = state.active_steps
97
+ next_step = active_steps.empty? ? state.next_workable : nil
98
+ return {state: state, active_steps: active_steps, next_step: next_step, focus_step: state.current || next_step, root: nil}
99
+ end
100
+
101
+ root = state.find_by_number(scope.strip)
102
+ raise StepErrors::NotFound, "Step #{scope} not found in queue" unless root
103
+
104
+ scoped_steps = state.subtree_steps(root.number)
105
+ scoped_state = Models::QueueState.new(steps: scoped_steps, assignment: state.assignment)
106
+ active_steps = scoped_state.active_steps
107
+ next_step = active_steps.empty? ? state.next_workable_in_subtree(root.number) : nil
108
+
109
+ {state: scoped_state, active_steps: active_steps, next_step: next_step, focus_step: scoped_state.current || next_step, root: root.number}
110
+ end
111
+
112
+ def fork_scope_root(state, step)
113
+ return nil unless step
114
+ return step if step.fork?
115
+
116
+ state.nearest_fork_ancestor(step.number)
117
+ end
118
+
119
+ def scoped_fork_metadata_step(state, step, scope, scope_root)
120
+ return nil unless step
121
+
122
+ if scope && !scope.strip.empty?
123
+ return state.find_by_number(scope_root || scope.strip)
124
+ end
125
+
126
+ fork_scope_root(state, step)
127
+ end
128
+
129
+ def effective_fork_provider_for(step, scoped_fork_step)
130
+ return nil unless step
131
+
132
+ provider = step.fork_provider || scoped_fork_step&.fork_provider
133
+ provider.to_s.strip.empty? ? nil : provider
134
+ end
51
135
  end
52
136
  end
53
137
  end
@@ -32,7 +32,7 @@ module Ace
32
32
  unless options[:quiet]
33
33
  print_terminal_skip_summary(result[:skipped_terminal])
34
34
  print_assignment_header(result[:assignment])
35
- print_step_instructions(result[:current])
35
+ print_step_instructions(result[:current] || result[:state]&.next_workable)
36
36
  end
37
37
  end
38
38
 
@@ -21,7 +21,7 @@ module Ace
21
21
 
22
22
  target = resolve_assignment_target(options)
23
23
  executor = build_executor_for_target(target)
24
- result = executor.fail(message)
24
+ result = executor.fail(message, fork_root: target.scope)
25
25
 
26
26
  unless options[:quiet]
27
27
  failed = result[:failed]
@@ -4,12 +4,12 @@ module Ace
4
4
  module Assign
5
5
  module CLI
6
6
  module Commands
7
- # Complete in-progress step with report content
7
+ # Complete active step with report content
8
8
  class Finish < Ace::Support::Cli::Command
9
9
  include Ace::Support::Cli::Base
10
10
  include AssignmentTarget
11
11
 
12
- desc "Complete in-progress step with report content"
12
+ desc "Complete active step with report content"
13
13
 
14
14
  argument :step, required: false, desc: "Step number to finish (active assignment only)"
15
15
  option :message, aliases: ["-m"], desc: "Report content: string, file path, or pipe stdin"
@@ -41,13 +41,24 @@ module Ace
41
41
  report_path = File.join(assignment.reports_dir, report_filename)
42
42
  puts "Report saved to: #{report_path}"
43
43
 
44
- if result[:current]
45
- puts "Advancing to step #{result[:current].number}: #{result[:current].name}"
46
- puts
47
- puts "Instructions:"
48
- puts result[:current].instructions
44
+ scoped_state = if target.scope && !target.scope.to_s.strip.empty?
45
+ Ace::Assign::Models::QueueState.new(
46
+ steps: result[:state].subtree_steps(target.scope.strip),
47
+ assignment: assignment
48
+ )
49
+ else
50
+ result[:state]
51
+ end
52
+
53
+ active_steps = scoped_state.active_steps
54
+ if active_steps.any?
55
+ focused = scoped_state.current
56
+ puts "Active steps remaining: #{active_steps.map { |step| "#{step.number} #{step.name}" }.join(', ')}"
57
+ puts "Next: ace-assign step#{step_target_suffix(focused.number, options[:assignment])}" if focused
58
+ elsif (next_step = next_pending_step(result[:state], scoped_state, target.scope))
59
+ puts "No active step selected."
60
+ puts "Next pending step: #{next_step.number} - #{next_step.name}"
49
61
  else
50
- puts
51
62
  fork_root = target.scope&.strip
52
63
  if fork_root && result[:state].subtree_complete?(fork_root)
53
64
  puts "Fork subtree #{fork_root} completed."
@@ -81,6 +92,19 @@ module Ace
81
92
  rescue IOError, Errno::EBADF
82
93
  nil
83
94
  end
95
+
96
+ def step_target_suffix(step_number, assignment_target)
97
+ return " #{step_number}" if assignment_target.nil? || assignment_target.to_s.strip.empty?
98
+
99
+ %( #{step_number} --assignment "#{assignment_target}")
100
+ end
101
+
102
+ def next_pending_step(state, scoped_state, scope_root)
103
+ scope_ref = scope_root.to_s.strip
104
+ return scoped_state.next_workable if scope_ref.empty?
105
+
106
+ state.next_workable_in_subtree(scope_ref)
107
+ end
84
108
  end
85
109
  end
86
110
  end