ace-assign 0.61.1 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d55939eec104b31f029b0e8a703977c5b06afc67a82ddbc1b6274c9fa471bd21
4
- data.tar.gz: e9f910d2474dbbdee91b75760c25711f32169c0a8e8ea476ef01ec1dd9ac8526
3
+ metadata.gz: 17761502c8b23ff60844d6a3da34ddde53c776d18907a23854ad4168dec3c033
4
+ data.tar.gz: 1538f48c6aec44ac548dcc116bddc0c4928cbc876485513b616604f886f0deb0
5
5
  SHA512:
6
- metadata.gz: 86d8c02efe5e126b34d683eba6c81dc1e49367e2f847c08f3cad560bde57cfebae491745346b3cfd852fccbf067e7581f01c6175068a88e7d41df24b363cab35
7
- data.tar.gz: 27c7e06bfc8e553615416ea1ec36f3bc7d4a818bf7e5c844d16fb130ef6384e8132af30e5271f529679c69714e6b222620b4bb8a8e40f2c20755735df855aa22
6
+ metadata.gz: 7f6b66a87f7a32496f11974ced9cb200c092488f027eb233dde59703ba3dcffa2eaad309d1f57b0b023113eb3f08c58e451b69823a33533d80c5fb45b79110e2
7
+ data.tar.gz: 6d0c8ef8b69d5002b98b60b22018fdc4aecf809818284e80bd11c70020a9733b9ba37140237b30fea470d539de23ce98a658bec902b1a6b9bf4454329ab50ed9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.62.0] - 2026-10-04
11
+
12
+ ### Added
13
+ - **Lock-scoped external attempt authority (spec 8wq.t.34i)**: `AttemptCoordinator#with_verified_attempt` verifies that a managed attempt is live (reserved/running — uncertainty never grants external authority), bound to the exact assignment/project, and owned by the requesting actor, holding the assignment `LifecycleExclusion` shared side across the caller's whole locked transition. `finish` and `reconcile` hold the exclusive side across their terminal commits, so a liveness check can never observe an attempt as active after the terminal event landed (and vice versa). This is the authority behind ace-hitl's managed HITL request binding.
14
+
15
+
10
16
  ## [0.61.1] - 2026-10-02
11
17
 
12
18
  ### Fixed
@@ -104,6 +104,29 @@ module Ace
104
104
  end
105
105
  end
106
106
 
107
+ # The exact-authority check behind with_verified_attempt: the
108
+ # journal-derived managed attempt must be live (reserved/running —
109
+ # uncertainty is unproven liveness and never grants external
110
+ # authority) and bound to the exact assignment, project, and
111
+ # requesting actor.
112
+ def verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
113
+ attempt = journal_for.derived_attempts(assignment_id)
114
+ .find { |candidate| candidate.attempt_id == attempt_id }
115
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
116
+
117
+ unless attempt.managed? && %w[reserved running].include?(attempt.state) &&
118
+ attempt.binding.assignment_id == assignment_id &&
119
+ attempt.binding.project_id == project_id
120
+ raise AttemptErrors::ReceiptRejected,
121
+ "Authority request does not match an active managed attempt"
122
+ end
123
+ unless attempt.binding.actor == requester
124
+ raise AttemptErrors::UnauthorizedIdentity,
125
+ "Requester #{requester.inspect} does not own attempt '#{attempt_id}'"
126
+ end
127
+ attempt
128
+ end
129
+
107
130
  def reject_service_request(binding, reason:)
108
131
  service_attempt(binding)
109
132
  journal_for.reject_service_request(binding, reason: reason)
@@ -299,6 +322,36 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
299
322
  true
300
323
  end
301
324
 
325
+ # Hold verified authority over one managed attempt for the duration
326
+ # of a single external transition (the HITL consume/deliver
327
+ # critical sections). The shared exclusion side is held across the
328
+ # yielded transition, so a terminal commit (finish/reconcile hold
329
+ # the exclusive side) or a prune cannot land between the liveness
330
+ # check and the caller's commit: stale, ended or replaced attempts
331
+ # cannot acquire new authority. Authority is attribution over
332
+ # journal facts and the requesting identity — never a process id,
333
+ # runtime marker, or caller-supplied name.
334
+ #
335
+ # @param assignment_id [String] Owning assignment ID
336
+ # @param attempt_id [String] Attempt ID
337
+ # @param project_id [String] Project the attempt must be bound to
338
+ # @param requester [String] Authenticated requester (execution-
339
+ # boundary identity of the caller, matched against binding actor)
340
+ # @yield [attempt] the verified managed attempt
341
+ # @raise [AttemptErrors::NotFound, AttemptErrors::ReceiptRejected,
342
+ # AttemptErrors::UnauthorizedIdentity] on any authority doubt
343
+ def with_verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
344
+ assignment_id = assignment_id.to_s
345
+ attempt_id = attempt_id.to_s
346
+ lifecycle_exclusion.with_shared(lifecycle_exclusion.assignment_key(assignment_id)) do
347
+ attempt = verified_attempt(
348
+ assignment_id: assignment_id, attempt_id: attempt_id,
349
+ project_id: project_id.to_s, requester: requester.to_s
350
+ )
351
+ yield attempt
352
+ end
353
+ end
354
+
302
355
  # Start a scoped attempt for an assignment.
