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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -5,6 +5,8 @@ require "fileutils"
5
5
  require "json"
6
6
  require "open3"
7
7
  require "time"
8
+ require "securerandom"
9
+ require_relative "../atoms/evidence_digest"
8
10
 
9
11
  module Ace
10
12
  module Assign
@@ -25,6 +27,95 @@ module Ace
25
27
  # present. Legitimate fresh provisioning (a new worktree for a task)
26
28
  # clears the marker explicitly via `reset_removed`.
27
29
  class LifecycleExclusion
30
+ autoload :ControlExclusion, File.expand_path("protected_control_exclusion", __dir__)
31
+
32
+ def self.control_exclusion(authority:, project_id:, descriptor_sha256:, root_identity: nil, **boundaries)
33
+ ControlExclusion.new(authority: authority, project_id: project_id,
34
+ descriptor_sha256: descriptor_sha256, root_identity: root_identity, **boundaries)
35
+ end
36
+
37
+ autoload :WorkspaceNativeReader, File.expand_path("protected_workspace_exclusion", __dir__)
38
+ autoload :WorkspaceReader, File.expand_path("protected_workspace_exclusion", __dir__)
39
+
40
+ autoload :WorkspaceWriter, File.expand_path("protected_workspace_exclusion", __dir__)
41
+
42
+ autoload :WorkspaceFenceReader, File.expand_path("protected_workspace_exclusion", __dir__)
43
+
44
+ def self.workspace_fence_reader(projection:, protection: nil, files: File)
45
+ WorkspaceFenceReader.new(projection: projection, protection: protection, files: files)
46
+ end
47
+
48
+ def self.workspace_writer(projection:, protection: nil, files: File)
49
+ WorkspaceWriter.new(projection: projection, protection: protection, files: files)
50
+ end
51
+
52
+ autoload :WorkspaceProvisioner, File.expand_path("protected_workspace_provisioner", __dir__)
53
+
54
+ def self.provision_workspace!(mapping_id:, project_id:, authority:, cwd_resource:, **boundaries)
55
+ WorkspaceProvisioner.new(mapping_id: mapping_id, project_id: project_id,
56
+ authority: authority, cwd_resource: cwd_resource, **boundaries).provision!
57
+ end
58
+
59
+ def self.workspace_reader(projection:, protection: nil, files: File)
60
+ WorkspaceReader.new(projection: projection, protection: protection, files: files)
61
+ end
62
+
63
+ def self.workspace_selection(mapping_id:, project_id:, authority:, cwd_resource:)
64
+ fields = %w[device filesystem_type gid host_path inode mount_id uid view_path]
65
+ unless authority.is_a?(Hash) && cwd_resource.is_a?(Hash) && cwd_resource.keys.sort == fields &&
66
+ %w[device gid inode uid].all? { |field| cwd_resource[field].is_a?(Integer) && cwd_resource[field] >= 0 } &&
67
+ cwd_resource["mount_id"].is_a?(Integer) && cwd_resource["mount_id"].positive? &&
68
+ %w[host_path view_path].all? { |field| cwd_resource[field].is_a?(String) &&
69
+ cwd_resource[field].valid_encoding? && cwd_resource[field].bytesize.between?(1, 4096) &&
70
+ !cwd_resource[field].include?("\0") && cwd_resource[field].start_with?("/") &&
71
+ File.expand_path(cwd_resource[field]) == cwd_resource[field] } &&
72
+ %w[ext4 xfs btrfs tmpfs].include?(cwd_resource["filesystem_type"]) &&
73
+ [mapping_id, project_id].all? { |value| value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) } &&
74
+ %w[uid gid].all? { |field| authority[field].is_a?(Integer) && authority[field].positive? } &&
75
+ authority["state_root"].is_a?(String) && authority["state_root"].valid_encoding? &&
76
+ authority["state_root"].bytesize.between?(1, 4096) && !authority["state_root"].include?("\0") &&
77
+ authority["state_root"].start_with?("/") &&
78
+ File.expand_path(authority["state_root"]) == authority["state_root"]
79
+ raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace selection is malformed"
80
+ end
81
+ digest = Atoms::EvidenceDigest.digest(cwd_resource)
82
+ selection = {"key" => "workspace:#{mapping_id}:#{digest}",
83
+ "host_path" => File.join(authority.fetch("state_root"), "lifecycle-exclusion", project_id,
84
+ "workspaces", mapping_id, digest), "view_path" => "/run/ace/lifecycle-exclusion/#{mapping_id}"}
85
+ unless selection.fetch("host_path").bytesize <= 4096
86
+ raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace path is oversized"
87
+ end
88
+ selection.each_value(&:freeze)
89
+ selection.freeze
90
+ end
91
+
92
+ def self.workspace_initial_marker(key:)
93
+ unless key.is_a?(String) && key.match?(/\Aworkspace:[A-Za-z0-9][A-Za-z0-9._-]{0,127}:[0-9a-f]{64}\z/)
94
+ raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace key is malformed"
95
+ end
96
+ Atoms::EvidenceDigest.canonical_json({"key" => key, "removed" => false, "removed_at" => nil}).freeze
97
+ end
98
+
99
+ def self.workspace_marker!(bytes:, key:)
100
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 8192)
101
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker bytes differ"
102
+ end
103
+ bytes = bytes.dup.force_encoding(Encoding::UTF_8)
104
+ unless bytes.valid_encoding?
105
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker is not bounded UTF8"
106
+ end
107
+ state = JSON.parse(bytes, create_additions: false, max_nesting: 4, allow_duplicate_key: false)
108
+ unless state.is_a?(Hash) && state.keys.sort == %w[key removed removed_at] && state.fetch("key") == key &&
109
+ (state.values_at("removed", "removed_at") == [false, nil] || state["removed"] == true &&
110
+ state["removed_at"].is_a?(String) && Time.iso8601(state["removed_at"]).utc.iso8601 == state["removed_at"])
111
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker schema differs"
112
+ end
113
+ state.each_value { |value| value.freeze }
114
+ state.freeze
115
+ rescue JSON::ParserError, ArgumentError, KeyError
116
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker schema differs"
117
+ end
118
+
28
119
  # @param repo_root [String, nil] Repository used to resolve the
29
120
  # shared Git common dir (default: project root)
30
121
  # @param root [String, nil] Explicit exclusion root (tests)
@@ -33,27 +124,27 @@ module Ace
33
124
  end
34
125
 
35
126
  # The exclusion root shared by every writer and prune for this repo.
36
- # Sandboxed environments (CACHE_BASE) scope it under the cache base,
37
- # which itself is never a deletion target; otherwise it lives under
38
- # the shared Git common dir, which survives worktree removal.
39
- def self.default_root(_repo_root = nil)
40
- cache_base = ENV["CACHE_BASE"]
41
- if cache_base && !cache_base.empty?
42
- return File.join(File.expand_path(cache_base), ".exclusion")
43
- end
44
-
45
- repo_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
127
+ # Assignment cache configuration does not select this repository
128
+ # identity. The Git common dir survives linked worktree removal.
129
+ def self.default_root(repo_root = nil)
130
+ repo_root ||= Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
46
131
  common_dir, _stderr, status = Open3.capture3(
47
132
  "git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir"
48
133
  )
49
- base = status.success? && !common_dir.to_s.strip.empty? ? common_dir.strip : File.join(repo_root, ".git")
50
- File.join(base, "ace", "lifecycle-exclusion")
134
+ unless status.success? && !common_dir.to_s.strip.empty? && File.absolute_path(common_dir.strip) == common_dir.strip
135
+ raise AttemptErrors::Conflict, "Repository lifecycle root is unavailable"
136
+ end
137
+ File.join(common_dir.strip, "ace", "lifecycle-exclusion")
51
138
  end
52
139
 
53
140
  def assignment_key(assignment_id)
54
141
  "assignment:#{assignment_id}"
55
142
  end
56
143
 
144
+ def slot_key(slot_id)
145
+ "execution-slot:#{slot_id}"
146
+ end
147
+
57
148
  # Task identity is the canonical worktree identity: work-on derives
58
149
  # the worktree path from the task ref, and prune candidates carry
59
150
  # the same task id.
@@ -75,12 +166,17 @@ module Ace
75
166
  #
76
167
  # @param key [String] Identity key
77
168
  # @yield Required block executed under exclusive exclusion
78
- def with_exclusive(key)
169
+ def with_exclusive(key, deadline: nil)
79
170
  raise ArgumentError, "exclusion key is required" if key.to_s.empty?
80
171
 
81
- lock = open_lock(key)
82
- lock.flock(File::LOCK_EX)
172
+ maintenance_deadline!(deadline) unless deadline.nil?
173
+ lock = open_lock(key, nonblock: !deadline.nil?)
83
174
  begin
175
+ mode = File::LOCK_EX | (deadline ? File::LOCK_NB : 0)
176
+ unless lock.flock(mode)
177
+ raise AttemptErrors::MaintenanceBusy, "maintenance exclusion is busy"
178
+ end
179
+ maintenance_deadline!(deadline) unless deadline.nil?
84
180
  yield
85
181
  ensure
86
182
  lock.flock(File::LOCK_UN)
@@ -122,8 +218,9 @@ module Ace
122
218
  ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
123
219
  raise ArgumentError, "exclusion keys are required" if ordered.empty?
124
220
 
125
- locks = ordered.map { |key| open_lock(key) }
221
+ locks = []
126
222
  begin
223
+ ordered.each { |key| locks << open_lock(key) }
127
224
  locks.each { |lock| lock.flock(File::LOCK_SH) }
128
225
  ordered.each do |key|
129
226
  if removed?(key)
@@ -153,25 +250,67 @@ module Ace
153
250
  end
154
251
 
155
252
  def removed?(key)
156
- state = JSON.parse(File.read(state_path(key)))
157
- state["removed"] == true
158
- rescue Errno::ENOENT, JSON::ParserError, TypeError
253
+ path = state_path(key)
254
+ opened = false
255
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
256
+ opened = true
257
+ before = file.stat
258
+ unless before.file? && before.nlink == 1 && before.size.between?(1, 8192) && (before.mode & 0022).zero?
259
+ raise AttemptErrors::Conflict, "Lifecycle marker is unsafe"
260
+ end
261
+ content = file.read(8193).force_encoding(Encoding::UTF_8)
262
+ unless content.valid_encoding? && content.bytesize == before.size && same_file?(before, file.stat) && same_file?(before, File.lstat(path))
263
+ raise AttemptErrors::Conflict, "Lifecycle marker changed"
264
+ end
265
+ state = JSON.parse(content, create_additions: false, max_nesting: 4, allow_duplicate_key: false)
266
+ unless state.is_a?(Hash) && state.keys.sort == %w[key removed removed_at] &&
267
+ state["key"] == key && state["removed"] == true && state["removed_at"].is_a?(String)
268
+ raise AttemptErrors::Conflict, "Lifecycle marker is malformed"
269
+ end
270
+ Time.iso8601(state.fetch("removed_at"))
271
+ true
272
+ end
273
+ rescue Errno::ENOENT
274
+ raise AttemptErrors::Conflict, "Lifecycle marker disappeared during read" if opened
159
275
  false
276
+ rescue JSON::ParserError, ArgumentError, TypeError, SystemCallError, IOError
277
+ raise AttemptErrors::Conflict, "Lifecycle marker is unavailable or malformed"
160
278
  end
161
279
 
162
280
  def clear_removed!(key)
281
+ return unless removed?(key)
163
282
  File.delete(state_path(key))
164
- rescue Errno::ENOENT
165
- nil
283
+ sync_directory(root)
166
284
  end
167
285
 
168
286
  private
169
287
 
170
288
  attr_reader :root
171
289
 
172
- def open_lock(key)
290
+ def open_lock(key, nonblock: false)
291
+ retained = false
173
292
  FileUtils.mkdir_p(root)
174
- File.open(lock_path(key), File::RDWR | File::CREAT)
293
+ flags = File::RDWR | File::CREAT | File::NONBLOCK | File::NOFOLLOW
294
+ file = File.open(lock_path(key), flags, 0600)
295
+ unless file.stat.file? && file.stat.nlink == 1 && (file.stat.mode & 0022).zero? &&
296
+ same_file?(file.stat, File.lstat(lock_path(key)))
297
+ raise AttemptErrors::MaintenanceBusy, "maintenance exclusion is not a regular file"
298
+ end
299
+ retained = true
300
+ file
301
+ rescue SystemCallError
302
+ raise AttemptErrors::Conflict, "Lifecycle lock is unavailable"
303
+ ensure
304
+ file&.close unless retained
305
+ end
306
+
307
+ def maintenance_deadline!(deadline)
308
+ unless (deadline.is_a?(Integer) || deadline.is_a?(Float)) && deadline.finite?
309
+ raise ArgumentError, "maintenance deadline must be finite"
310
+ end
311
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
312
+ raise AttemptErrors::MaintenanceBusy, "maintenance admission deadline expired"
313
+ end
175
314
  end
176
315
 
177
316
  def lock_path(key)
@@ -194,15 +333,32 @@ module Ace
194
333
  end
195
334
  end
196
335
 
336
+ def same_file?(left, right)
337
+ [left.dev, left.ino, left.size, left.mtime, left.ctime] == [right.dev, right.ino, right.size, right.mtime, right.ctime]
338
+ end
339
+
340
+ def sync_directory(path)
341
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) { |directory| directory.fsync }
342
+ end
343
+
197
344
  def atomic_write(path, content)
198
345
  dir = File.dirname(path)
199
- FileUtils.mkdir_p(dir)
200
- temp = File.join(dir, ".tmp-#{Process.pid}-#{rand(1_000_000)}")
201
- File.write(temp, content)
346
+ FileUtils.mkdir_p(dir, mode: 0700)
347
+ temp = File.join(dir, ".tmp-#{SecureRandom.hex(16)}")
348
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0600) do |file|
349
+ file.write(content)
350
+ file.flush
351
+ file.fsync
352
+ end
353
+ if File.exist?(path) || File.symlink?(path)
354
+ removed?(JSON.parse(content).fetch("key"))
355
+ end
202
356
  File.rename(temp, path)
357
+ sync_directory(dir)
203
358
  ensure
204
359
  File.delete(temp) if temp && File.exist?(temp)
205
360
  end
361
+
206
362
  end
207
363
  end
208
364
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/herdr/molecules/guarded_native_origin"
4
+ require_relative "../models/evidence_event"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Exact accepted launch record; this reader grants no native effect.
10
+ module OriginalLaunchBinding
11
+ def self.verify!(events:, state:, params:)
12
+ unless state.is_a?(Hash) && state["guarded_origin"].is_a?(Hash) && state["process_binding"].is_a?(Hash)
13
+ raise AttemptErrors::EvidenceUnavailable, "Original guarded record is unavailable"
14
+ end
15
+ unless Models::EvidenceEvent.chain_valid?(events)
16
+ raise AttemptErrors::EvidenceUnavailable, "Original guarded record chain is corrupt"
17
+ end
18
+ records = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "record_launch" }
19
+ raise AttemptErrors::EvidenceUnavailable, "Original guarded record is ambiguous or absent" unless records.size == 1
20
+ record = records.first
21
+ data = record.fetch("payload").fetch("data")
22
+ unless data.is_a?(Hash) && record["attempt_id"] == params.fetch("attempt_id") &&
23
+ %w[mapping_id assignment_id attempt_id].all? { |key| data[key] == params.fetch(key) } &&
24
+ data["process_binding"] == state.fetch("process_binding") && data["guarded_origin"] == state.fetch("guarded_origin")
25
+ raise AttemptErrors::EvidenceUnavailable, "Original guarded record differs from accepted launch"
26
+ end
27
+ binding = data.fetch("process_binding")
28
+ origin = Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(data.fetch("guarded_origin"),
29
+ terminal_id: binding.fetch("terminal_id"), child: binding.fetch("process_identity"))
30
+ {"binding_digest" => record.fetch("digest").dup.freeze, "origin" => origin}.freeze
31
+ rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError
32
+ raise AttemptErrors::EvidenceUnavailable, "Original guarded record is malformed"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ # A decision projection of the existing qjl event chain. No effect
9
+ # executor, extra ref, file registry, or claim ledger lives here.
10
+ module ProposalJournal
11
+ PROPOSAL_REFERENCE = /\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/
12
+ PROPOSAL_BINDING = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid].freeze
13
+
14
+ PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision]).freeze
15
+
16
+ def proposal_history(id)
17
+ assignment_ids.flat_map do |assignment|
18
+ events = read_events(assignment)
19
+ events.group_by { |event| event["attempt_id"] }.each_value do |chain|
20
+ unless Models::EvidenceEvent.chain_valid?(chain)
21
+ raise AttemptErrors::EvidenceUnavailable, "Proposal evidence chain is corrupt"
22
+ end
23
+ end
24
+ events.select { |event| event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id }
25
+ .map { |event| event.fetch("payload").fetch("record") }
26
+ end
27
+ end
28
+
29
+ def proposal_record(id)
30
+ proposal_history(id).last
31
+ end
32
+
33
+ def proposals
34
+ assignment_ids.flat_map do |assignment|
35
+ read_events(assignment).select { |event| event["type"] == "proposal_state" }
36
+ .map { |event| event.dig("payload", "record", "proposal_id") }
37
+ end.uniq.map { |id| proposal_record(id) }
38
+ end
39
+
40
+ def proposal_operation_record(operation_id)
41
+ proposals.each do |record|
42
+ found = proposal_history(record["proposal_id"]).reverse.find do |entry|
43
+ entry["revision_operation_id"] == operation_id
44
+ end
45
+ return found if found
46
+ end
47
+ nil
48
+ end
49
+
50
+ # Admitted HITL policy runs this block while the same canonical
51
+ # mutation lock/CAS boundary excludes service claims and other replies.
52
+ def change_proposal(id, assignment_id:, attempt_id:, &policy)
53
+ 3.times do
54
+ events = read_events(assignment_id).select { |event| event["attempt_id"] == attempt_id }
55
+ generation = events.count { |event| event["type"] == "authority_mutation" }
56
+ begin
57
+ return mutate(assignment_id: assignment_id, attempt_id: attempt_id,
58
+ mutation_id: "proposal-#{SecureRandom.hex(16)}", operation: "proposal-decision",
59
+ parameters_digest: Digest::SHA256.hexdigest(id), expected_generation: generation) do |current, _commit, _generation|
60
+ record = current.reverse.find do |event|
61
+ event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id
62
+ end&.dig("payload", "record")
63
+ global = proposal_record(id)
64
+ if global && (global["assignment_id"] != assignment_id || global["attempt_id"] != attempt_id)
65
+ raise AttemptErrors::ReceiptRejected, "Proposal identity belongs to another assignment or attempt"
66
+ end
67
+ updated = policy.call(record)
68
+ return record if updated == record
69
+ unless updated.is_a?(Hash) && updated["proposal_id"] == id &&
70
+ updated["assignment_id"] == assignment_id && updated["attempt_id"] == attempt_id
71
+ raise AttemptErrors::ReceiptRejected, "Proposal policy changed immutable ownership"
72
+ end
73
+ validate_proposal_revision!(record, updated)
74
+ entries = []
75
+ if record && record["revision_id"] != updated["revision_id"]
76
+ entries << {type: "proposal_state", payload: {"record" => record.merge("state" => "superseded", "resolution" => "revised")}}
77
+ end
78
+ if record.nil? || record["revision_id"] != updated["revision_id"]
79
+ entries << {type: "proposal_state", payload: {"record" => updated.merge("state" => "proposed")}}
80
+ end
81
+ entries << {type: "proposal_state", payload: {"record" => updated}}
82
+ {events: entries, data: {"record" => updated}}
83
+ end.fetch("record")
84
+ rescue AttemptErrors::Conflict => e
85
+ raise unless e.message == "Authority registration generation changed"
86
+ end
87
+ end
88
+ raise AttemptErrors::EvidenceUnavailable, "Proposal generation stayed conflicting"
89
+ end
90
+
91
+ def validate_proposal_revision!(previous, updated)
92
+ if previous && previous["revision_id"] == updated["revision_id"]
93
+ unless PROPOSAL_IMMUTABLE.all? { |field| previous[field] == updated[field] }
94
+ raise AttemptErrors::ReceiptRejected, "Proposal revision content is immutable"
95
+ end
96
+ return
97
+ end
98
+ expected = previous ? previous.fetch("revision") + 1 : 1
99
+ reference = "#{updated.fetch("proposal_id")}-r#{expected}"
100
+ valid = updated["revision"] == expected && updated["revision_id"] == reference &&
101
+ updated["request_id"] == reference && updated["authorization"] == reference &&
102
+ updated["state"] == "awaiting-delivery" && PROPOSAL_REFERENCE.match?(reference)
103
+ if previous
104
+ valid &&= updated["expected_source_revision"] == previous["revision"] &&
105
+ updated["revision_operation_id"].is_a?(String) &&
106
+ updated["revision_operation_id"].match?(/\Arevision-[0-9a-f]{24}\z/) &&
107
+ proposal_operation_record(updated["revision_operation_id"]).nil? &&
108
+ %w[assignment_id attempt_id project_id requester caller_uid].all? { |field| previous[field] == updated[field] }
109
+ else
110
+ valid &&= updated["revision_operation_id"].nil? && updated["expected_source_revision"].nil?
111
+ end
112
+ unresolved = previous && proposal_claims(previous["revision_id"]).any? do |claim|
113
+ claim["state"] != "succeeded" &&
114
+ !(claim["state"] == "failed-settled" && settlement_evidence_intact?(claim))
115
+ end
116
+ raise AttemptErrors::ReceiptRejected, "Claimed proposal cannot be revised; reconcile the existing effect" if unresolved
117
+ raise AttemptErrors::ReceiptRejected, "Proposal revision must begin with exact immutable delivery scope" unless valid
118
+ end
119
+ private :validate_proposal_revision!
120
+
121
+ def proposal_claims(reference)
122
+ service_request_records.select do |request|
123
+ request["authorization"] == reference &&
124
+ !(request["state"] == "rejected" && request["consumed"] == false)
125
+ end
126
+ end
127
+
128
+ def proposal_authorize!(reference, binding)
129
+ unless reference.is_a?(String) && PROPOSAL_REFERENCE.match?(reference)
130
+ raise AttemptErrors::ReceiptRejected, "Invalid proposal authorization reference"
131
+ end
132
+ id = reference.sub(/-r[1-9][0-9]*\z/, "")
133
+ record = proposal_record(id)
134
+ unless record && record["revision_id"] == reference &&
135
+ record["stop_requested"] != true &&
136
+ %w[approved-explicitly approved-by-silence].include?(record["state"]) &&
137
+ PROPOSAL_BINDING.all? { |field| record[field] == binding[field] }
138
+ raise AttemptErrors::ReceiptRejected, "Proposal is not authorized for this exact effect"
139
+ end
140
+ attempt = derived_attempts(binding.fetch("assignment_id")).find do |candidate|
141
+ candidate.attempt_id == binding.fetch("attempt_id")
142
+ end
143
+ unless attempt && attempt.state == "running"
144
+ raise AttemptErrors::ReceiptRejected, "Proposal effect needs an authoritative bound running attempt"
145
+ end
146
+ record
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end