ace-lab 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3799161a4575055b78e316deb18a4fa952fb1d84fa9fb10cd6e5c081a36acee
4
- data.tar.gz: dd2d0fa5764e5bce423396e93e4fd1751fddd880649219d6a130306d0ec49667
3
+ metadata.gz: bb4f48b950b3a32e27a457e36af99148a65e8a95cdbbc55a1586e4f9f2d2abd9
4
+ data.tar.gz: 361f1a4f8b0167365fe8fee52c7e78047c0f31a9d8f1b1dd9644de79e13b540f
5
5
  SHA512:
6
- metadata.gz: 1556efc67f0fa45a412b40430570355578c7e61000bda7af07982345631f023c3f56c709d2d02a25c3faf84495b510d6fb171a904d8f910e7c336b4b8113cd44
7
- data.tar.gz: a78076debb811ce94046ff8cb056648b6455f8dbe30514a5d575ac417bcf98adce21bb42c3f6f712f0d0e2d6d4bbb3c67ba3764dfb9e4138cb6eb17e6dd6a113
6
+ metadata.gz: c556ae2dfb4f3137d2e3a45473287c385cc265b2da115aa9db6a2fe42b1e1e61b5a4e6839d98b4b76ce093ace09032c1e8795373f41b33987c0e0127ba1e084a
7
+ data.tar.gz: 7d470523a46606f392ea1ed22cffd708861c5e635c34ff9d1321ff64d542ef45f30e5cc763ac422ec28a9add7c7ef6a26e8fb705d01f25cdfba291746c19c520
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Compose ordinary Assign, HITL and Forgejo merge under one installed owner and journal, with readiness barriers and fresh native/project admission.
15
+ - Optionally serve existing mTLS HITL beside the ordinary Unix listener under the same canonical owner, journal, proposal policy and one-use OTP vault. Validate the configured project and service identity before either listener starts.
16
+ - Add retained assignment prepare/request clients and bounded canonical worktree observations. Separate stable configuration identity from changing decisions, preserve no-effect settlement after slow provider calls, and clean delivery handler process groups.
17
+ - This source cut enables initial installation; it does not claim autonomous release or installed two-project acceptance.
18
+
10
19
  ## [0.7.0] - 2026-10-16
11
20
 
12
21
  ### Added
data/docs/usage.md CHANGED
@@ -121,6 +121,161 @@ Only the original OS caller identity with project visibility can read the
121
121
  request. The authoritative state survives loss of the local assignment cache
122
122
  because it lives in the separate assignment evidence Git ref.
123
123
 
124
+ ### `ace-lab service listen --mode ordinary-merge`
125
+
126
+ The installed ordinary service owns the Assign, HITL and merge endpoints in one
127
+ process. Start it as the configured service account:
128
+
129
+ ```sh
130
+ ace-lab service listen --mode ordinary-merge --project atlas \
131
+ --config /etc/lab/ace-lab/atlas-receiver.yml
132
+ ```
133
+
134
+ The receiver configuration selects its existing forge, repository, principal and
135
+ merge socket. `/etc/lab/ace-lab/assign-owners.yml` separately selects the canonical
136
+ Assign journal and native execution owner. Both selections must agree with the
137
+ ordinary project mapping in the trusted authorization document. `repo_path` is
138
+ the merge candidate repository; it does not choose the execution journal.
139
+
140
+ Each project entry in the root-installed assignment document contains:
141
+
142
+ | Entry | Installed selection |
143
+ |---|---|
144
+ | `service` | Non-root owner UID/GID, assignment socket and private state root |
145
+ | `journal` | Service-owned repository, execution ref and private evidence checkout |
146
+ | `assignment_root`, `lifecycle_root` | Separate private prepared-input and exclusion directories |
147
+ | `runtime_binary`, `native` | Installed executable and exact native server/socket generation from the installation receipt |
148
+ | `worktrees` | Allowed worktree root and Git common directory |
149
+ | `participants` | OS UID/GID/actor, native workspace and supervisor panes |
150
+ | `hitl` | Private store root, distinct socket, dedicated group, trusted grants path and optional installed TLS authority reference |
151
+
152
+ For example, the `hitl` entry inside an already selected project can be:
153
+
154
+ ```yaml
155
+ hitl:
156
+ root: /var/lib/lab/assign/atlas/hitl
157
+ socket_path: /run/lab/assign/atlas/hitl.sock
158
+ group: atlas-lifecycle
159
+ grants_path: /etc/lab/ace-lab/authorization.yml
160
+ ```
161
+
162
+ The store must be a separate child of that project's `service.state_root`; the
163
+ installer creates `service.state_root` with mode `0700`, the HITL root with its
164
+ maintained mode `0711`, and socket access before startup. The private parent keeps
165
+ the HITL tree inaccessible to other accounts; Store maintains private request and
166
+ secret subdirectories. Startup and restart validate the same layout.
167
+ `hitl.service_uid` in the grants document must equal the Assign owner UID. Native
168
+ identities come from the installed receipt and live observation, not the project
169
+ configuration cascade. HITL requests, decisions and merge claims use the same
170
+ canonical Assign journal and lifecycle exclusion.
171
+
172
+ For the existing remote Hermes transport, install its project-scoped mTLS
173
+ authority and add its absolute configuration path to this same `hitl` entry:
174
+
175
+ ```yaml
176
+ tls_authority: /etc/lab/ace-hitl/atlas-tls.json
177
+ ```
178
+
179
+ The referenced file uses the maintained `ace.hitl.tls.authority/v1` schema and
180
+ credential provisioning layout from `ace-hitl`. Its project must match this
181
+ owner; `service_user` and `service_group` must resolve to the owner's actual UID
182
+ and primary GID. The dedicated Unix control group remains `hitl.group`. Root
183
+ installs the credential references and Incus TCP proxy; callers cannot choose
184
+ them. Invalid ownership, identity, project or TLS material prevents startup.
185
+
186
+ The TLS listener runs in this same service process and shares its exact HITL
187
+ binding, store, decision policy and in-memory one-time OTP vault. Hermes uses its
188
+ verified project certificate as a remote transport identity; it does not need a
189
+ synthetic Unix transport UID. Keep `transport_uids` empty when no local transport
190
+ is installed. Configure the actual local proposer separately with
191
+ `proposal_uids` and its project grant. A proposer grant never authorizes replies
192
+ or delegates silence-based decisions. Restart the composed service after changing
193
+ its TLS authority or credential selection, retaining the canonical journal.
194
+
195
+ All selected endpoints must become ready before assignment ingress opens. A
196
+ failed endpoint shuts down the complete owner. A stale assignment endpoint needs
197
+ installer inspection; startup does not remove it or recover merge effects first.
198
+
199
+ ## Ordinary assignment client
200
+
201
+ `ace-lab assignment` selects the root-installed project owner and uses the
202
+ existing Assign protocol. Assign remains the only accepted execution journal.
203
+ Preparation captures reviewed task context and the existing managed assignment
204
+ graph; it does not start a worker or register accepted work.
205
+
206
+ ```sh
207
+ ace-lab assignment prepare --project atlas --task 8wr.t.abc --pane w1:pS \
208
+ --output .ace-local/prepared-task
209
+ ace-lab assignment request --project atlas --input .ace-local/prepared-task/request.json
210
+ ```
211
+
212
+ The output directory must not exist. Preparation retains the definition, bundle,
213
+ and registration request with one stable mutation ID. `--dependency-reports FILE`
214
+ selects an explicit JSON array of `{task_id, assignment_id, number}` entries for
215
+ dependency reports. A failed preparation keeps partial files for inspection;
216
+ it never rebuilds an existing directory. In this source checkout use
217
+ `bundle exec ace-lab` because no `bin/ace-lab` binstub is committed.
218
+
219
+ Every request file has exactly these fields:
220
+
221
+ ```json
222
+ {
223
+ "version": 1,
224
+ "project_id": "atlas",
225
+ "operation": "start_attempt",
226
+ "mutation_id": "original-start-001",
227
+ "params": {
228
+ "assignment_id": "assignment-id",
229
+ "attempt_id": "attempt-id",
230
+ "expected_generation": 0,
231
+ "scope": "010",
232
+ "head": "0123456789012345678901234567890123456789",
233
+ "pane": "w1:p2"
234
+ },
235
+ "parts": []
236
+ }
237
+ ```
238
+
239
+ Use actual retained IDs, scope, head and native pane. The actual worker executes
240
+ its start request: the owner verifies caller ancestry, live process and checkout.
241
+ A supervisor cannot impersonate another pane by changing `pane`. A new terminal
242
+ or prompt acknowledgement alone is not running proof.
243
+
244
+ | Operation | `params` beyond `assignment_id` | Transfer / authority |
245
+ |---|---|---|
246
+ | `register_assignment` | Generated by prepare | Exact prepared bundle; supervisor |
247
+ | `registration_status` | None | Read; `mutation_id: null` |
248
+ | `start_attempt` | `attempt_id`, `expected_generation: 0`, `scope`, `head`, `pane` | Actual worker start |
249
+ | `assignment_snapshot` | `attempt_id` (or null), `kind: queue` or `prepared_work` | Download; `mutation_id: null` |
250
+ | `queue_start` | `attempt_id`, `expected_generation`, `pane`, `step` (or null for next) | Original worker |
251
+ | `queue_finish` | Same as queue_start | One UTF-8 report part |
252
+ | `queue_fail` | `attempt_id`, `expected_generation`, `pane`, `message` | Original worker; unfinished work stays failed |
253
+ | `submit_result` | `attempt_id`, `expected_generation`, `pane`, `head`, `receipt_sha256` | Receipt first, then its ordered artifacts; original worker |
254
+ | `result_status` | `attempt_id` | Read; `mutation_id: null` |
255
+ | `result_snapshot` | `attempt_id`, `result_id` | Canonical receipt/artifacts download; `mutation_id: null` |
256
+ | `finish` | `attempt_id`, `expected_generation`, `pane`, `head`, `result_id`, `receipt_digest` | Supervisor accepts exact submitted result after settlement |
257
+
258
+ Upload `parts` are ordered `{file, bytes, sha256}` entries. Relative files resolve
259
+ beside the retained request. Changed, oversized or symlinked inputs refuse before
260
+ connection. Requesters never supply a wire `transfer` descriptor. Downloads require
261
+ `--output NEW_DIRECTORY`; numbered byte files and `response.json` are private
262
+ projections of the authenticated reply, not another execution store. For queue
263
+ snapshots, the first byte file contains the current step instructions and reports
264
+ as JSON; prepared-work snapshots contain the original Git bundle.
265
+
266
+ Retain each request and its exact parts before invoking it. On response loss,
267
+ inspect `registration_status` or `result_status` and reuse the same original
268
+ mutation when replay is appropriate. Never refresh its generation, replace its
269
+ bytes, or create a second worker because a reply was lost. Queue completion and
270
+ `submit_result` keep the attempt running; only the canonical accepted `finish`
271
+ reply proves terminal completion. Unknown effect or Inbox settlement refuses it.
272
+
273
+ These commands do not silently route to a local `ace-assign` cache. Missing or
274
+ changed installed owner selection is an explicit failure. Native wake consumers
275
+ must read the same canonical status and adopt its original attempt/result IDs.
276
+ Changed owner selection refuses new admission and dispatch until a controlled
277
+ restart. Existing canonical receipts remain the source for reconciliation.
278
+
124
279
  ## Error semantics
125
280
 
126
281
  Errors are one deterministic JSON document on stdout plus a non-zero exit.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../organisms/ordinary_assignment_client"
4
+
5
+ module Ace
6
+ module Lab
7
+ module CLI
8
+ module Commands
9
+ module Assignment
10
+ class Prepare < Ace::Support::Cli::Command
11
+ desc "Retain reviewed task input for ordinary Assign registration"
12
+ option :project, required: true, desc: "Installed project ID"
13
+ option :task, required: true, desc: "Reviewed leaf task ID"
14
+ option :pane, required: true, desc: "Actual supervisor pane for registration"
15
+ option :output, required: true, desc: "New private directory for retained input"
16
+ option :dependency_reports, desc: "JSON array of explicit dependency report selections"
17
+
18
+ def call(project:, task:, pane:, output:, **options)
19
+ reports = options[:dependency_reports] ? JSON.parse(Organisms::OrdinaryAssignmentClient.read_file(
20
+ options.fetch(:dependency_reports), 65_536), allow_duplicate_key: false, create_additions: false, max_nesting: 16) : []
21
+ puts JSON.generate(Organisms::OrdinaryAssignmentClient.prepare(project: project, task: task,
22
+ pane: pane, output: output, dependency_reports: reports))
23
+ rescue JSON::ParserError, ArgumentError, KeyError, SystemCallError, Ace::Assign::Error, Ace::Runtime::Error => error
24
+ raise Ace::Support::Cli::Error, "ordinary preparation incomplete: #{error.message}; retain existing files"
25
+ end
26
+ end
27
+
28
+ class Request < Ace::Support::Cli::Command
29
+ desc "Send one retained operation to the installed ordinary Assign owner"
30
+ option :project, required: true, desc: "Installed project ID"
31
+ option :input, required: true, desc: "Retained request JSON with original mutation and part digests"
32
+ option :output, desc: "New private directory for a canonical snapshot download"
33
+
34
+ def call(project:, input:, **options)
35
+ service = Organisms::OrdinaryAssignmentClient.new(project: project)
36
+ puts JSON.generate(service.request(input: input, output: options[:output]))
37
+ rescue ArgumentError, KeyError, SystemCallError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Lab::Error => error
38
+ raise Ace::Support::Cli::Error, "ordinary assignment unavailable: #{error.message}; retain the original request; inspect status before new work"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -7,6 +7,7 @@ require_relative "support"
7
7
  require_relative "../../organisms/protected_service_request"