303
356
  #
304
357
  # A repeated identical start returns the existing active attempt; a
@@ -392,38 +445,44 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
392
445
  data = read_receipt_file(receipt_path)
393
446
  assignment_id = data["assignment_id"].to_s
394
447
 
395
- # Serialize load-validate-accept so concurrent finishes observe one
396
- # consistent state and cannot persist contradictory terminal outcomes.
397
- @store.with_lock(assignment_id) do
398
- attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
399
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
400
-
401
- ensure_journal_consistent!(attempt) if attempt.managed?
402
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
403
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
404
-
405
- identity ||= @identity_resolver.resolve
406
- live_head = candidate_head!
407
-
408
- attempt = invalidate_stale_candidate(attempt, live_head)
448
+ # Terminal commits hold the exclusive exclusion so an external
449
+ # liveness reader (with_verified_attempt holds the shared side
450
+ # across its whole transition) can never observe the attempt as
451
+ # active after the terminal event landed.
452
+ lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
453
+ # Serialize load-validate-accept so concurrent finishes observe one
454
+ # consistent state and cannot persist contradictory terminal outcomes.
455
+ @store.with_lock(assignment_id) do
456
+ attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
457
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
458
+
459
+ ensure_journal_consistent!(attempt) if attempt.managed?
460
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
461
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
462
+
463
+ identity ||= @identity_resolver.resolve
464
+ live_head = candidate_head!
465
+
466
+ attempt = invalidate_stale_candidate(attempt, live_head)
467
+
468
+ receipt = @verifier.verify!(
469
+ data,
470
+ attempt: attempt,
471
+ identity: identity,
472
+ live_head: live_head,
473
+ repo_root: @repo_root
474
+ )
475
+ if @verifier.external_effect?(receipt.operation)
476
+ unless attempt.managed?
477
+ raise AttemptErrors::InvalidState,
478
+ "Taskless attempts cannot record external effects without managed evidence"
479
+ end
409
480
 
410
- receipt = @verifier.verify!(
411
- data,
412
- attempt: attempt,
413
- identity: identity,
414
- live_head: live_head,
415
- repo_root: @repo_root
416
- )
417
- if @verifier.external_effect?(receipt.operation)
418
- unless attempt.managed?
419
- raise AttemptErrors::InvalidState,
420
- "Taskless attempts cannot record external effects without managed evidence"
481
+ require_review_evidence(attempt, live_head)
421
482
  end
422
483
 
423
- require_review_evidence(attempt, live_head)
484
+ accept(attempt, receipt, live_head)
424
485
  end
425
-
426
- accept(attempt, receipt, live_head)
427
486
  end
428
487
  end
429
488
 
@@ -512,24 +571,27 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
512
571
  probe = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
513
572
  raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless probe
514
573
 
515
- @store.with_lock(probe.binding.assignment_id) do
516
- attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
517
- recover_managed_attempt(probe.binding.assignment_id, attempt_id)
518
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
574
+ # Terminal commits hold the exclusive exclusion (see finish).
575
+ lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(probe.binding.assignment_id)) do
576
+ @store.with_lock(probe.binding.assignment_id) do
577
+ attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
578
+ recover_managed_attempt(probe.binding.assignment_id, attempt_id)
579
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
519
580
 
520
- derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
521
- # The journal is authoritative for managed attempts: uncertainty
522
- # journaled after the local save drives reconciliation.
523
- attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
581
+ derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
582
+ # The journal is authoritative for managed attempts: uncertainty
583
+ # journaled after the local save drives reconciliation.
584
+ attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
524
585
 
525
- if attempt.terminal?
526
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
527
- end
586
+ if attempt.terminal?
587
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
588
+ end
528
589
 
529
- return classify_running(attempt) if attempt.state == "running"
590
+ return classify_running(attempt) if attempt.state == "running"
530
591
 
531
- identity ||= @identity_resolver.resolve
532
- resolve_uncertain(attempt, receipt_path, identity)
592
+ identity ||= @identity_resolver.resolve
593
+ resolve_uncertain(attempt, receipt_path, identity)
594
+ end
533
595
  end
534
596
  end
535
597
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.61.1"
5
+ VERSION = "0.62.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-assign
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.61.1
4
+ version: 0.62.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-03 00:00:00.000000000 Z
10
+ date: 2026-10-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli