ace-assign 0.57.1 → 0.60.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +46 -12
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +49 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/evidence.rb +28 -0
  12. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  13. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  14. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  15. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  16. data/lib/ace/assign/cli/commands/status.rb +24 -1
  17. data/lib/ace/assign/cli.rb +34 -2
  18. data/lib/ace/assign/models/assignment.rb +21 -4
  19. data/lib/ace/assign/models/attempt.rb +178 -0
  20. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  21. data/lib/ace/assign/models/evidence_event.rb +82 -0
  22. data/lib/ace/assign/models/execution_receipt.rb +139 -0
  23. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  24. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  25. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  26. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  27. data/lib/ace/assign/molecules/evidence_journal.rb +423 -0
  28. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  29. data/lib/ace/assign/molecules/fork_session_launcher.rb +38 -19
  30. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +209 -0
  31. data/lib/ace/assign/molecules/receipt_verifier.rb +272 -0
  32. data/lib/ace/assign/organisms/assignment_executor.rb +12 -4
  33. data/lib/ace/assign/organisms/attempt_coordinator.rb +618 -0
  34. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  35. data/lib/ace/assign/version.rb +1 -1
  36. data/lib/ace/assign.rb +56 -0
  37. metadata +36 -2
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "time"
8
+
9
+ module Ace
10
+ module Assign
11
+ module Molecules
12
+ # Durable start/prune exclusion for assignment and worktree identities.
13
+ #
14
+ # Lock files live OUTSIDE every deletion target — under the shared Git
15
+ # common dir — so they survive removal of the assignment cache, the
16
+ # worktree, or a recreated lock pathname. Prune holds the exclusive
17
+ # side from its final evidence reads through completion of removal;
18
+ # every supported start path (attempt registration, fork/driver
19
+ # session launch, worktree provisioning) holds the shared side while
20
+ # registering a writer.
21
+ #
22
+ # A `removed` marker recorded by prune makes post-deletion starts fail
23
+ # closed instead of recreating the deleted identity: acquiring the
24
+ # shared side raises {AttemptErrors::Conflict} when the marker is
25
+ # present. Legitimate fresh provisioning (a new worktree for a task)
26
+ # clears the marker explicitly via `reset_removed`.
27
+ class LifecycleExclusion
28
+ # @param repo_root [String, nil] Repository used to resolve the
29
+ # shared Git common dir (default: project root)
30
+ # @param root [String, nil] Explicit exclusion root (tests)
31
+ def initialize(repo_root: nil, root: nil)
32
+ @root = root || self.class.default_root(repo_root)
33
+ end
34
+
35
+ # The exclusion root shared by every writer and prune for this repo.
36
+ # Sandboxed environments (CACHE_BASE) scope it under the cache base,
37
+ # which itself is never a deletion target; otherwise it lives under
38
+ # the shared Git common dir, which survives worktree removal.
39
+ def self.default_root(_repo_root = nil)
40
+ cache_base = ENV["CACHE_BASE"]
41
+ if cache_base && !cache_base.empty?
42
+ return File.join(File.expand_path(cache_base), ".exclusion")
43
+ end
44
+
45
+ repo_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
46
+ common_dir, _stderr, status = Open3.capture3(
47
+ "git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir"
48
+ )
49
+ base = status.success? && !common_dir.to_s.strip.empty? ? common_dir.strip : File.join(repo_root, ".git")
50
+ File.join(base, "ace", "lifecycle-exclusion")
51
+ end
52
+
53
+ def assignment_key(assignment_id)
54
+ "assignment:#{assignment_id}"
55
+ end
56
+
57
+ # Task identity is the canonical worktree identity: work-on derives
58
+ # the worktree path from the task ref, and prune candidates carry
59
+ # the same task id.
60
+ def task_key(task_id)
61
+ "task:#{task_id}"
62
+ end
63
+
64
+ def worktree_key(worktree_path)
65
+ canonical = begin
66
+ File.realpath(worktree_path.to_s)
67
+ rescue Errno::ENOENT, Errno::EACCES
68
+ File.expand_path(worktree_path.to_s)
69
+ end
70
+ "worktree:#{canonical}"
71
+ end
72
+
73
+ # Hold the exclusive side while recomputing proofs and removing the
74
+ # target. Start paths block for the whole block.
75
+ #
76
+ # @param key [String] Identity key
77
+ # @yield Required block executed under exclusive exclusion
78
+ def with_exclusive(key)
79
+ raise ArgumentError, "exclusion key is required" if key.to_s.empty?
80
+
81
+ lock = open_lock(key)
82
+ lock.flock(File::LOCK_EX)
83
+ begin
84
+ yield
85
+ ensure
86
+ lock.flock(File::LOCK_UN)
87
+ lock.close
88
+ end
89
+ end
90
+
91
+ # Hold the shared side while registering a writer or creating a
92
+ # resource. Fails closed when the identity was pruned.
93
+ #
94
+ # @param key [String] Identity key
95
+ # @param reset_removed [Boolean] Clear a stale removed marker instead
96
+ # of raising (legitimate fresh provisioning of the same pathname)
97
+ def with_shared(key, reset_removed: false)
98
+ raise ArgumentError, "exclusion key is required" if key.to_s.empty?
99
+
100
+ lock = open_lock(key)
101
+ lock.flock(File::LOCK_SH)
102
+ begin
103
+ if removed?(key)
104
+ unless reset_removed
105
+ raise AttemptErrors::Conflict, removed_message(key)
106
+ end
107
+
108
+ clear_removed!(key)
109
+ end
110
+ yield
111
+ ensure
112
+ lock.flock(File::LOCK_UN)
113
+ lock.close
114
+ end
115
+ end
116
+
117
+ # Hold the shared side on several identities at once (acquired in the
118
+ # given order, released in reverse — callers must use one consistent
119
+ # global order, task before assignment, to avoid deadlock). Fails
120
+ # closed when any identity was pruned.
121
+ def with_shared_multi(keys, reset_removed: false)
122
+ ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
123
+ raise ArgumentError, "exclusion keys are required" if ordered.empty?
124
+
125
+ locks = ordered.map { |key| open_lock(key) }
126
+ begin
127
+ locks.each { |lock| lock.flock(File::LOCK_SH) }
128
+ ordered.each do |key|
129
+ if removed?(key)
130
+ unless reset_removed
131
+ raise AttemptErrors::Conflict, removed_message(key)
132
+ end
133
+
134
+ clear_removed!(key)
135
+ end
136
+ end
137
+ yield
138
+ ensure
139
+ locks.reverse_each do |lock|
140
+ lock.flock(File::LOCK_UN)
141
+ lock.close
142
+ end
143
+ end
144
+ end
145
+
146
+ # Record that the identity was removed. Call while holding the
147
+ # exclusive side, BEFORE deleting the target.
148
+ def record_removed!(key)
149
+ atomic_write(
150
+ state_path(key),
151
+ JSON.generate({"removed" => true, "removed_at" => Time.now.utc.iso8601, "key" => key})
152
+ )
153
+ end
154
+
155
+ def removed?(key)
156
+ state = JSON.parse(File.read(state_path(key)))
157
+ state["removed"] == true
158
+ rescue Errno::ENOENT, JSON::ParserError, TypeError
159
+ false
160
+ end
161
+
162
+ def clear_removed!(key)
163
+ File.delete(state_path(key))
164
+ rescue Errno::ENOENT
165
+ nil
166
+ end
167
+
168
+ private
169
+
170
+ attr_reader :root
171
+
172
+ def open_lock(key)
173
+ FileUtils.mkdir_p(root)
174
+ File.open(lock_path(key), File::RDWR | File::CREAT)
175
+ end
176
+
177
+ def lock_path(key)
178
+ File.join(root, "#{Digest::SHA256.hexdigest(key)}.lock")
179
+ end
180
+
181
+ def state_path(key)
182
+ File.join(root, "#{Digest::SHA256.hexdigest(key)}.state.json")
183
+ end
184
+
185
+ def removed_message(key)
186
+ identity = key.to_s.sub(/\A[^:]+:/, "")
187
+ case key
188
+ when /\Aassignment:/
189
+ "Assignment #{identity} was pruned; refusing to start a writer on a removed identity"
190
+ when /\Atask:/
191
+ "Task #{identity} worktree was pruned; refusing to start a writer on a removed identity"
192
+ else
193
+ "Worktree #{identity} was pruned; refusing to start a writer on a removed identity"
194
+ end
195
+ end
196
+
197
+ def atomic_write(path, content)
198
+ dir = File.dirname(path)
199
+ FileUtils.mkdir_p(dir)
200
+ temp = File.join(dir, ".tmp-#{Process.pid}-#{rand(1_000_000)}")
201
+ File.write(temp, content)
202
+ File.rename(temp, path)
203
+ ensure
204
+ File.delete(temp) if temp && File.exist?(temp)
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Canonical receipt validation for accepted execution evidence.
10
+ #
11
+ # Verifies schema and field hygiene, attempt binding, producer
12
+ # attribution, transition authority, current-head candidate binding,
13
+ # artifact digests, executed checks, and independent review verdicts.
14
+ # Every failure is a rejection with an actionable reason; a receipt is
15
+ # never partially accepted.
16
+ class ReceiptVerifier
17
+ # Operations that act outside the local repository once executed.
18
+ EXTERNAL_EFFECT_OPERATIONS = %w[merge publish deploy release].freeze
19
+
20
+ REVIEW_OPERATION = "review"
21
+ HEAD_PATTERN = /\A[0-9a-f]{7,64}\z/.freeze
22
+ APPROVED_VERDICT = "approved"
23
+
24
+ # @param identity_resolver [ExecutionIdentityResolver] Trust boundary
25
+ def initialize(identity_resolver: nil)
26
+ @identity_resolver = identity_resolver || ExecutionIdentityResolver.new
27
+ end
28
+
29
+ # Validate a submitted receipt against an attempt.
30
+ #
31
+ # @param data [Hash] Parsed receipt payload
32
+ # @param attempt [Models::Attempt] Target attempt (loaded by coordinator)
33
+ # @param identity [ExecutionIdentityResolver::Identity] Finishing boundary identity
34
+ # @param live_head [String] Current candidate revision (git rev-parse HEAD)
35
+ # @param repo_root [String] Project root for artifact resolution
36
+ # @return [Models::ExecutionReceipt] Normalized receipt with digest
37
+ # @raise [AttemptErrors::ReceiptRejected] on any validation failure
38
+ def verify!(data, attempt:, identity:, live_head:, repo_root:)
39
+ reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
40
+
41
+ if Models::ExecutionReceipt.forbidden_field?(data)
42
+ reject("receipt carries forbidden fields (credentials, environment, or terminal output)")
43
+ end
44
+
45
+ required = %w[attempt_id assignment_id project_id scope operation producer head verdict]
46
+ missing = required.select { |key| data[key].nil? || data[key].to_s.strip.empty? }
47
+ reject("missing required fields: #{missing.join(', ')}") unless missing.empty?
48
+ unless Models::ExecutionReceipt::VERDICTS.include?(data["verdict"].to_s)
49
+ reject("unsupported verdict '#{data['verdict']}': must be one of #{Models::ExecutionReceipt::VERDICTS.join(', ')}")
50
+ end
51
+
52
+ verify_binding(data, attempt)
53
+ verify_producer(data)
54
+ verify_authority(data, identity)
55
+ verify_head(data, live_head)
56
+ verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
57
+ verify_checks(data)
58
+ verify_review(data)
59
+ verify_campaign(data, repo_root: repo_root, live_head: live_head)
60
+
61
+ receipt = Models::ExecutionReceipt.from_h(data.merge("recorded_at" => Time.now.utc))
62
+ verify_digest(receipt)
63
+
64
+ receipt
65
+ end
66
+ # Recheck the source artifacts/checks of an already accepted receipt.
67
+ def verify_accepted_evidence!(data, live_head:, repo_root:)
68
+ verify_head(data, live_head)
69
+ verify_artifacts(data, repo_root)
70
+ verify_checks(data)
71
+ verify_review(data)
72
+ end
73
+
74
+ # @param operation [String] Receipt operation
75
+ # @return [Boolean] True when the operation acts outside the repository
76
+ def external_effect?(operation)
77
+ EXTERNAL_EFFECT_OPERATIONS.include?(operation.to_s)
78
+ end
79
+
80
+ private
81
+
82
+ def verify_binding(data, attempt)
83
+ binding = attempt.binding
84
+ unless data["attempt_id"] == attempt.attempt_id
85
+ reject("receipt attempt_id #{data['attempt_id']} does not match attempt #{attempt.attempt_id}")
86
+ end
87
+ unless data["assignment_id"] == binding.assignment_id
88
+ reject("receipt assignment #{data['assignment_id']} does not match attempt assignment #{binding.assignment_id}")
89
+ end
90
+ unless data["project_id"] == binding.project_id
91
+ reject("receipt project #{data['project_id']} does not match attempt project #{binding.project_id}")
92
+ end
93
+ unless Atoms::AssignmentScope.equal?(data["scope"], binding.scope)
94
+ reject("receipt scope #{data['scope']} does not match attempt scope #{binding.scope}")
95
+ end
96
+ end
97
+
98
+ def verify_producer(data)
99
+ producer = data["producer"]
100
+ reject_unless(producer.is_a?(Hash), "producer must be an object with actor, role, runtime")
101
+
102
+ actor = producer["actor"].to_s.strip
103
+ role = producer["role"].to_s.strip
104
+ runtime = producer["runtime"].to_s.strip
105
+ if actor.empty? || runtime.empty? || !ExecutionIdentityResolver::ROLES.include?(role)
106
+ reject("producer attribution incomplete: actor, runtime, and a known role are required")
107
+ end
108
+ end
109
+
110
+ # Workers submit attributable results but cannot self-approve
111
+ # succeeded outcomes; only coordinator/service authority accepts them.
112
+ def verify_authority(data, identity)
113
+ return if data["verdict"] != "succeeded"
114
+ return if @identity_resolver.trusted?(identity)
115
+
116
+ reject("identity #{identity.actor}/#{identity.role} may not accept a succeeded verdict")
117
+ end
118
+
119
+ # The tested/reviewed head must be the live candidate revision; stale
120
+ # heads are rejected (candidate invalidation is recorded by the
121
+ # coordinator).
122
+ def verify_head(data, live_head)
123
+ reject("live candidate head unavailable") if live_head.nil? || live_head.to_s.strip.empty?
124
+ reject("receipt head '#{data['head']}' is not a git SHA") unless data["head"].match?(HEAD_PATTERN)
125
+
126
+ unless data["head"] == live_head
127
+ reject("receipt head #{data['head']} is stale; current candidate is #{live_head}")
128
+ end
129
+ end
130
+
131
+ # Succeeded outcomes need at least one artifact whose recorded digest
132
+ # matches the file on disk. Paths stay below the project root; symlink
133
+ # or traversal escapes are rejected.
134
+ def verify_artifacts(data, repo_root)
135
+ artifacts = data["artifacts"]
136
+ reject_unless(artifacts.is_a?(Array) && !artifacts.empty?, "succeeded verdict requires at least one artifact")
137
+
138
+ artifacts.each do |artifact|
139
+ reject_unless(artifact.is_a?(Hash), "artifact entries must be objects")
140
+ path = artifact["path"].to_s
141
+ recorded = artifact["sha256"].to_s
142
+ reject("artifact missing path or sha256") if path.empty? || recorded.empty?
143
+
144
+ expanded = begin
145
+ File.expand_path(path, File.realpath(repo_root))
146
+ rescue Errno::ENOENT, Errno::EACCES
147
+ nil
148
+ end
149
+ reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
150
+
151
+ resolved = safe_resolve(repo_root, path)
152
+ reject("artifact path escapes project root: #{path}") if resolved.nil?
153
+
154
+ actual = Digest::SHA256.hexdigest(File.read(resolved))
155
+ reject("artifact digest mismatch for #{path}") unless actual == recorded
156
+ end
157
+ end
158
+
159
+ def verify_checks(data)
160
+ checks = data["checks"] || []
161
+ reject_unless(checks.is_a?(Array), "checks must be an array")
162
+ if data["verdict"] == "succeeded" && checks.empty?
163
+ reject("succeeded verdict requires at least one executed check")
164
+ end
165
+
166
+ checks.each do |check|
167
+ reject_unless(check.is_a?(Hash), "check entries must be objects")
168
+ reject("check missing name") if check["name"].to_s.strip.empty?
169
+ reject("check #{check['name']} did not pass") if check["verdict"].to_s != "passed" && data["verdict"] == "succeeded"
170
+ end
171
+ end
172
+
173
+ # A caller-supplied digest must equal the canonical digest of the
174
+ # receipt payload; arbitrary or all-zero digests are rejected.
175
+ def verify_digest(receipt)
176
+ return if receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
177
+
178
+ reject("receipt digest mismatch: supplied digest does not match the canonical payload")
179
+ end
180
+
181
+ # Review receipts need an executed, independent reviewer verdict for
182
+ # exactly the tested head — a report existing is not approval, and a
183
+ # review receipt without a reviewer verdict is not evidence.
184
+ def verify_review(data)
185
+ review = data["review"]
186
+ if review.nil?
187
+ if data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
188
+ reject("review receipt requires an executed independent reviewer verdict")
189
+ end
190
+
191
+ return
192
+ end
193
+
194
+ reject_unless(review.is_a?(Hash), "review must be an object")
195
+ reviewer = review["reviewer"]
196
+ reject_unless(reviewer.is_a?(Hash) && !reviewer["actor"].to_s.strip.empty?, "review must name an executed reviewer")
197
+
198
+ producer_actor = data.dig("producer", "actor").to_s.strip
199
+ if reviewer["actor"].to_s.strip == producer_actor
200
+ reject("reviewer and producer must differ (self-approval rejected)")
201
+ end
202
+
203
+ reject("review verdict must be '#{APPROVED_VERDICT}' for a succeeded receipt") if review["verdict"] != APPROVED_VERDICT && data["verdict"] == "succeeded"
204
+ unless review["head"] == data["head"]
205
+ reject("review head #{review['head']} does not match receipt head #{data['head']}")
206
+ end
207
+ end
208
+
209
+ # Consume ace-review's single campaign authority through the existing
210
+ # receipt boundary; a worker-supplied local result grants no authority.
211
+ def verify_campaign(data, repo_root:, live_head:)
212
+ campaign = data["campaign"]
213
+ return unless campaign
214
+ reject_unless(campaign.is_a?(Hash), "campaign must be an object")
215
+ reject("campaign result requires a succeeded review receipt") unless
216
+ data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
217
+ reference = campaign["result"]
218
+ reject_unless(reference.is_a?(Hash), "campaign requires a result artifact reference")
219
+ unless Array(data["artifacts"]).include?(reference)
220
+ reject("campaign result must be included in verified receipt artifacts")
221
+ end
222
+ resolved = safe_resolve(repo_root, reference["path"].to_s)
223
+ reject("campaign result path escapes repository or is missing") unless resolved
224
+ result = JSON.parse(File.read(resolved))
225
+ reject_unless(result.is_a?(Hash), "campaign result must be an object")
226
+ require "ace/review"
227
+ current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
228
+ unless result["campaign_id"] == campaign["id"] && result["accepted"] == true &&
229
+ result["dry_run"] == false && current["accepted"] == true &&
230
+ result["result_identity"] == current["result_identity"] &&
231
+ result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
232
+ reject("campaign result is stale, incomplete, blocked or does not match live authority")
233
+ end
234
+ approval = current["rounds"].last["approval"]
235
+ unless approval["producer"] == data.dig("producer", "actor") &&
236
+ approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
237
+ approval["head"] == data["head"]
238
+ reject("campaign producer/reviewer attribution does not match independently validated receipt")
239
+ end
240
+ rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
241
+ reject("invalid campaign result: #{e.message}")
242
+ end
243
+
244
+ # Resolve a project-relative artifact path to its real location,
245
+ # rejecting traversal and symlink escapes (an in-repo symlink must
246
+ # not smuggle in an external file); returns nil when the path leaves
247
+ # the root.
248
+ def safe_resolve(repo_root, path)
249
+ root = File.realpath(repo_root)
250
+ candidate = File.expand_path(path, root)
251
+ return nil unless candidate == root || candidate.start_with?(root + File::SEPARATOR)
252
+ return nil unless File.exist?(candidate)
253
+
254
+ resolved = File.realpath(candidate)
255
+ return nil unless resolved == root || resolved.start_with?(root + File::SEPARATOR)
256
+
257
+ resolved
258
+ rescue Errno::ENOENT, Errno::EACCES
259
+ nil
260
+ end
261
+
262
+ def reject(message)
263
+ raise AttemptErrors::ReceiptRejected, message
264
+ end
265
+
266
+ def reject_unless(condition, message)
267
+ reject(message) unless condition
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -56,8 +56,12 @@ module Ace
56
56
  #