8
8
  require_relative "../../organisms/ordinary_merge_request"
9
9
  require_relative "../../organisms/ordinary_merge_receiver"
10
+ require_relative "../../organisms/ordinary_authority_composition"
10
11
  require_relative "../../molecules/ordinary_listener"
11
12
  require_relative "../../molecules/ordinary_receiver_config"
12
13
 
@@ -176,7 +177,7 @@ module Ace
176
177
  # The ordinary receiver serve entrypoint (spec 8x3.t.xz9.4
177
178
  # execution clauses; 8x3.t.xz9.5 integration handoff): binds the
178
179
  # configured unix socket, mutually authenticates every connection
179
- # through SO_PEERCRED, admits through the canonical Assign
180
+ # through OS peer credentials, admits through the canonical Assign
180
181
  # coordinator, executes the authorized fixed operation — one REAL
181
182
  # Forgejo merge under the scoped host-installed service identity —
182
183
  # and settles the exact terminal receipt proven by real forge
@@ -188,7 +189,7 @@ module Ace
188
189
  include Ace::Support::Cli::Base
189
190
  include Output
190
191
 
191
- desc "Serve the ordinary fixed merge receiver on its configured socket"
192
+ desc "Serve ordinary Assign, HITL and fixed merge under the installed journal owner"
192
193
  option :mode, type: :string, required: true,
193
194
  desc: "ordinary-merge selects the ordinary fixed merge receiver"
194
195
  option :project, type: :string, required: true, desc: "Project stable ID"
@@ -217,36 +218,19 @@ module Ace
217
218
  request_id: "unknown", input_digest: nil
218
219
  ))
219
220
  end
220
- policy_document = Molecules::GrantResolver.trusted_document(
221
- receiver_config.policy_path
222
- )
223
- # The ONE canonical Assign journal: the coordinator binds the
224
- # evidence ref of the canonical repository the receiver is
225
- # installed for (config repo_path — the producer project's
226
- # canonical checkout, the same authoritative journal the
227
- # producer coordinator uses). Never an integration-checkout
228
- # duplicate; the service identity's reviewed permissions are
229
- # journal ref read/append, evidence checkout and no-effect
230
- # attestation writes inside that repository.
231
- coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(
232
- repo_root: receiver_config.repo_path
233
- )
234
- receiver = Organisms::OrdinaryMergeReceiver.new(
235
- config: receiver_config, coordinator: coordinator,
236
- policy_document: policy_document
237
- )
238
- listener = Molecules::OrdinaryListener.new(
239
- socket_path: receiver_config.socket_path,
240
- peer_uid: receiver_config.principal_uid,
241
- handler: receiver
242
- )
243
- receiver.recover_pending!
244
- listener.serve
245
- rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::AttemptErrors::EvidenceUnavailable => e
221
+ unless receiver_config.receiver_uid == Process.uid
222
+ raise Ace::Lab::InvalidConfigurationError, "receiver must run as its configured UID"
223
+ end
224
+ server = Organisms::OrdinaryAuthorityComposition.new(receiver_config: receiver_config).build
225
+ signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { server.request_stop }] }
226
+ server.serve
227
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Hitl::Lifecycle::Error => e
246
228
  emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
