ace-assign 0.63.1 → 0.64.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 +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +43 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +107 -8
|
@@ -4,6 +4,7 @@ require "digest"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "open3"
|
|
6
6
|
require "pathname"
|
|
7
|
+
require "ace/herdr"
|
|
7
8
|
|
|
8
9
|
module Ace
|
|
9
10
|
module Assign
|
|
@@ -45,6 +46,41 @@ module Ace
|
|
|
45
46
|
|
|
46
47
|
attr_reader :store
|
|
47
48
|
|
|
49
|
+
# Read-only exact native reverse binding from accepted history. A PID
|
|
50
|
+
# supplied by a trusted boundary must be kernel-derived; no command
|
|
51
|
+
# flag, environment address or local cache grants this authority.
|
|
52
|
+
# Missing/dead/changed owners remain a classified refusal.
|
|
53
|
+
def runtime_binding(attempt_id:, caller_pid: nil)
|
|
54
|
+
attempt = recover_managed_attempt(nil, attempt_id)
|
|
55
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found in accepted history" unless attempt
|
|
56
|
+
unless attempt.managed? && attempt.active?
|
|
57
|
+
raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
|
|
58
|
+
end
|
|
59
|
+
binding = reconciler.recorded_binding(attempt)
|
|
60
|
+
observation = reconciler.observation(attempt)
|
|
61
|
+
unless binding && observation["liveness"] == "live" &&
|
|
62
|
+
binding["process_identity"] == observation["identity"] &&
|
|
63
|
+
%w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
|
|
64
|
+
raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
|
|
65
|
+
end
|
|
66
|
+
unless caller_pid.nil?
|
|
67
|
+
unless caller_pid.is_a?(Integer) && caller_pid.positive?
|
|
68
|
+
raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
|
|
69
|
+
end
|
|
70
|
+
caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
|
|
71
|
+
unless caller == binding
|
|
72
|
+
raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
|
|
76
|
+
unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
|
|
77
|
+
raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
|
|
78
|
+
end
|
|
79
|
+
binding
|
|
80
|
+
rescue Ace::Runtime::Error => e
|
|
81
|
+
raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
|
|
82
|
+
end
|
|
83
|
+
|
|
48
84
|
# Claim an external service effect before dispatch. Only a managed,
|
|
49
85
|
# active attempt for the exact project and candidate may own it. The
|
|
50
86
|
# guard re-runs inside the journal lock per CAS attempt, so a
|
|
@@ -65,6 +101,10 @@ module Ace
|
|
|
65
101
|
journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
|
|
66
102
|
end
|
|
67
103
|
|
|
104
|
+
def proposal_authorization(reference, binding)
|
|
105
|
+
journal_for.proposal_authorize!(reference, binding)
|
|
106
|
+
end
|
|
107
|
+
|
|
68
108
|
# Rechecked against the authoritative ref for every locked claim
|
|
69
109
|
# attempt: the attempt must still be journal-active and the exact
|
|
70
110
|
# authorization must still be unconsumed.
|
|
@@ -228,6 +268,11 @@ module Ace
|
|
|
228
268
|
# and attested outcome. All checks read one open handle, so a swap
|
|
229
269
|
# on a caller-writable path cannot mix files between checks.
|
|
230
270
|
def verify_service_evidence!(evidence, request, state, require_no_effect: false)
|
|
271
|
+
if @journal&.evidence_mode == :protected
|
|
272
|
+
receipt = request.slice(*SERVICE_BINDING_FIELDS).merge("outcome" => state, "evidence" => evidence)
|
|
273
|
+
@journal.validate_terminal_receipt!(request, require_no_effect ? "failed-settled" : state, receipt)
|
|
274
|
+
return true
|
|
275
|
+
end
|
|
231
276
|
repo_root = File.realpath(@repo_root)
|
|
232
277
|
claimed_at = parse_claimed_at(request)
|
|
233
278
|
evidence.each do |item|
|
|
@@ -391,9 +436,14 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
391
436
|
@store.with_lock(assignment_id) do
|
|
392
437
|
journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
|
|
393
438
|
|
|
394
|
-
existing =
|
|
395
|
-
|
|
439
|
+
existing = journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) } ||
|
|
440
|
+
@store.active(assignment_id, scope)
|
|
396
441
|
if existing
|
|
442
|
+
if existing.state == "reserved"
|
|
443
|
+
raise AttemptErrors::Conflict,
|
|
444
|
+
"Unresolved reservation #{existing.attempt_id} owns #{assignment_id}@#{scope}; " \
|
|
445
|
+
"protected launch reconciliation needs positive no-execution/no-surviving-writer proof before abort"
|
|
446
|
+
end
|
|
397
447
|
if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
|
|
398
448
|
existing.binding.actor == identity.actor && existing.binding.role == identity.role
|
|
399
449
|
return existing
|
|
@@ -456,7 +506,13 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
456
506
|
attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
|
|
457
507
|
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
458
508
|
|
|
459
|
-
ensure_journal_consistent!(attempt) if attempt.managed?
|
|
509
|
+
derived = ensure_journal_consistent!(attempt) if attempt.managed?
|
|
510
|
+
attempt = attempt.with(state: derived.state) if derived
|
|
511
|
+
if attempt.state == "reserved"
|
|
512
|
+
raise AttemptErrors::EvidenceUnavailable,
|
|
513
|
+
"Reservation #{attempt_id} is unbound; protected launch reconciliation requires positive " \
|
|
514
|
+
"no-execution/no-surviving-writer proof before abort"
|
|
515
|
+
end
|
|
460
516
|
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
|
|
461
517
|
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
|
|
462
518
|
|
|
@@ -498,6 +554,199 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
498
554
|
attempt.projection
|
|
499
555
|
end
|
|
500
556
|
|
|
557
|
+
# Reconstruct recovery from accepted history. Local caches and pane
|
|
558
|
+
# titles never override journal authority. This method has no writes.
|
|
559
|
+
def recovery_snapshot(assignment_id)
|
|
560
|
+
assignment = @manager.load(assignment_id)
|
|
561
|
+
managed = assignment&.managed? || journal_for.assignment_ids.include?(assignment_id)
|
|
562
|
+
attempts = managed ? journal_for.derived_attempts(assignment_id) : @store.list(assignment_id)
|
|
563
|
+
raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment || attempts.any?
|
|
564
|
+
|
|
565
|
+
requests = managed ? journal_for.service_requests(assignment_id) : []
|
|
566
|
+
unresolved = requests.reject do |request|
|
|
567
|
+
%w[succeeded failed-settled].include?(request["state"]) ||
|
|
568
|
+
request["state"] == "rejected" && request["consumed"] == false
|
|
569
|
+
end
|
|
570
|
+
queue = assignment && Molecules::QueueScanner.new.scan(assignment.steps_dir, assignment: assignment)
|
|
571
|
+
inbox_records = recovery_inboxes(attempts)
|
|
572
|
+
recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
|
|
573
|
+
observation = reconciler.observation(attempt)
|
|
574
|
+
blocked = unresolved.any? { |request| request["attempt_id"] == attempt.attempt_id }
|
|
575
|
+
inbox_blocked = inbox_records.any? do |record|
|
|
576
|
+
[nil, attempt.attempt_id].include?(record["attempt_id"]) &&
|
|
577
|
+
%w[unknown claimed uncertain delivered].include?(record["state"])
|
|
578
|
+
end
|
|
579
|
+
unstarted = !reconciler.process_started?(attempt)
|
|
580
|
+
decision, reason = if blocked || inbox_blocked || attempt.uncertain?
|
|
581
|
+
["reconcile-required", blocked ? "unresolved external effect" :
|
|
582
|
+
(inbox_blocked ? "inbox outcome requires signed native observation" : "attempt remains uncertain")]
|
|
583
|
+
elsif attempt.terminal?
|
|
584
|
+
["restart-required", "attempt ended; restart requires a new attributable attempt"]
|
|
585
|
+
elsif unstarted
|
|
586
|
+
["reconcile-required", "launch binding unavailable; positive no-execution/no-surviving-writer proof required before abort"]
|
|
587
|
+
elsif !assignment || observation["liveness"] != "live"
|
|
588
|
+
["reconcile-required", !assignment ? "assignment checkpoint unavailable" : observation["reason"]]
|
|
589
|
+
else
|
|
590
|
+
["adopt", "verified owner survives; preserve current attempt"]
|
|
591
|
+
end
|
|
592
|
+
receipt_history = reconciler.events(attempt).select { |event| event["type"] == "receipt_accepted" }
|
|
593
|
+
.filter_map { |event| event.dig("payload", "receipt") }
|
|
594
|
+
accepted_effects = receipt_history.select { |receipt| @verifier.external_effect?(receipt["operation"]) }
|
|
595
|
+
.map { |receipt| receipt.slice("operation", "head", "digest", "verdict") }
|
|
596
|
+
attempt.projection.merge("decision" => decision, "recovery_reason" => reason,
|
|
597
|
+
"accepted_effects" => accepted_effects,
|
|
598
|
+
"accepted_receipt_digests" => receipt_history.map { |receipt| receipt["digest"] },
|
|
599
|
+
"liveness" => observation["liveness"], "last_verified_observation" =>
|
|
600
|
+
(observation["liveness"] == "live" ? observation : last_verified_observation(attempt)),
|
|
601
|
+
"observation" => observation)
|
|
602
|
+
end
|
|
603
|
+
decision = if recovered.empty? || recovered.any? { |item| item["decision"] == "reconcile-required" }
|
|
604
|
+
"reconcile-required"
|
|
605
|
+
elsif recovered.any? { |item| item["decision"] == "adopt" }
|
|
606
|
+
"adopt"
|
|
607
|
+
else
|
|
608
|
+
"restart-required"
|
|
609
|
+
end
|
|
610
|
+
{"assignment_id" => assignment_id, "task_id" => assignment&.task_id,
|
|
611
|
+
"latest_attempt_id" => recovered.last&.dig("attempt_id"),
|
|
612
|
+
"decision" => decision, "liveness" => decision == "adopt" ? "live" : "unknown",
|
|
613
|
+
"last_verified_observation" => recovered.filter_map { |item| item["last_verified_observation"] }
|
|
614
|
+
.max_by { |item| item["observed_at"].to_s },
|
|
615
|
+
"recovery_reason" => recovered.empty? ? "accepted attempt evidence unavailable" :
|
|
616
|
+
recovered.find { |item| item["decision"] == decision }&.dig("recovery_reason"),
|
|
617
|
+
"goal" => assignment&.description || assignment&.name,
|
|
618
|
+
"checkpoint" => queue && {"state" => queue.assignment_state.to_s,
|
|
619
|
+
"active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
|
|
620
|
+
"attempts" => recovered, "inbox_events" => inbox_records,
|
|
621
|
+
"pending_hitl" => queue ? queue.steps.filter_map do |step|
|
|
622
|
+
id = step.stall_reason.to_s[/\AHITL:\s*([a-zA-Z0-9_.-]+)/, 1]
|
|
623
|
+
{"event_id" => id, "scope" => step.number, "rebind" => false} if id
|
|
624
|
+
end : [], "unresolved_effects" => unresolved.map do |request|
|
|
625
|
+
request.slice("request_id", "attempt_id", "operation", "state", "candidate_head")
|
|
626
|
+
end}
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def resume(assignment_id:, dry_run: false)
|
|
630
|
+
return recovery_snapshot(assignment_id) if dry_run
|
|
631
|
+
|
|
632
|
+
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
|
|
633
|
+
@store.with_lock(assignment_id) do
|
|
634
|
+
snapshot = recovery_snapshot(assignment_id)
|
|
635
|
+
snapshot.fetch("attempts").each do |projection|
|
|
636
|
+
attempt = recover_managed_attempt(assignment_id, projection["attempt_id"]) ||
|
|
637
|
+
@store.load(assignment_id, projection["attempt_id"])
|
|
638
|
+
next unless attempt && !attempt.terminal?
|
|
639
|
+
|
|
640
|
+
event = Models::EvidenceEvent.build(type: "recovery_observation", attempt_id: attempt.attempt_id,
|
|
641
|
+
payload: projection.slice("decision", "recovery_reason", "observation"),
|
|
642
|
+
previous_digest: last_event_digest(attempt))
|
|
643
|
+
attempt = append_events(attempt, [event])
|
|
644
|
+
if attempt.state == "running" && projection["decision"] != "adopt"
|
|
645
|
+
target = projection["decision"] == "restart-required" ? "stopped" : "uncertain"
|
|
646
|
+
attempt = append_events(attempt, [transition_event(attempt, target, projection["recovery_reason"])])
|
|
647
|
+
attempt = attempt.transition(target)
|
|
648
|
+
@store.release(assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
|
|
649
|
+
end
|
|
650
|
+
@store.save(attempt)
|
|
651
|
+
end
|
|
652
|
+
recovery_snapshot(assignment_id)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Inbox delivery is transport evidence, never business-effect proof.
|
|
658
|
+
# Herdr owns signature, pinned key, generation and native identity
|
|
659
|
+
# verification; this consumer records only its verified references.
|
|
660
|
+
def reconcile_inbox(attempt_id:, event_id:, receipt_path:, inbox:, identity: nil)
|
|
661
|
+
identity ||= @identity_resolver.resolve
|
|
662
|
+
unless @identity_resolver.trusted?(identity)
|
|
663
|
+
raise AttemptErrors::UnauthorizedIdentity, "Inbox recovery requires a trusted supervisor"
|
|
664
|
+
end
|
|
665
|
+
attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
666
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
667
|
+
|
|
668
|
+
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
|
|
669
|
+
@store.with_lock(attempt.binding.assignment_id) do
|
|
670
|
+
attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
|
|
671
|
+
@store.load(attempt.binding.assignment_id, attempt_id)
|
|
672
|
+
record = inbox.status(event: event_id)
|
|
673
|
+
unless record["attempt_id"] == attempt_id
|
|
674
|
+
raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
|
|
675
|
+
end
|
|
676
|
+
registered = reconciler.events(attempt).find do |event|
|
|
677
|
+
event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
|
|
678
|
+
end
|
|
679
|
+
expected = record.slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
|
|
680
|
+
unless registered && registered["payload"] == expected
|
|
681
|
+
raise AttemptErrors::ReceiptRejected, "Inbox event does not match its registered binding"
|
|
682
|
+
end
|
|
683
|
+
bytes = File.binread(receipt_path)
|
|
684
|
+
receipt = JSON.parse(bytes)
|
|
685
|
+
result = inbox.reconcile(event: event_id, receipt: receipt,
|
|
686
|
+
expected_registration: registered.fetch("payload"),
|
|
687
|
+
signed_bytes: bytes, signature: File.binread("#{receipt_path}.sig"))
|
|
688
|
+
return result if result["reconciliation_refusal"]
|
|
689
|
+
|
|
690
|
+
payload = result.slice("event_id", "attempt_id", "claim_generation", "payload_sha256",
|
|
691
|
+
"receipt_key_sha256", "binding", "state")
|
|
692
|
+
.merge("receipt_sha256" => Digest::SHA256.hexdigest(bytes),
|
|
693
|
+
"receipt_ref" => File.expand_path(receipt_path),
|
|
694
|
+
"outcome" => receipt["outcome"], "observer" => receipt["observer"],
|
|
695
|
+
"evidence" => receipt["evidence"].slice("kind", "native_reference"))
|
|
696
|
+
accepted = reconciler.events(attempt).any? do |event|
|
|
697
|
+
event["type"] == "inbox_reconciliation" &&
|
|
698
|
+
event["payload"].reject { |key, _| %w[receipt_ref receipt_sha256].include?(key) } ==
|
|
699
|
+
payload.reject { |key, _| %w[receipt_ref receipt_sha256].include?(key) }
|
|
700
|
+
end
|
|
701
|
+
unless accepted
|
|
702
|
+
event = Models::EvidenceEvent.build(type: "inbox_reconciliation", attempt_id: attempt_id,
|
|
703
|
+
payload: payload, previous_digest: last_event_digest(attempt))
|
|
704
|
+
@store.save(append_events(attempt, [event]))
|
|
705
|
+
end
|
|
706
|
+
result
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
710
|
+
raise AttemptErrors::ReceiptRejected, "Inbox proof unavailable (#{e.class})"
|
|
711
|
+
rescue Ace::Herdr::Error => e
|
|
712
|
+
raise AttemptErrors::ReceiptRejected, "Inbox proof rejected: #{e.message}"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Register a transport reference in the existing attempt journal so
|
|
716
|
+
# a missing delivery record cannot be mistaken for no pending effect.
|
|
717
|
+
def bind_inbox(attempt_id:, event_id:, inbox:, identity: nil)
|
|
718
|
+
identity ||= @identity_resolver.resolve
|
|
719
|
+
attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
720
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
721
|
+
|
|
722
|
+
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
|
|
723
|
+
@store.with_lock(attempt.binding.assignment_id) do
|
|
724
|
+
attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
|
|
725
|
+
@store.load(attempt.binding.assignment_id, attempt_id)
|
|
726
|
+
unless attempt.active? && attempt.binding.actor == identity.actor
|
|
727
|
+
raise AttemptErrors::UnauthorizedIdentity, "Inbox registration must belong to the active attempt owner"
|
|
728
|
+
end
|
|
729
|
+
record = inbox.status(event: event_id)
|
|
730
|
+
unless record["attempt_id"] == attempt_id
|
|
731
|
+
raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
|
|
732
|
+
end
|
|
733
|
+
payload = record.slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
|
|
734
|
+
existing = reconciler.events(attempt).find do |event|
|
|
735
|
+
event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
|
|
736
|
+
end
|
|
737
|
+
if existing && existing["payload"] != payload
|
|
738
|
+
raise AttemptErrors::ReceiptRejected, "Inbox binding changed"
|
|
739
|
+
end
|
|
740
|
+
unless existing
|
|
741
|
+
event = Models::EvidenceEvent.build(type: "inbox_binding", attempt_id: attempt_id,
|
|
742
|
+
payload: payload, previous_digest: last_event_digest(attempt))
|
|
743
|
+
@store.save(append_events(attempt, [event]))
|
|
744
|
+
end
|
|
745
|
+
payload
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
|
|
501
750
|
# Read accepted check evidence without transitions, locks, cache writes
|
|
502
751
|
# or audit checkout creation. Managed history comes from the Git ref.
|
|
503
752
|
def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
|
|
@@ -579,14 +828,19 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
579
828
|
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
580
829
|
|
|
581
830
|
derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
|
|
582
|
-
# The journal is authoritative
|
|
583
|
-
#
|
|
584
|
-
attempt = attempt.with(state: derived.state) if derived
|
|
831
|
+
# The journal is authoritative, including an unbound reservation
|
|
832
|
+
# that a stale local running projection must not promote.
|
|
833
|
+
attempt = attempt.with(state: derived.state) if derived
|
|
585
834
|
|
|
586
835
|
if attempt.terminal?
|
|
587
836
|
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
|
|
588
837
|
end
|
|
589
838
|
|
|
839
|
+
if attempt.state == "reserved"
|
|
840
|
+
raise AttemptErrors::EvidenceUnavailable,
|
|
841
|
+
"Reservation #{attempt_id} requires protected launch reconciliation with positive no-execution/" \
|
|
842
|
+
"no-surviving-writer proof before abort; missing process_start is not proof"
|
|
843
|
+
end
|
|
590
844
|
return classify_running(attempt) if attempt.state == "running"
|
|
591
845
|
|
|
592
846
|
identity ||= @identity_resolver.resolve
|
|
@@ -597,6 +851,51 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
597
851
|
|
|
598
852
|
private
|
|
599
853
|
|
|
854
|
+
def last_verified_observation(attempt)
|
|
855
|
+
event = reconciler.events(attempt).reverse.find do |candidate|
|
|
856
|
+
candidate["type"] == "recovery_observation" && candidate.dig("payload", "observation", "liveness") == "live"
|
|
857
|
+
end
|
|
858
|
+
event&.dig("payload", "observation")
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def recovery_inboxes(attempts)
|
|
862
|
+
root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
|
|
863
|
+
entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
|
|
864
|
+
registrations = attempts.flat_map do |attempt|
|
|
865
|
+
reconciler.events(attempt).select { |event| event["type"] == "inbox_binding" }
|
|
866
|
+
.map { |event| [attempt, event["payload"]] }
|
|
867
|
+
end
|
|
868
|
+
records = registrations.map do |attempt, payload|
|
|
869
|
+
record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
|
|
870
|
+
attributed = record && record.inbox && record.event_id == payload["event_id"] &&
|
|
871
|
+
record.inbox["attempt_id"] == attempt.attempt_id && payload["attempt_id"] == attempt.attempt_id &&
|
|
872
|
+
record.answer_digest == payload["payload_sha256"] &&
|
|
873
|
+
record.inbox["receipt_key_sha256"] == payload["receipt_key_sha256"]
|
|
874
|
+
verified = attributed && reconciler.events(attempt).any? do |event|
|
|
875
|
+
next false unless event["type"] == "inbox_reconciliation"
|
|
876
|
+
proof = event["payload"]
|
|
877
|
+
proof["event_id"] == record.event_id && proof["claim_generation"] == record.inbox["claim_generation"] &&
|
|
878
|
+
proof["payload_sha256"] == record.answer_digest && proof["binding"] == record.inbox["binding"] &&
|
|
879
|
+
proof["receipt_key_sha256"] == record.inbox["receipt_key_sha256"] && proof["state"] == record.state
|
|
880
|
+
end
|
|
881
|
+
state = if !attributed || ((record.state == "completed" || record.state == "queued" && record.inbox["reconciliation"]) && !verified)
|
|
882
|
+
"unknown"
|
|
883
|
+
else
|
|
884
|
+
record.state
|
|
885
|
+
end
|
|
886
|
+
payload.slice("event_id", "attempt_id", "receipt_key_sha256").merge("state" => state)
|
|
887
|
+
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
888
|
+
payload.slice("event_id", "attempt_id").merge("state" => "unknown")
|
|
889
|
+
end
|
|
890
|
+
entries.each do |entry|
|
|
891
|
+
next if records.any? { |record| record["event_id"] == entry[:event_id] }
|
|
892
|
+
record = entry[:record]
|
|
893
|
+
next if record && (!record.inbox || !attempts.any? { |attempt| attempt.attempt_id == record.inbox["attempt_id"] })
|
|
894
|
+
records << {"event_id" => entry[:event_id], "state" => "unknown"}
|
|
895
|
+
end
|
|
896
|
+
records
|
|
897
|
+
end
|
|
898
|
+
|
|
600
899
|
# Attempt IDs must be unique per assignment AND globally: records,
|
|
601
900
|
# journal events, and reconciliation lookups key on them. Allocation
|
|
602
901
|
# runs under the assignment lock, avoids journal-known IDs, and
|
|
@@ -698,12 +997,6 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
698
997
|
when :live
|
|
699
998
|
raise AttemptErrors::InvalidState,
|
|
700
999
|
"Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
|
|
701
|
-
when :stopped
|
|
702
|
-
attempt = append_events(attempt, [transition_event(attempt, "stopped", "interrupted before process start")])
|
|
703
|
-
attempt = attempt.transition("stopped")
|
|
704
|
-
@store.save(attempt)
|
|
705
|
-
@store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id)
|
|
706
|
-
attempt
|
|
707
1000
|
else
|
|
708
1001
|
attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
|
|
709
1002
|
attempt = attempt.transition("uncertain")
|
|
@@ -809,7 +1102,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
809
1102
|
"actor" => identity.actor,
|
|
810
1103
|
"role" => identity.role,
|
|
811
1104
|
"runtime" => identity.runtime,
|
|
812
|
-
"pid" =>
|
|
1105
|
+
"pid" => identity.process_pid,
|
|
1106
|
+
"process_identity" => Ace::Runtime::Molecules::ProcessIdentity.new.capture(identity.process_pid),
|
|
1107
|
+
"runtime_binding" => identity.runtime_binding
|
|
813
1108
|
},
|
|
814
1109
|
previous_digest: intent["digest"]
|
|
815
1110
|
)
|
|
@@ -833,6 +1128,10 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
833
1128
|
end
|
|
834
1129
|
|
|
835
1130
|
def accept(attempt, receipt, live_head, pre_events = [])
|
|
1131
|
+
# Reject illegal terminal outcomes before accepting any receipt.
|
|
1132
|
+
if %w[succeeded failed].include?(receipt.verdict)
|
|
1133
|
+
Atoms::AttemptStateMachine.transition!(attempt.state, receipt.verdict)
|
|
1134
|
+
end
|
|
836
1135
|
previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
|
|
837
1136
|
event = Models::EvidenceEvent.build(
|
|
838
1137
|
type: "receipt_accepted",
|