57
57
  # @param config_path [String] Path to job.yaml config
58
58
  # @param parent_id [String, nil] Parent assignment ID for hierarchy linking
59
+ # @param task_id [String, nil] Source task ID attaching this assignment
60
+ # (also read from the job's assignment.task_id); nil = taskless
61
+ # @param project_id [String, nil] Project ID (also read from the job's
62
+ # assignment.project_id)
59
63
  # @return [Hash] Result with assignment and first step
60
- def start(config_path, parent_id: nil)
64
+ def start(config_path, parent_id: nil, task_id: nil, project_id: nil)
61
65
  raise ConfigErrors::NotFound, "Config file not found: #{config_path}" unless File.exist?(config_path)
62
66
 
63
67
  config = YAML.safe_load_file(config_path, permitted_classes: [Time, Date])
@@ -74,12 +78,14 @@ module Ace
74
78
  steps_config = expand_sub_steps(steps_config)
75
79
  steps_config = materialize_skill_backed_steps(steps_config)
76
80
 
77
- # Create assignment
81
+ # Create assignment; explicit args win over job config keys
78
82
  assignment = assignment_manager.create(
79
83
  name: assignment_config["name"] || File.basename(config_path, ".yaml"),
80
84
  description: assignment_config["description"],
81
85
  source_config: config_path,
82
- parent: parent_id
86
+ parent: parent_id,
87
+ task_id: task_id || assignment_config["task_id"],
88
+ project_id: project_id || assignment_config["project_id"]
83
89
  )
84
90
 
85
91
  # Create initial step files
@@ -108,7 +114,9 @@ module Ace
108
114
  updated_at: assignment.updated_at,
109
115
  source_config: archived_path,
110
116
  cache_dir: assignment.cache_dir,
111
- parent: assignment.parent
117
+ parent: assignment.parent,
118
+ task_id: assignment.task_id,
119
+ project_id: assignment.project_id
112
120
  )
113
121
  assignment_manager.update(assignment)
114
122