247
229
  "ordinary_merge_unavailable", e.message,
248
230
  request_id: "unknown", input_digest: nil
249
231
  ))
232
+ ensure
233
+ signals&.each { |signal, handler| Signal.trap(signal, handler) }
250
234
  end
251
235
  end
252
236
  end
data/lib/ace/lab/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "cli/commands/services"
9
9
  require_relative "cli/commands/resolve"
10
10
  require_relative "cli/commands/route"
11
11
  require_relative "cli/commands/service"
12
+ require_relative "cli/commands/assignment"
12
13
 
13
14
  module Ace
14
15
  module Lab
@@ -27,7 +28,9 @@ module Ace
27
28
  ["route", "Select a configured capable service in a project"],
28
29
  ["service request", "Request one authorized service operation"],
29
30
  ["service status", "Read a service request outcome"],
30
- ["service listen", "Serve the ordinary fixed merge receiver on its configured socket"]
31
+ ["service listen", "Serve the ordinary fixed merge receiver on its configured socket"],
32
+ ["assignment prepare", "Retain reviewed task input for ordinary Assign registration"],
33
+ ["assignment request", "Send one retained operation to the installed ordinary Assign owner"]
31
34
  ].freeze
32
35
 
33
36
  HELP_EXAMPLES = [
@@ -54,6 +57,8 @@ module Ace
54
57
  register "service request", CLI::Commands::Service::Request.new
55
58
  register "service status", CLI::Commands::Service::Status.new
56
59
  register "service listen", CLI::Commands::Service::Listen.new
60
+ register "assignment prepare", CLI::Commands::Assignment::Prepare.new
61
+ register "assignment request", CLI::Commands::Assignment::Request.new
57
62
 
58
63
  # Register version command
59
64
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -43,13 +43,16 @@ module Ace
43
43
  # @param path [String, nil] trusted policy path to re-read per decision
44
44
  # @param loader [Proc, nil] trusted reader (default: GrantResolver);
45
45
  # the only override point, for non-privileged test documents
46
- def initialize(policy_document, path: nil, loader: nil)
46
+ def initialize(policy_document, path: nil, loader: nil, proposal_resolver: nil, caller_uid: nil)
47
47
  unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
48
48
  raise Ace::Lab::InvalidConfigurationError,
49
49
  "trusted policy document must contain an authorizations mapping"
50
50
  end
51
51
  @authorizations = policy_document.fetch("authorizations")
52
+ @configuration_digest = ServicePolicy.configuration_digest(policy_document)
52
53
  @path = path
54
+ @proposal_resolver = proposal_resolver
55
+ @caller_uid = caller_uid
53
56
  @loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
54
57
  freeze
55
58
  end
@@ -65,6 +68,18 @@ module Ace
65
68
  unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
66
69
  return refuse("only the ordinary merge operation is resolvable")
67
70
  end
71
+ if reference.start_with?("proposal-")
72
+ return refuse("canonical proposal authority is unavailable") unless @proposal_resolver && @caller_uid.is_a?(Integer)
73
+ begin
74
+ decision = @proposal_resolver.call(reference, binding.slice(
75
+ "operation", "project_id", "assignment_id", "attempt_id", "input_digest", "candidate_head").merge(
76
+ "target" => {"resource" => binding.fetch("pr_url"), "artifact_digest" => nil},
77
+ "caller_uid" => @caller_uid))
78
+ return Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
79
+ rescue Ace::Assign::Error
80
+ return refuse("canonical proposal does not authorize this exact ordinary merge")
81
+ end
82
+ end
68
83
  decision = authorizations[reference]
69
84
  return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
70
85
  unless DECISION_KEYS.all? { |key| decision.key?(key) }
@@ -99,6 +114,10 @@ module Ace
99
114
  "invalid lab configuration: trusted authorization refresh at #{@path} " \
100
115
  "did not yield an authorizations mapping"
101
116
  end
117
+ unless ServicePolicy.configuration_digest(document) == @configuration_digest
118
+ raise Ace::Lab::InvalidConfigurationError,
119
+ "ordinary receiver configuration changed; restart with the installed configuration"
120
+ end
102
121
  document.fetch("authorizations")
103
122
  end
104
123
 
@@ -12,7 +12,7 @@ module Ace
12
12
  # entrypoint). Binds the configured unix socket and answers the exact
13
13
  # framed protocol the OrdinarySocket client speaks: one bounded JSON
14
14
  # document each way behind the shared handshake line. Authentication is
15
- # MUTUAL through SO_PEERCRED: every connection's peer UID must equal
15
+ # MUTUAL through OS peer credentials: every connection's peer UID must equal
16
16
  # the configured producer principal UID, and the listener itself runs
17
17
  # as the configured receiver UID — the identity the client verifies on
18
18
  # its side. Every identity, framing, deadline or parse failure is
@@ -24,11 +24,15 @@ module Ace
24
24
  LIMIT = OrdinarySocket::LIMIT
25
25
  DEADLINE_SECONDS = 30
26
26
 
27
- def initialize(socket_path:, peer_uid:, handler:, lock_path: nil, logger: nil)
27
+ def initialize(socket_path:, peer_uid:, handler:, socket_gid: nil, lock_path: nil, logger: nil)
28
28
  unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
29
29
  peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
30
30
  raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
31
31
  end
32
+ unless socket_gid.nil? || (socket_gid.is_a?(Integer) && socket_gid >= 0)
33
+ raise ArgumentError, "ordinary listener socket_gid must be a non-negative integer"
34
+ end
35
+ @socket_gid = socket_gid
32
36
  @socket_path = socket_path
33
37
  @peer_uid = peer_uid
34
38
  @handler = handler
@@ -40,12 +44,15 @@ module Ace
40
44
  # Serve until #stop. Excludes a second listener through one owned
41
45
  # lock file, removes only its own stale endpoint, and always removes
42
46
  # the endpoint it created.
43
- def serve
47
+ def serve(on_ready: nil)
44
48
  acquire_lock!
45
49
  remove_stale_endpoint!
46
50
  @listener = UNIXServer.new(@socket_path)
47
- File.chmod(0o600, @socket_path)
48
51
  @endpoint_identity = File.stat(@socket_path)
52
+ File.chown(nil, @socket_gid, @socket_path) if @socket_gid
53
+ File.chmod(@socket_gid ? 0o660 : 0o600, @socket_path)
54
+ on_ready.call if on_ready
55
+ @handler.recover_pending! if @handler.respond_to?(:recover_pending!)
49
56
  until @stopping
50
57
  socket = @listener.accept
51
58
  serve_connection(socket)
@@ -59,6 +66,11 @@ module Ace
59
66
  @lock&.close
60
67
  end
61
68
 
69
+ # The existing listener participates in the Assign owner's lifetime.
70
+ def run(on_ready:)
71
+ serve(on_ready: on_ready)
72
+ end
73
+
62
74
  def stop
63
75
  @stopping = true
64
76
  @listener&.close
@@ -75,26 +87,27 @@ module Ace
75
87
  verify_peer!(socket)
76
88
  submission = read_request!(socket, deadline)
77
89
  response = @handler.call(submission)
78
- write_response!(socket, response, deadline)
90
+ write_response_safely(socket, response, deadline)
79
91
  if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
80
92
  begin
81
- # The durable acknowledgment is already on the wire; the
93
+ # Admission is durable even if the acknowledgment was lost; the
82
94
  # execution lifetime belongs to the canonical record, not
83
95
  # the short connection. A lost lifecycle stays pending or
84
96
  # uncertain and reconciles by the original reference.
85
97
  @handler.execute(response.dig("data", "request_id"))
86
- rescue StandardError
87
- nil
98
+ rescue StandardError => error
99
+ log_failure("effect", error)
88
100
  end
89
101
  end
90
102
  rescue OrdinaryRefused => e
91
- write_response!(socket, e.envelope, deadline)
92
- rescue OrdinaryListenerError
103
+ write_response_safely(socket, e.envelope, deadline)
104
+ rescue OrdinaryListenerError, IOError, SystemCallError => error
93
105
  # Fail closed without a fabricated envelope; the client classifies
94
106
  # a lost/unreadable response as ordinary_merge_unavailable.
95
- nil
96
- rescue StandardError
97
- write_response!(socket, refusal_envelope("ordinary_merge_unavailable",
107
+ log_failure("connection", error)
108
+ rescue StandardError => error
109
+ log_failure("request", error)
110
+ write_response_safely(socket, refusal_envelope("ordinary_merge_unavailable",
98
111
  "receiver failed to handle the request"), deadline)
99
112
  ensure
100
113
  begin
@@ -119,16 +132,27 @@ module Ace
119
132
 
120
133
  private
121
134
 
122
- # Mutual half of SO_PEERCRED: the connecting process must run as the
135
+ def write_response_safely(socket, response, deadline)
136
+ write_response!(socket, response, deadline)
137
+ rescue OrdinaryListenerError, IOError, SystemCallError => error
138
+ log_failure("response", error)
139
+ end
140
+
141
+ def log_failure(phase, error)
142
+ # Arbitrary exception messages may contain provider credentials or
143
+ # request bodies. Journal references remain the source of effect state.
144
+ @logger&.call("ordinary receiver #{phase} failed (#{error.class.name})")
145
+ end
146
+
147
+ # Mutual half of OS peer authentication: the connecting process must run as the
123
148
  # configured producer principal UID. Anything else is refused before
124
149
  # one byte of request is read.
125
150
  def verify_peer!(socket)
126
- credentials = socket.getsockopt(:SOCKET, :PEERCRED)
127
- _peer_pid, peer_uid, = credentials.unpack("L3")
151
+ peer_uid, = socket.getpeereid
128
152
  return if peer_uid == @peer_uid
129
153
  raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
130
154
  "connecting principal is not the configured ordinary overseer")
131
- rescue SystemCallError, IOError
155
+ rescue SystemCallError, IOError, SocketError, NotImplementedError
132
156
  raise OrdinaryListenerError, "peer credentials are unavailable"
133
157
  end
134
158
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ordinary_worktree_read"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # Local checks are bounded read-only observations. Only the forge provider
9
+ # executes merge; moving a local ref can never prove a delivered merge.
10
+ class OrdinaryMergeRead
11
+ STRATEGY = "ff"
12
+
13
+ def initialize(repo_path:, base_ref:, root: repo_path, common_dir: File.join(repo_path, ".git"),
14
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
15
+ unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
16
+ base_ref.is_a?(String) && base_ref.start_with?("refs/")
17
+ raise ArgumentError, "ordinary merge read needs an absolute repository and full base ref"
18
+ end
19
+ @repo_path, @base_ref = repo_path, base_ref
20
+ @reader = OrdinaryWorktreeRead.new(root: root, common_dir: common_dir, deadline: deadline)
21
+ end
22
+
23
+ def base_head
24
+ @reader.ref_head(@repo_path, @base_ref)
25
+ rescue Ace::Assign::Error => error
26
+ raise GitUnavailable, error.message
27
+ end
28
+
29
+ def candidate_commit?(head)
30
+ @reader.commit?(@repo_path, head)
31
+ rescue Ace::Assign::Error => error
32
+ raise GitUnavailable, error.message
33
+ end
34
+
35
+ def fast_forwardable?(head)
36
+ @reader.ancestor?(@repo_path, base_head, head)
37
+ rescue Ace::Assign::Error => error
38
+ raise GitUnavailable, error.message
39
+ end
40
+
41
+ class GitUnavailable < StandardError; end
42
+ end
43
+ end
44
+ end
45
+ end