ace-assign 0.57.0 → 0.58.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +18 -0
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +32 -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/finish.rb +35 -0
  12. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  13. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  14. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  15. data/lib/ace/assign/cli/commands/status.rb +24 -1
  16. data/lib/ace/assign/cli.rb +30 -2
  17. data/lib/ace/assign/models/assignment.rb +21 -4
  18. data/lib/ace/assign/models/attempt.rb +178 -0
  19. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  20. data/lib/ace/assign/models/evidence_event.rb +82 -0
  21. data/lib/ace/assign/models/execution_receipt.rb +135 -0
  22. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  23. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  24. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  25. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  26. data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
  27. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  28. data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
  29. data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
  30. data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
  31. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  32. data/lib/ace/assign/version.rb +1 -1
  33. data/lib/ace/assign.rb +56 -0
  34. metadata +20 -2
@@ -0,0 +1,424 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Append-only execution evidence journal backed by a dedicated Git ref.
11
+ #
12
+ # Accepted evidence for assignment attempts is committed to a separate
13
+ # configured evidence ref (default `refs/ace/execution`) in canonical,
14
+ # non-secret paths `execution/<assignment-id>/events/`. The ref lives
15
+ # outside the deliverable candidate branch: appending evidence never
16
+ # advances or changes the reviewed candidate. Working state uses an
17
+ # isolated, disposable detached worktree under the configured checkout
18
+ # root.
19
+ #
20
+ # Concurrency: all ref updates serialize on a checkout-root flock, and
21
+ # every update is additionally guarded by an expected-old-value
22
+ # `git update-ref` compare-and-swap. On a CAS conflict the journal
23
+ # reloads the authoritative ref and replays only still-valid,
24
+ # non-duplicate events.
25
+ class EvidenceJournal
26
+ CAS_ATTEMPTS = 3
27
+
28
+ # @param repo_root [String] Git repository root holding the evidence ref
29
+ # @param ref [String, nil] Evidence ref (default from config)
30
+ # @param checkout_root [String, nil] Isolated checkout root (default from config)
31
+ def initialize(repo_root:, ref: nil, checkout_root: nil)
32
+ @repo_root = repo_root
33
+ @ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
34
+ root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
35
+ @checkout_root = Pathname.new(root).absolute? ? root : File.join(repo_root, root)
36
+ end
37
+
38
+ attr_reader :repo_root, :ref, :checkout_root
39
+
40
+ # @return [Boolean] True when the repo can hold the evidence ref
41
+ def available?
42
+ git_ok?("rev-parse", "--git-dir")
43
+ end
44
+
45
+ # Current value of the evidence ref.
46
+ #
47
+ # @return [String, nil] Commit SHA or nil when the ref does not exist
48
+ def ref_value
49
+ out, stderr, status = git("rev-parse", "--verify", "--quiet", @ref)
50
+ return out if status.success?
51
+
52
+ raise AttemptErrors::EvidenceUnavailable, "Cannot read evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
53
+
54
+ nil
55
+ end
56
+
57
+ # Append accepted events to the journal under lock + CAS.
58
+ #
59
+ # @param assignment_id [String] Assignment ID (journal path segment)
60
+ # @param attempt_id [String] Attempt ID (commit message + dedupe scope)
61
+ # @param events [Array<Hash>] Event payloads from Models::EvidenceEvent.build
62
+ # @return [String] Journal commit SHA after the append
63
+ # @raise [AttemptErrors::EvidenceUnavailable] when the ref stays conflicting
64
+ def append(assignment_id:, attempt_id:, events:)
65
+ return ref_value if events.empty?
66
+
67
+ with_lock do
68
+ CAS_ATTEMPTS.times do
69
+ # Re-read the authoritative value every attempt. nil means the
70
+ # ref does not exist yet; ensure_checkout! seeds it, so re-read
71
+ # before computing the compare-and-swap baseline.
72
+ old = ref_value
73
+ ensure_checkout!
74
+ old = ref_value if old.nil?
75
+ sync_checkout(old)
76
+ written = write_event_files(assignment_id, events)
77
+ new_commit = written.zero? ? old : commit_events(assignment_id, attempt_id, events)
78
+ return new_commit if update_ref_cas(new_commit, old)
79
+
80
+ # CAS conflict: another writer advanced the ref. The next pass
81
+ # resets the checkout to the authoritative tree and replays
82
+ # only the events that are still missing (duplicates skipped).
83
+ end
84
+
85
+ raise AttemptErrors::EvidenceUnavailable,
86
+ "Evidence ref #{@ref} stayed conflicting after #{CAS_ATTEMPTS} compare-and-swap attempts"
87
+ end
88
+ end
89
+
90
+ # Read all journal events recorded for an assignment, ordered by the
91
+ # digest chain (never by filename: same-second events sort
92
+ # alphabetically, which can invert lifecycle order).
93
+ #
94
+ # @param assignment_id [String] Assignment ID
95
+ # @return [Array<Hash>] Parsed events in journal order
96
+ def read_events(assignment_id)
97
+ value = ref_value
98
+ return [] if value.nil?
99
+
100
+ with_lock do
101
+ ensure_checkout!
102
+ sync_checkout(value)
103
+ order_by_chain(event_files(assignment_id).map { |path| JSON.parse(File.read(path)) })
104
+ end
105
+ rescue JSON::ParserError => e
106
+ raise AttemptErrors::EvidenceUnavailable, "Corrupt journal event for #{assignment_id}: #{e.message}"
107
+ end
108
+
109
+ # Accepted receipt payloads recorded for an assignment.
110
+ #
111
+ # @param assignment_id [String] Assignment ID
112
+ # @return [Array<Hash>] Accepted receipt payloads (digest included)
113
+ def accepted_receipts(assignment_id)
114
+ read_events(assignment_id)
115
+ .select { |event| event["type"] == "receipt_accepted" }
116
+ .map { |event| event.dig("payload", "receipt") }
117
+ .compact
118
+ end
119
+
120
+ # All attempts derivable from the journal, reconstructed from intent
121
+ # and process_start facts with journal-authoritative state. The
122
+ # journal is the owner of attempt-state interpretation.
123
+ #
124
+ # @param assignment_id [String] Assignment ID
125
+ # @return [Array<Models::Attempt>] Derived attempts
126
+ def derived_attempts(assignment_id)
127
+ events = read_events(assignment_id)
128
+ by_attempt = events.group_by { |event| event["attempt_id"] }
129
+ by_attempt.delete(nil)
130
+
131
+ by_attempt.filter_map do |attempt_id, attempt_events|
132
+ intent = attempt_events.find { |event| event["type"] == "intent" }
133
+ next unless intent
134
+
135
+ state = derive_state(attempt_events)
136
+ next if state.nil?
137
+
138
+ build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state)
139
+ end
140
+ end
141
+
142
+ # Non-terminal attempts derived from the journal.
143
+ #
144
+ # @param assignment_id [String] Assignment ID
145
+ # @return [Array<Models::Attempt>] Active attempts
146
+ def active_attempts(assignment_id)
147
+ derived_attempts(assignment_id).reject(&:terminal?)
148
+ end
149
+
150
+ # Assignment IDs present in the journal, discovered from the evidence
151
+ # ref itself (works even when the local assignment cache is gone).
152
+ #
153
+ # @return [Array<String>] Assignment IDs with journal evidence
154
+ def assignment_ids
155
+ value = ref_value
156
+ return [] if value.nil?
157
+
158
+ with_lock do
159
+ ensure_checkout!
160
+ sync_checkout(value)
161
+ Dir.glob(File.join(checkout_dir, "execution", "*"))
162
+ .select { |path| File.directory?(path) }
163
+ .map { |path| File.basename(path) }
164
+ .sort
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ # Latest lifecycle state implied by the events, or nil when the
171
+ # events do not describe a full attempt (intent missing).
172
+ def derive_state(events)
173
+ return nil unless events.any? { |event| event["type"] == "intent" }
174
+
175
+ state = "running"
176
+ events.each do |event|
177
+ case event["type"]
178
+ when "receipt_accepted"
179
+ state = event.dig("payload", "receipt", "verdict") || state
180
+ when "transition"
181
+ state = event.dig("payload", "to") || state
182
+ when "reconciliation"
183
+ state = event.dig("payload", "resolution") || state
184
+ end
185
+ end
186
+ state
187
+ end
188
+
189
+ def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
190
+ process_start = events.reverse.find do |event|
191
+ event["type"] == "process_start" && event["attempt_id"] == attempt_id
192
+ end
193
+ candidate_head = events.select { |event| event["type"] == "receipt_accepted" }
194
+ .map { |event| event.dig("payload", "receipt", "head") }
195
+ .compact
196
+ .first
197
+ binding = Models::AttemptBinding.new(
198
+ attempt_id: attempt_id,
199
+ assignment_id: assignment_id,
200
+ scope: intent_payload["scope"],
201
+ project_id: intent_payload["project_id"],
202
+ task_id: intent_payload["task_id"],
203
+ actor: process_start&.dig("payload", "actor") || "recovered",
204
+ role: process_start&.dig("payload", "role") || "coordinator",
205
+ runtime: process_start&.dig("payload", "runtime") || "recovered",
206
+ base_head: intent_payload["base_head"],
207
+ evidence_git_ref: ref,
208
+ created_at: parse_event_time(intent_time(events, attempt_id))
209
+ )
210
+ Models::Attempt.new(
211
+ binding: binding,
212
+ state: state,
213
+ candidate_head: candidate_head,
214
+ journal_commit: ref_value
215
+ )
216
+ end
217
+
218
+ def intent_time(events, attempt_id)
219
+ intent = events.find { |event| event["type"] == "intent" && event["attempt_id"] == attempt_id }
220
+ intent&.dig("recorded_at")
221
+ end
222
+
223
+ def parse_event_time(value)
224
+ return Time.now.utc if value.nil?
225
+
226
+ require "time"
227
+ Time.parse(value)
228
+ rescue ArgumentError
229
+ Time.now.utc
230
+ end
231
+
232
+ private
233
+
234
+ def event_files(assignment_id)
235
+ Dir.glob(File.join(checkout_dir, "execution", assignment_id, "events", "*.json")).sort
236
+ end
237
+
238
+ # Order events by following previous_digest links from chain roots;
239
+ # orphaned events (unknown predecessor) keep filename order after the
240
+ # resolved chains.
241
+ def order_by_chain(events)
242
+ by_digest = {}
243
+ events.each { |event| by_digest[event["digest"]] = event }
244
+
245
+ next_of = {}
246
+ events.each do |event|
247
+ previous = event["previous_digest"]
248
+ next_of[previous] = event if previous && by_digest.key?(previous)
249
+ end
250
+
251
+ roots = events.reject do |event|
252
+ previous = event["previous_digest"]
253
+ previous && by_digest.key?(previous)
254
+ end
255
+
256
+ ordered = []
257
+ visited = {}
258
+ roots.each do |root|
259
+ cursor = root
260
+ while cursor && !visited[cursor["digest"]]
261
+ visited[cursor["digest"]] = true
262
+ ordered << cursor
263
+ cursor = next_of[cursor["digest"]]
264
+ end
265
+ end
266
+
267
+ ordered + events.reject { |event| visited[event["digest"]] }
268
+ end
269
+
270
+ def event_filename(event)
271
+ "#{event["recorded_at"].to_s.tr("-:T Z", "")}-#{event["type"]}-#{event["digest"][0, 12]}.json"
272
+ end
273
+
274
+ def write_event_files(assignment_id, events)
275
+ written = 0
276
+ events.each do |event|
277
+ path = File.join(checkout_dir, "execution", assignment_id, "events", event_filename(event))
278
+ next if File.exist?(path)
279
+
280
+ FileUtils.mkdir_p(File.dirname(path))
281
+ File.write(path, JSON.pretty_generate(event))
282
+ written += 1
283
+ end
284
+ written
285
+ end
286
+
287
+ def commit_events(assignment_id, attempt_id, events)
288
+ types = events.map { |event| event["type"] }.uniq.join(",")
289
+ git!("-C", checkout_dir, "add", "-A", "execution/#{assignment_id}")
290
+ git!(
291
+ "-C", checkout_dir,
292
+ "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
293
+ "commit", "-m", "evidence: #{assignment_id} #{attempt_id} (#{types})"
294
+ )
295
+ git!("-C", checkout_dir, "rev-parse", "HEAD").first
296
+ end
297
+
298
+ # Expected-old-value compare-and-swap; returns false on conflict.
299
+ # A nil expectation is the create case: the ref must not exist.
300
+ def update_ref_cas(new_commit, expected_old)
301
+ return true if !expected_old.nil? && new_commit == expected_old
302
+
303
+ expected = expected_old || ("0" * 40)
304
+ _out, stderr, status = git("update-ref", @ref, new_commit, expected)
305
+ return true if status.success?
306
+
307
+ raise AttemptErrors::EvidenceUnavailable, "Cannot update evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
308
+
309
+ false
310
+ end
311
+
312
+ def checkout_dir
313
+ File.join(@checkout_root, "journal")
314
+ end
315
+
316
+ def lock_path
317
+ File.join(@checkout_root, ".evidence.lock")
318
+ end
319
+
320
+ def with_lock
321
+ FileUtils.mkdir_p(@checkout_root)
322
+ File.open(lock_path, File::RDWR | File::CREAT) do |lock|
323
+ lock.flock(File::LOCK_EX)
324
+ begin
325
+ yield
326
+ ensure
327
+ lock.flock(File::LOCK_UN)
328
+ end
329
+ end
330
+ end
331
+
332
+ def ensure_checkout!
333
+ return if detached_worktree_at?(checkout_dir)
334
+
335
+ FileUtils.rm_rf(checkout_dir)
336
+ git!("worktree", "prune")
337
+ value = ref_value
338
+ if value.nil?
339
+ seed_ref
340
+ value = ref_value
341
+ end
342
+ git!("worktree", "add", "--detach", checkout_dir, value)
343
+ end
344
+
345
+ # Seed the evidence ref with an empty-tree commit so a worktree can
346
+ # attach before any real evidence exists. The create is a
347
+ # compare-and-swap against the zero SHA: a writer that loses the
348
+ # race keeps the winner's ref instead of resetting it.
349
+ def seed_ref
350
+ empty_tree = git!("mktree").first
351
+ seed = git!("-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
352
+ "commit-tree", empty_tree, "-m", "seed: ace-assign execution evidence").first
353
+ _out, stderr, status = git("update-ref", @ref, seed, "0" * 40)
354
+ return if status.success?
355
+
356
+ raise AttemptErrors::EvidenceUnavailable, "Cannot seed evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
357
+ end
358
+
359
+ def detached_worktree_at?(path)
360
+ dot_git = File.join(path, ".git")
361
+ return false unless File.exist?(dot_git)
362
+
363
+ out, _s = git("-C", path, "rev-parse", "--abbrev-ref", "HEAD")
364
+ out.to_s.strip == "HEAD"
365
+ end
366
+
367
+ # Sync the disposable checkout to an authoritative ref value.
368
+ def sync_checkout(target)
369
+ return if target.nil?
370
+
371
+ git!("-C", checkout_dir, "reset", "--hard", target)
372
+ rescue AttemptErrors::EvidenceUnavailable
373
+ # Stale, broken, or unregistered worktree: rebuild from scratch.
374
+ FileUtils.rm_rf(checkout_dir)
375
+ git!("worktree", "prune")
376
+ git!("worktree", "add", "--detach", checkout_dir, target)
377
+ end
378
+
379
+ def git(*argv)
380
+ unless File.directory?(@repo_root)
381
+ return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
382
+ end
383
+
384
+ out, stderr, status = Open3.capture3("git", *argv, chdir: @repo_root, stdin_data: "")
385
+ [out.to_s.strip, stderr.to_s.strip, status]
386
+ end
387
+
388
+ def git!(*argv)
389
+ out, stderr, status = git(*argv)
390
+ unless status.success?
391
+ raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(' ')} failed: #{stderr}"
392
+ end
393
+
394
+ [out, stderr, status]
395
+ end
396
+
397
+ def git_ok?(*argv)
398
+ git(*argv)[2].success?
399
+ end
400
+
401
+ # Distinguish "ref missing" (normal, quiet, no stderr) and expected
402
+ # compare-and-swap conflicts (retryable) from a broken repository or
403
+ # ref store.
404
+ def git_broken?(stderr)
405
+ message = stderr.to_s.strip
406
+ return false if message.empty?
407
+ return false if message.include?("unknown revision") || message.include?("not a valid ref")
408
+ return false if message.include?("cannot lock ref") || message.include?("but expected")
409
+
410
+ true
411
+ end
412
+
413
+ # Stand-in status for commands that cannot run at all.
414
+ FailedResult = Struct.new(:success?)
415
+ FAILED_RESULT = FailedResult.new(false).freeze
416
+
417
+ def default_config(key)
418
+ section = Ace::Assign.config["attempt"]
419
+ section && section[key]
420
+ end
421
+ end
422
+ end
423
+ end
424
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "json"
5
+ require "socket"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Resolves the trusted execution-boundary identity for attempt
11
+ # transitions.
12
+ #
13
+ # Actor identity is derived from the execution boundary, never granted
14
+ # by command flags or receipt claims. Two adapters ship:
15
+ # - `local` — the interactive operator is the coordinator; identity
16
+ # comes from the OS login boundary.
17
+ # - `service` — a trusted service executor supplies a structured
18
+ # identity through a configured environment variable; the executor
19
+ # owns OS/process enforcement.
20
+ #
21
+ # Unknown adapter, missing actor, or malformed identity fails closed:
22
+ # unknown identity is an error, never permission.
23
+ class ExecutionIdentityResolver
24
+ ROLES = %w[coordinator service worker].freeze
25
+ TRUSTED_ROLES = %w[coordinator service].freeze
26
+
27
+ # Resolved boundary identity.
28
+ Identity = Struct.new(:actor, :role, :runtime, :adapter, keyword_init: true)
29
+
30
+ # @param adapter [String, nil] "local" or "service" (default from config)
31
+ # @param service_env [String, nil] Env var carrying the service identity (default from config)
32
+ def initialize(adapter: nil, service_env: nil)
33
+ section = Ace::Assign.config["attempt"] || {}
34
+ @adapter = adapter || section["identity_adapter"] || "local"
35
+ @service_env = service_env || section["service_identity_env"] || "ACE_ASSIGN_SERVICE_IDENTITY"
36
+ end
37
+
38
+ # @return [Identity] Trusted boundary identity
39
+ # @raise [AttemptErrors::UnauthorizedIdentity] when identity cannot be established
40
+ def resolve
41
+ case @adapter
42
+ when "local" then from_local_boundary
43
+ when "service" then from_service_env
44
+ else
45
+ raise AttemptErrors::UnauthorizedIdentity, "Unknown identity adapter '#{@adapter}'"
46
+ end
47
+ end
48
+
49
+ # @param identity [Identity] Identity to inspect
50
+ # @return [Boolean] True when the role may accept privileged transitions
51
+ def trusted?(identity)
52
+ TRUSTED_ROLES.include?(identity.role)
53
+ end
54
+
55
+ private
56
+
57
+ def from_local_boundary
58
+ actor = Etc.getlogin || ENV["USER"] || ENV["LOGNAME"]
59
+ if actor.nil? || actor.strip.empty?
60
+ raise AttemptErrors::UnauthorizedIdentity, "No OS login identity available at the execution boundary"
61
+ end
62
+
63
+ Identity.new(
64
+ actor: actor.strip,
65
+ role: "coordinator",
66
+ runtime: "local:#{Socket.gethostname}",
67
+ adapter: "local"
68
+ )
69
+ end
70
+
71
+ def from_service_env
72
+ raw = ENV[@service_env].to_s.strip
73
+ if raw.empty?
74
+ raise AttemptErrors::UnauthorizedIdentity,
75
+ "Service identity env #{@service_env} is empty; the trusted executor must supply it"
76
+ end
77
+
78
+ data = begin
79
+ JSON.parse(raw)
80
+ rescue JSON::ParserError
81
+ raise AttemptErrors::UnauthorizedIdentity, "Service identity env #{@service_env} is not valid JSON"
82
+ end
83
+
84
+ actor = data["actor"].to_s.strip
85
+ role = data["role"].to_s.strip
86
+ runtime = data["runtime"].to_s.strip
87
+ if actor.empty? || runtime.empty? || !ROLES.include?(role)
88
+ raise AttemptErrors::UnauthorizedIdentity,
89
+ "Service identity must supply actor, runtime, and a role in #{ROLES.join('/')}"
90
+ end
91
+
92
+ Identity.new(actor: actor, role: role, runtime: runtime, adapter: "service")
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end