ace-assign 0.63.1 → 0.66.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/auto-merge.step.yml +11 -0
- 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 +62 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +282 -0
- data/docs/usage.md +205 -3
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +257 -0
- data/lib/ace/assign/authority/deployment.rb +627 -0
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +455 -0
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +398 -0
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +598 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +393 -0
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +59 -0
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +76 -0
- data/lib/ace/assign/authority/server.rb +300 -0
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +286 -0
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +187 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +11 -1
- data/lib/ace/assign/cli/commands/delivery.rb +107 -0
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +38 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +56 -4
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +4 -2
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +4 -102
- metadata +186 -8
|
@@ -4,6 +4,10 @@ require "digest"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "open3"
|
|
6
6
|
require "pathname"
|
|
7
|
+
require "ace/herdr/errors"
|
|
8
|
+
require "ace/herdr/models/delivery_record"
|
|
9
|
+
require "ace/herdr/molecules/delivery_record_store"
|
|
10
|
+
require_relative "../molecules/canonical_attempt_state"
|
|
7
11
|
|
|
8
12
|
module Ace
|
|
9
13
|
module Assign
|
|
@@ -45,6 +49,94 @@ module Ace
|
|
|
45
49
|
|
|
46
50
|
attr_reader :store
|
|
47
51
|
|
|
52
|
+
# Pure producer plan. The protected lifecycle authenticates the original
|
|
53
|
+
# proof and both exhaustive owner projections anew at each CAS prefix.
|
|
54
|
+
# No caller receipt, cache state, native wait or store mutation enters it.
|
|
55
|
+
def self.stopped_transition_plan(events:, selection:, service_evidence:, commit:)
|
|
56
|
+
Atoms::AttemptStateMachine.proof_stopped_transition!(Molecules::CanonicalAttemptState.derive(events))
|
|
57
|
+
payload = Molecules::CanonicalAttemptState.stopped_payload!(events: events, selection: selection,
|
|
58
|
+
service_evidence: service_evidence, commit: commit)
|
|
59
|
+
{events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The protected result owner authenticates imported bytes, candidate,
|
|
63
|
+
# closure and independent settlements at the same CAS prefix.
|
|
64
|
+
def self.finished_transition_plan(events:, receipt:)
|
|
65
|
+
state = Molecules::CanonicalAttemptState.derive(events)
|
|
66
|
+
unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
|
|
67
|
+
raise AttemptErrors::Conflict, "Result finish requires a running attempt"
|
|
68
|
+
end
|
|
69
|
+
Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
|
|
70
|
+
{events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
|
|
71
|
+
{type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"}}], blobs: {}}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
|
|
75
|
+
return ["restart-required", "terminal_attempt"] if %w[succeeded failed stopped].include?(state)
|
|
76
|
+
reason = if effects_pending
|
|
77
|
+
"unresolved_effect"
|
|
78
|
+
elsif state == "uncertain"
|
|
79
|
+
"attempt_uncertain"
|
|
80
|
+
elsif !bound
|
|
81
|
+
"launch_unbound"
|
|
82
|
+
elsif !checkpoint
|
|
83
|
+
"checkpoint_unavailable"
|
|
84
|
+
elsif !live
|
|
85
|
+
"scope_unverifiable"
|
|
86
|
+
end
|
|
87
|
+
reason ? ["reconcile-required", reason] : ["adopt", "live_owner"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.protected_recovery_plan(events:, decision:, reason:, observation:)
|
|
91
|
+
state = Molecules::CanonicalAttemptState.derive(events)
|
|
92
|
+
if %w[succeeded failed stopped].include?(state)
|
|
93
|
+
raise AttemptErrors::Conflict, "Terminal recovery is read-only"
|
|
94
|
+
end
|
|
95
|
+
planned = [{type: "recovery_observation", payload: {"decision" => decision,
|
|
96
|
+
"recovery_reason" => reason, "observation" => observation}}]
|
|
97
|
+
if state == "running" && decision != "adopt"
|
|
98
|
+
Atoms::AttemptStateMachine.transition!(state, "uncertain")
|
|
99
|
+
planned << {type: "transition", payload: {"from" => state, "to" => "uncertain", "reason" => reason}}
|
|
100
|
+
state = "uncertain"
|
|
101
|
+
end
|
|
102
|
+
{events: planned, blobs: {}, state: state}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Read-only exact native reverse binding from accepted history. A PID
|
|
106
|
+
# supplied by a trusted boundary must be kernel-derived; no command
|
|
107
|
+
# flag, environment address or local cache grants this authority.
|
|
108
|
+
# Missing/dead/changed owners remain a classified refusal.
|
|
109
|
+
def runtime_binding(attempt_id:, caller_pid: nil)
|
|
110
|
+
attempt = recover_managed_attempt(nil, attempt_id)
|
|
111
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found in accepted history" unless attempt
|
|
112
|
+
unless attempt.managed? && attempt.active?
|
|
113
|
+
raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
|
|
114
|
+
end
|
|
115
|
+
binding = reconciler.recorded_binding(attempt)
|
|
116
|
+
observation = reconciler.observation(attempt)
|
|
117
|
+
unless binding && observation["liveness"] == "live" &&
|
|
118
|
+
binding["process_identity"] == observation["identity"] &&
|
|
119
|
+
%w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
|
|
120
|
+
raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
|
|
121
|
+
end
|
|
122
|
+
unless caller_pid.nil?
|
|
123
|
+
unless caller_pid.is_a?(Integer) && caller_pid.positive?
|
|
124
|
+
raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
|
|
125
|
+
end
|
|
126
|
+
caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
|
|
127
|
+
unless caller == binding
|
|
128
|
+
raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
|
|
132
|
+
unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
|
|
133
|
+
raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
|
|
134
|
+
end
|
|
135
|
+
binding
|
|
136
|
+
rescue Ace::Runtime::Error => e
|
|
137
|
+
raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
|
|
138
|
+
end
|
|
139
|
+
|
|
48
140
|
# Claim an external service effect before dispatch. Only a managed,
|
|
49
141
|
# active attempt for the exact project and candidate may own it. The
|
|
50
142
|
# guard re-runs inside the journal lock per CAS attempt, so a
|
|
@@ -65,6 +157,10 @@ module Ace
|
|
|
65
157
|
journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
|
|
66
158
|
end
|
|
67
159
|
|
|
160
|
+
def proposal_authorization(reference, binding)
|
|
161
|
+
journal_for.proposal_authorize!(reference, binding)
|
|
162
|
+
end
|
|
163
|
+
|
|
68
164
|
# Rechecked against the authoritative ref for every locked claim
|
|
69
165
|
# attempt: the attempt must still be journal-active and the exact
|
|
70
166
|
# authorization must still be unconsumed.
|
|
@@ -228,6 +324,11 @@ module Ace
|
|
|
228
324
|
# and attested outcome. All checks read one open handle, so a swap
|
|
229
325
|
# on a caller-writable path cannot mix files between checks.
|
|
230
326
|
def verify_service_evidence!(evidence, request, state, require_no_effect: false)
|
|
327
|
+
if @journal&.evidence_mode == :protected
|
|
328
|
+
receipt = request.slice(*SERVICE_BINDING_FIELDS).merge("outcome" => state, "evidence" => evidence)
|
|
329
|
+
@journal.validate_terminal_receipt!(request, require_no_effect ? "failed-settled" : state, receipt)
|
|
330
|
+
return true
|
|
331
|
+
end
|
|
231
332
|
repo_root = File.realpath(@repo_root)
|
|
232
333
|
claimed_at = parse_claimed_at(request)
|
|
233
334
|
evidence.each do |item|
|
|
@@ -391,9 +492,14 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
391
492
|
@store.with_lock(assignment_id) do
|
|
392
493
|
journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
|
|
393
494
|
|
|
394
|
-
existing =
|
|
395
|
-
|
|
495
|
+
existing = journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) } ||
|
|
496
|
+
@store.active(assignment_id, scope)
|
|
396
497
|
if existing
|
|
498
|
+
if existing.state == "reserved"
|
|
499
|
+
raise AttemptErrors::Conflict,
|
|
500
|
+
"Unresolved reservation #{existing.attempt_id} owns #{assignment_id}@#{scope}; " \
|
|
501
|
+
"protected launch reconciliation needs positive no-execution/no-surviving-writer proof before abort"
|
|
502
|
+
end
|
|
397
503
|
if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
|
|
398
504
|
existing.binding.actor == identity.actor && existing.binding.role == identity.role
|
|
399
505
|
return existing
|
|
@@ -456,7 +562,13 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
456
562
|
attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
|
|
457
563
|
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
458
564
|
|
|
459
|
-
ensure_journal_consistent!(attempt) if attempt.managed?
|
|
565
|
+
derived = ensure_journal_consistent!(attempt) if attempt.managed?
|
|
566
|
+
attempt = attempt.with(state: derived.state) if derived
|
|
567
|
+
if attempt.state == "reserved"
|
|
568
|
+
raise AttemptErrors::EvidenceUnavailable,
|
|
569
|
+
"Reservation #{attempt_id} is unbound; protected launch reconciliation requires positive " \
|
|
570
|
+
"no-execution/no-surviving-writer proof before abort"
|
|
571
|
+
end
|
|
460
572
|
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
|
|
461
573
|
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
|
|
462
574
|
|
|
@@ -498,6 +610,142 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
498
610
|
attempt.projection
|
|
499
611
|
end
|
|
500
612
|
|
|
613
|
+
# Reconstruct recovery from accepted history. Local caches and pane
|
|
614
|
+
# titles never override journal authority. This method has no writes.
|
|
615
|
+
def recovery_snapshot(assignment_id)
|
|
616
|
+
assignment = @manager.load(assignment_id)
|
|
617
|
+
managed = assignment&.managed? || journal_for.assignment_ids.include?(assignment_id)
|
|
618
|
+
attempts = managed ? journal_for.derived_attempts(assignment_id) : @store.list(assignment_id)
|
|
619
|
+
raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment || attempts.any?
|
|
620
|
+
|
|
621
|
+
requests = managed ? journal_for.service_requests(assignment_id) : []
|
|
622
|
+
unresolved = requests.reject do |request|
|
|
623
|
+
%w[succeeded failed-settled].include?(request["state"]) ||
|
|
624
|
+
request["state"] == "rejected" && request["consumed"] == false
|
|
625
|
+
end
|
|
626
|
+
queue = assignment && Molecules::QueueScanner.new.scan(assignment.steps_dir, assignment: assignment)
|
|
627
|
+
inbox_records = recovery_inboxes(attempts)
|
|
628
|
+
recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
|
|
629
|
+
observation = reconciler.observation(attempt)
|
|
630
|
+
blocked = unresolved.any? { |request| request["attempt_id"] == attempt.attempt_id }
|
|
631
|
+
unstarted = !reconciler.process_started?(attempt)
|
|
632
|
+
decision, reason = if blocked || attempt.uncertain?
|
|
633
|
+
["reconcile-required", blocked ? "unresolved external effect" :
|
|
634
|
+
"attempt remains uncertain"]
|
|
635
|
+
elsif attempt.terminal?
|
|
636
|
+
["restart-required", "attempt ended; restart requires a new attributable attempt"]
|
|
637
|
+
elsif unstarted
|
|
638
|
+
["reconcile-required", "launch binding unavailable; positive no-execution/no-surviving-writer proof required before abort"]
|
|
639
|
+
elsif !assignment || observation["liveness"] != "live"
|
|
640
|
+
["reconcile-required", !assignment ? "assignment checkpoint unavailable" : observation["reason"]]
|
|
641
|
+
else
|
|
642
|
+
["adopt", "verified owner survives; preserve current attempt"]
|
|
643
|
+
end
|
|
644
|
+
receipt_history = reconciler.events(attempt).select { |event| event["type"] == "receipt_accepted" }
|
|
645
|
+
.filter_map { |event| event.dig("payload", "receipt") }
|
|
646
|
+
accepted_effects = receipt_history.select { |receipt| @verifier.external_effect?(receipt["operation"]) }
|
|
647
|
+
.map { |receipt| receipt.slice("operation", "head", "digest", "verdict") }
|
|
648
|
+
attempt.projection.merge("decision" => decision, "recovery_reason" => reason,
|
|
649
|
+
"accepted_effects" => accepted_effects,
|
|
650
|
+
"accepted_receipt_digests" => receipt_history.map { |receipt| receipt["digest"] },
|
|
651
|
+
"liveness" => observation["liveness"], "last_verified_observation" =>
|
|
652
|
+
(observation["liveness"] == "live" ? observation : last_verified_observation(attempt)),
|
|
653
|
+
"observation" => observation)
|
|
654
|
+
end
|
|
655
|
+
decision = if recovered.empty? || recovered.any? { |item| item["decision"] == "reconcile-required" }
|
|
656
|
+
"reconcile-required"
|
|
657
|
+
elsif recovered.any? { |item| item["decision"] == "adopt" }
|
|
658
|
+
"adopt"
|
|
659
|
+
else
|
|
660
|
+
"restart-required"
|
|
661
|
+
end
|
|
662
|
+
{"assignment_id" => assignment_id, "task_id" => assignment&.task_id,
|
|
663
|
+
"latest_attempt_id" => recovered.last&.dig("attempt_id"),
|
|
664
|
+
"decision" => decision, "liveness" => decision == "adopt" ? "live" : "unknown",
|
|
665
|
+
"last_verified_observation" => recovered.filter_map { |item| item["last_verified_observation"] }
|
|
666
|
+
.max_by { |item| item["observed_at"].to_s },
|
|
667
|
+
"recovery_reason" => recovered.empty? ? "accepted attempt evidence unavailable" :
|
|
668
|
+
recovered.find { |item| item["decision"] == decision }&.dig("recovery_reason"),
|
|
669
|
+
"goal" => assignment&.description || assignment&.name,
|
|
670
|
+
"checkpoint" => queue && {"state" => queue.assignment_state.to_s,
|
|
671
|
+
"active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
|
|
672
|
+
"attempts" => recovered, "inbox_events" => inbox_records,
|
|
673
|
+
"pending_hitl" => queue ? queue.steps.filter_map do |step|
|
|
674
|
+
id = step.stall_reason.to_s[/\AHITL:\s*([a-zA-Z0-9_.-]+)/, 1]
|
|
675
|
+
{"event_id" => id, "scope" => step.number, "rebind" => false} if id
|
|
676
|
+
end : [], "unresolved_effects" => unresolved.map do |request|
|
|
677
|
+
request.slice("request_id", "attempt_id", "operation", "state", "candidate_head")
|
|
678
|
+
end}
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def resume(assignment_id:, dry_run: false)
|
|
682
|
+
return recovery_snapshot(assignment_id) if dry_run
|
|
683
|
+
|
|
684
|
+
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
|
|
685
|
+
@store.with_lock(assignment_id) do
|
|
686
|
+
snapshot = recovery_snapshot(assignment_id)
|
|
687
|
+
snapshot.fetch("attempts").each do |projection|
|
|
688
|
+
attempt = recover_managed_attempt(assignment_id, projection["attempt_id"]) ||
|
|
689
|
+
@store.load(assignment_id, projection["attempt_id"])
|
|
690
|
+
next unless attempt && !attempt.terminal?
|
|
691
|
+
|
|
692
|
+
event = Models::EvidenceEvent.build(type: "recovery_observation", attempt_id: attempt.attempt_id,
|
|
693
|
+
payload: projection.slice("decision", "recovery_reason", "observation"),
|
|
694
|
+
previous_digest: last_event_digest(attempt))
|
|
695
|
+
attempt = append_events(attempt, [event])
|
|
696
|
+
if attempt.state == "running" && projection["decision"] != "adopt"
|
|
697
|
+
target = projection["decision"] == "restart-required" ? "stopped" : "uncertain"
|
|
698
|
+
attempt = append_events(attempt, [transition_event(attempt, target, projection["recovery_reason"])])
|
|
699
|
+
attempt = attempt.transition(target)
|
|
700
|
+
@store.release(assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
|
|
701
|
+
end
|
|
702
|
+
@store.save(attempt)
|
|
703
|
+
end
|
|
704
|
+
recovery_snapshot(assignment_id)
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# Inbox delivery is transport evidence, never business-effect proof.
|
|
710
|
+
# Herdr owns signature, pinned key, generation and native identity
|
|
711
|
+
# verification; this consumer records only its verified references.
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# Register a transport reference in the existing attempt journal so
|
|
715
|
+
# a missing delivery record cannot be mistaken for no pending effect.
|
|
716
|
+
def bind_inbox(attempt_id:, event_id:, inbox:, identity: nil)
|
|
717
|
+
identity ||= @identity_resolver.resolve
|
|
718
|
+
attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
719
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
720
|
+
|
|
721
|
+
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
|
|
722
|
+
@store.with_lock(attempt.binding.assignment_id) do
|
|
723
|
+
attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
|
|
724
|
+
@store.load(attempt.binding.assignment_id, attempt_id)
|
|
725
|
+
unless attempt.active? && attempt.binding.actor == identity.actor
|
|
726
|
+
raise AttemptErrors::UnauthorizedIdentity, "Inbox registration must belong to the active attempt owner"
|
|
727
|
+
end
|
|
728
|
+
record = inbox.status(event: event_id)
|
|
729
|
+
unless record["attempt_id"] == attempt_id
|
|
730
|
+
raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
|
|
731
|
+
end
|
|
732
|
+
payload = record.slice("event_id", "attempt_id", "payload_sha256")
|
|
733
|
+
existing = reconciler.events(attempt).find do |event|
|
|
734
|
+
event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
|
|
735
|
+
end
|
|
736
|
+
if existing && existing["payload"] != payload
|
|
737
|
+
raise AttemptErrors::ReceiptRejected, "Inbox binding changed"
|
|
738
|
+
end
|
|
739
|
+
unless existing
|
|
740
|
+
event = Models::EvidenceEvent.build(type: "inbox_binding", attempt_id: attempt_id,
|
|
741
|
+
payload: payload, previous_digest: last_event_digest(attempt))
|
|
742
|
+
@store.save(append_events(attempt, [event]))
|
|
743
|
+
end
|
|
744
|
+
payload
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
501
749
|
# Read accepted check evidence without transitions, locks, cache writes
|
|
502
750
|
# or audit checkout creation. Managed history comes from the Git ref.
|
|
503
751
|
def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
|
|
@@ -579,14 +827,19 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
579
827
|
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
580
828
|
|
|
581
829
|
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
|
|
830
|
+
# The journal is authoritative, including an unbound reservation
|
|
831
|
+
# that a stale local running projection must not promote.
|
|
832
|
+
attempt = attempt.with(state: derived.state) if derived
|
|
585
833
|
|
|
586
834
|
if attempt.terminal?
|
|
587
835
|
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
|
|
588
836
|
end
|
|
589
837
|
|
|
838
|
+
if attempt.state == "reserved"
|
|
839
|
+
raise AttemptErrors::EvidenceUnavailable,
|
|
840
|
+
"Reservation #{attempt_id} requires protected launch reconciliation with positive no-execution/" \
|
|
841
|
+
"no-surviving-writer proof before abort; missing process_start is not proof"
|
|
842
|
+
end
|
|
590
843
|
return classify_running(attempt) if attempt.state == "running"
|
|
591
844
|
|
|
592
845
|
identity ||= @identity_resolver.resolve
|
|
@@ -597,6 +850,42 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
597
850
|
|
|
598
851
|
private
|
|
599
852
|
|
|
853
|
+
def last_verified_observation(attempt)
|
|
854
|
+
event = reconciler.events(attempt).reverse.find do |candidate|
|
|
855
|
+
candidate["type"] == "recovery_observation" && candidate.dig("payload", "observation", "liveness") == "live"
|
|
856
|
+
end
|
|
857
|
+
event&.dig("payload", "observation")
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def recovery_inboxes(attempts)
|
|
861
|
+
# Ordinary local recovery selects Herdr's configuration only here;
|
|
862
|
+
# protected launch admission must not initialize that broad entry.
|
|
863
|
+
require "ace/herdr"
|
|
864
|
+
root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
|
|
865
|
+
entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
|
|
866
|
+
registrations = attempts.flat_map do |attempt|
|
|
867
|
+
reconciler.events(attempt).select { |event| event["type"] == "inbox_binding" }
|
|
868
|
+
.map { |event| [attempt, event["payload"]] }
|
|
869
|
+
end
|
|
870
|
+
records = registrations.map do |attempt, payload|
|
|
871
|
+
record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
|
|
872
|
+
attributed = record && record.inbox && record.event_id == payload["event_id"] &&
|
|
873
|
+
record.inbox["attempt_id"] == attempt.attempt_id && payload["attempt_id"] == attempt.attempt_id &&
|
|
874
|
+
record.answer_digest == payload["payload_sha256"]
|
|
875
|
+
state = attributed ? record.state : "unknown"
|
|
876
|
+
payload.slice("event_id", "attempt_id").merge("state" => state)
|
|
877
|
+
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
878
|
+
payload.slice("event_id", "attempt_id").merge("state" => "unknown")
|
|
879
|
+
end
|
|
880
|
+
entries.each do |entry|
|
|
881
|
+
next if records.any? { |record| record["event_id"] == entry[:event_id] }
|
|
882
|
+
record = entry[:record]
|
|
883
|
+
next if record && (!record.inbox || !attempts.any? { |attempt| attempt.attempt_id == record.inbox["attempt_id"] })
|
|
884
|
+
records << {"event_id" => entry[:event_id], "state" => "unknown"}
|
|
885
|
+
end
|
|
886
|
+
records
|
|
887
|
+
end
|
|
888
|
+
|
|
600
889
|
# Attempt IDs must be unique per assignment AND globally: records,
|
|
601
890
|
# journal events, and reconciliation lookups key on them. Allocation
|
|
602
891
|
# runs under the assignment lock, avoids journal-known IDs, and
|
|
@@ -698,12 +987,6 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
698
987
|
when :live
|
|
699
988
|
raise AttemptErrors::InvalidState,
|
|
700
989
|
"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
990
|
else
|
|
708
991
|
attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
|
|
709
992
|
attempt = attempt.transition("uncertain")
|
|
@@ -809,7 +1092,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
809
1092
|
"actor" => identity.actor,
|
|
810
1093
|
"role" => identity.role,
|
|
811
1094
|
"runtime" => identity.runtime,
|
|
812
|
-
"pid" =>
|
|
1095
|
+
"pid" => identity.process_pid,
|
|
1096
|
+
"process_identity" => Ace::Runtime::Molecules::ProcessIdentity.new.capture(identity.process_pid),
|
|
1097
|
+
"runtime_binding" => identity.runtime_binding
|
|
813
1098
|
},
|
|
814
1099
|
previous_digest: intent["digest"]
|
|
815
1100
|
)
|
|
@@ -833,6 +1118,10 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
833
1118
|
end
|
|
834
1119
|
|
|
835
1120
|
def accept(attempt, receipt, live_head, pre_events = [])
|
|
1121
|
+
# Reject illegal terminal outcomes before accepting any receipt.
|
|
1122
|
+
if %w[succeeded failed].include?(receipt.verdict)
|
|
1123
|
+
Atoms::AttemptStateMachine.transition!(attempt.state, receipt.verdict)
|
|
1124
|
+
end
|
|
836
1125
|
previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
|
|
837
1126
|
event = Models::EvidenceEvent.build(
|
|
838
1127
|
type: "receipt_accepted",
|