ace-assign 0.70.0 → 0.71.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  4. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  5. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  6. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  7. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  8. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  9. data/lib/ace/assign/authority/candidate_transfer.rb +55 -25
  10. data/lib/ace/assign/authority/command_execution.rb +180 -0
  11. data/lib/ace/assign/authority/endcap.rb +4 -2
  12. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  13. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  14. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  15. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  16. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  17. data/lib/ace/assign/authority/ordinary_completion.rb +13 -3
  18. data/lib/ace/assign/authority/ordinary_lifecycle.rb +40 -4
  19. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  20. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  21. data/lib/ace/assign/authority/ordinary_results.rb +11 -1
  22. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  23. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  24. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  25. data/lib/ace/assign/authority/prepared_work.rb +7 -2
  26. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  27. data/lib/ace/assign/authority/retained_input.rb +53 -0
  28. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  29. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  30. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  31. data/lib/ace/assign/molecules/evidence_journal.rb +17 -30
  32. data/lib/ace/assign/molecules/journal_mutation.rb +2 -1
  33. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/attempt_coordinator.rb +40 -3
  36. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  37. data/lib/ace/assign/version.rb +1 -1
  38. metadata +19 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9de34921fd3a299eabfd530c8df3c76fed9405606cb0e717a0458fed9ecf614e
4
- data.tar.gz: a9a2d22448d0c100b5404ce9ca9666b72ed0e6fb3d9bf158a5051ce001b703b1
3
+ metadata.gz: 5330cebc5f70e683451c7a69082c04f7fb6d8a5d92b24b92c73b37d30c56c4a9
4
+ data.tar.gz: d1687b8ba44c21a2a0618e61199c3043a67a8c6f0ecf7be38635635696c57b71
5
5
  SHA512:
6
- metadata.gz: f68696b349c145768cf15634945c0fe40a659a80394f6053b37fd4270c244d8884345dbc9ba438a257de66593a0e44d88feca77c3c2580ea0e5208221d896746
7
- data.tar.gz: 07c573b0f8160c1929fbba896362180ad2bd08097ab306139ca0e5a28c00762650845840f8709e16f825660add6057b6569ce49a1f7595370c2f83a6740c4e07
6
+ metadata.gz: e792c5dae1dda172bfa11a6bf7cc08c074796e70dd5fd67b2ba42294975b71b46ef7fbef858ca7e848675ae83fc4d567dcffb5cf5e5867ea3425b6aba25e161a
7
+ data.tar.gz: 564c47ff609931ae807a8a0f2cd8c1806160272ab9ecc3405a90118e42f184dc93c180b6b7b834844cc3668699f2bcc2fef3632db773ba7da4aa6e29cb0fd912
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.71.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Compare two independently retained candidate bundles through bounded bare Git object reads, including nonancestor rebases, full context inventories, binary patches and historical symlink bytes without executing or materializing candidate code.
15
+ - Ground candidate and assignment-snapshot wire/expansion ceilings in closed 128MiB budgets backed by a shared fixed `cat-file --batch` codec, explicit 20,000-object bounds (per tree inventory and across the comparison's unique-blob union), and chunked batch reads instead of one Git process per blob; historical journal reads reuse the same codec.
16
+ - Bound candidate, prepared and head-comparison tree paths to finite total/component lengths (4096/255 bytes) before any inventory, delta or filesystem prefix work, so a hostile tree cannot push unbounded path bytes into inventories or directory creation.
17
+ - Retain built ordinary release gems as canonical binary evidence bound to the actual native producer, current accepted candidate, independent review and exact release decision. Recheck authorization on replay; preserve report limits and keep publication dispatch separate.
18
+ - Admit ordinary candidates and independently started native reviewers into the canonical journal. Revalidate original review artifacts and root-witnessed test executions before fixed merge admission and effects; preserve strict protected-review attribution.
19
+ - Drive ordinary work through the installed canonical owner with retained requests, accepted context snapshots and result adoption. Record coordinator replacement in the existing definition chain only after positive old-process termination; preserve worker attempts and reject stale supervisor completion.
20
+
21
+ ### Fixed
22
+
23
+ - Preserve exact lost-response result replay while allowing canonical-generation CAS conflicts to recover without replacing original inputs.
24
+
25
+ ### Changed
26
+
27
+ - Keep protected launch identity construction and comparison in its boundary owner while shared fixed service transitions reuse the same checks.
28
+ - Require ace-review 0.61 for the shared complete-head-context ceiling; raise the candidate transfer deadline from 30s to a measured 60s with byte/count bounds unchanged (two full admits plus the complete comparison of the real 82,809,675-byte candidate legitimately need 23–27s on a loaded host; 30s failed closed under recorded load).
29
+
10
30
  ## [0.70.0] - 2026-10-16
11
31
 
12
32
  ### Added
@@ -6,12 +6,13 @@ description: Drive agent execution through an active assignment
6
6
  user-invocable: true
7
7
  allowed-tools:
8
8
  - Bash(ace-assign:*)
9
+ - Bash(ace-lab:*)
9
10
  - Bash(ace-bundle:*)
10
11
  - Read
11
12
  - Write
12
13
  - AskUserQuestion
13
14
  - Skill
14
- argument-hint: "[assignment[@scope]]"
15
+ argument-hint: "[assignment[@scope] | --ordinary <context.json>]"
15
16
  last_modified: 2026-04-23
16
17
  source: ace-assign
17
18
  skill:
@@ -23,6 +24,8 @@ skill:
23
24
 
24
25
  Load and run `ace-bundle wfi://assign/drive` in the current project, then follow the loaded workflow as the source of truth and execute it end-to-end instead of only summarizing it.
25
26
 
27
+ When `--ordinary` or `ACE_ASSIGN_ORDINARY_CONTEXT` is present, follow the workflow’s ordinary installed branch before resolving local assignment state. Its canonical `ace-lab assignment drive` operations own status, start, progress, results and recovery; local commands below apply only outside that branch.
28
+
26
29
  Hard stop rule:
27
30
 
28
31
  - Do not stop after intermediate progress.
@@ -30,7 +33,7 @@ Hard stop rule:
30
33
  - Treat `ace-assign status --assignment <id>@<root>` as the source of truth for fork completion; quiet terminal output is not enough reason to stop or declare a stall.
31
34
  - If a prior terminal or drive session ended, re-enter from assignment state and continue from the next runnable work instead of depending on the old terminal handle.
32
35
  - Before any final response, re-check pinned assignment status. If any runnable `pending` or `active` work remains, continue driving.
33
- - If unrelated dirty files are generated side effects outside task scope, clean/reset them instead of auto-committing them.
36
+ - Preserve unrelated dirty files; commit only task-owned changes.
34
37
  - Use progress updates for partial status only.
35
38
  - Return a final user-facing completion response only when the assignment is complete or the workflow reaches an explicit blocker/failure stop condition.
36
39
  - If pending work remains runnable, continue driving.
@@ -3,6 +3,7 @@ name: assign-drive
3
3
  description: Drive an ace-assign assignment until completion or an explicit blocker.
4
4
  allowed-tools:
5
5
  - Bash(ace-assign:*)
6
+ - Bash(ace-lab:*)
6
7
  - Bash(ace-bundle:*)
7
8
  - Read
8
9
  - Write
@@ -27,6 +28,82 @@ Drive agent execution through an active assignment by continuously checking stat
27
28
  - An active assignment exists (created via `ace-assign create` or `/as-assign-create`)
28
29
  - Assignment has at least one pending or active step
29
30
 
31
+ ## Ordinary installed execution
32
+
33
+ Select this branch **before resolving any local assignment** when invoked with
34
+ `--ordinary <context.json>` or when `ACE_ASSIGN_ORDINARY_CONTEXT` is set. Require
35
+ `ACE_ASSIGN_ORDINARY_PROJECT` and use the exact retained context path throughout.
36
+ Conflicting explicit and environment paths are an error. In this branch every
37
+ execution read and write uses `ace-lab assignment drive --project <project>
38
+ --context <context.json>` against the installed owner. The local assignment
39
+ commands below, `.current`, `.latest`, `fork-run`, and protected `work-on --project`
40
+ are not fallback routes.
41
+
42
+ 1. Run `--operation status`. If `not_started`, run `--operation start` from the
43
+ actual native worker pane. A launch/send acknowledgement is not a start receipt.
44
+ Verify canonical status is `running` with the expected assignment, attempt,
45
+ scope, native binding and producer. Never adopt another pane's execution.
46
+ 2. Run `--operation bootstrap --output <new-private-directory>`. Read the returned
47
+ accepted task/context/report paths and retain their exact manifest and file
48
+ digests in the work report. These bytes come from canonical prepared work;
49
+ mutable task files in a checkout cannot replace them. Resolve referenced
50
+ project workflows/guides with the selected installed packages and record each
51
+ effective path and digest. A missing or conflicting required asset is a blocker.
52
+ 3. Run `--operation next`. Execute the returned current step, or start the returned
53
+ next step using `--operation step-start --step <number> --generation <observed>`.
54
+ The accepted root's children run in this already admitted worker; do not fork
55
+ another execution because a prepared root originated in a batch preset.
56
+ 4. After executing a step, write its bounded UTF-8 report and run
57
+ `--operation step-finish --step <number> --report <path> --generation <observed>`.
58
+ On an unrecoverable step failure, use `--operation fail --message <reason>
59
+ --generation <observed>`. Read `next` again after every acknowledged mutation.
60
+ Do not mark an unexecuted step successful. Nested skills may perform the work,
61
+ but their local Assign bookkeeping must not replace these canonical operations.
62
+ 5. When the scope is complete or failed, prepare an execution receipt and exact
63
+ artifact files using the receipt contract in the Lab usage documentation.
64
+ Its producer is the canonical start producer; HEAD is the actual committed
65
+ candidate; checks describe executed checks. Submit with `--operation result
66
+ --generation <observed> --receipt <json> --artifact <file>` in receipt order.
67
+ Submission is not terminal acceptance, merge, publication or installation.
68
+ 6. Keep the worker's native process and checkout available until the supervisor
69
+ accepts the result through `--operation adopt` and canonical `status` becomes
70
+ terminal. The filesystem watch emits only a hint. Never infer completion from
71
+ a quiet pane, a hint file, an old message or a send acknowledgement.
72
+
73
+ Each mutation prints its retained original request path. After any lost reply,
74
+ read canonical `status`/`next` first and use `--operation replay --request <path>`
75
+ only for that original input. Preserve assignment, attempt, mutation, candidate
76
+ and pane identities. Never edit an original request's generation or bytes. For a
77
+ result/finish CAS refusal, reread canonical status. An accepted result replays its
78
+ exact original input; an accepted terminal outcome needs no new finish. If no
79
+ result/terminal was accepted and the attempt generation advanced, the original
80
+ CAS can no longer accept: result/finish may retain a separate request at the new
81
+ observed generation. The driver preserves all old inputs and performs these
82
+ checks; unavailable status remains a blocker. Changed content, project or owner
83
+ is not a generation retry, and never authorizes another worker.
84
+
85
+ A supervisor installs the `watch_command` returned by `ace-overseer ordinary`
86
+ exactly once in its native session, runs a harmless turn to persist it, and uses
87
+ exact-session resume thereafter. Do not recreate an intentionally removed watch.
88
+ A watch merely requests canonical inspection; recurring native progress audits
89
+ also find accepted results when a wake or reply is lost. Wrong project/attempt
90
+ hints carry no authority. Existing inbound Inbox settlement remains a separate
91
+ owner configuration: without it, nonempty Inbox references refuse completion.
92
+
93
+ After replacement of the coordinator process, read `--operation supervisor`.
94
+ A new coordinator uses `--operation adopt-supervisor --pane <actual-pane>
95
+ --generation <old-selection-generation> --previous-binding-sha256 <old-digest>`.
96
+ The installed owner authenticates its actual placement and positively verifies
97
+ that the original process generation ended. Live or unobservable old generations
98
+ refuse adoption. Replay the original adoption request after a lost reply. This
99
+ changes only the canonical supervisor selection; existing workers, uncertain
100
+ launches, attempts, pending results and mutation IDs remain intact. Then resume
101
+ canonical inspection and result adoption. Never re-register or relaunch workers
102
+ because the coordinator restarted.
103
+
104
+ **End of ordinary branch.** The remaining sections apply only to local assignment
105
+ execution; do not enter them from an ordinary installed context.
106
+
30
107
  ## Assignment Context Propagation
31
108
 
32
109
  When working with multiple concurrent assignments, the active assignment is resolved in this order:
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Closed candidate-purpose budgets. Reports and release gems retain their
7
+ # separate bounds; callers cannot select a larger wire/storage ceiling.
8
+ module CandidateLimits
9
+ BYTES = 128 * 1024 * 1024
10
+ PREPARED_BYTES = 64 * 1024 * 1024
11
+ REVIEW_SNAPSHOT_BYTES = 64 * 1024 * 1024
12
+ # Explicit finite path shape (PATH_MAX/NAME_MAX-sized) so a hostile
13
+ # tree entry cannot push unbounded path bytes into inventories,
14
+ # filesystem prefix creation or per-component scans.
15
+ PATH_BYTES = 4096
16
+ PATH_COMPONENT_BYTES = 255
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Atoms
8
+ # Git's fixed cat-file batch framing. Owners select process, immutable
9
+ # repository, deadline and byte/count limits before invoking this codec.
10
+ module GitBlobBatch
11
+ OID = /\A[0-9a-f]{40}\z/
12
+ class Invalid < StandardError
13
+ attr_reader :reason
14
+ def initialize(reason)
15
+ @reason = reason
16
+ super("invalid Git blob batch #{reason}")
17
+ end
18
+ end
19
+
20
+ def self.sizes(oids, output)
21
+ unless oids.is_a?(Array) && oids.all? { |oid| oid.is_a?(String) && OID.match?(oid) } && output.is_a?(String)
22
+ raise Invalid, :selector
23
+ end
24
+ lines = output.b.lines
25
+ raise Invalid, :count unless lines.size == oids.size
26
+ lines.zip(oids).map do |line, oid|
27
+ match = /\A([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n\z/.match(line)
28
+ raise Invalid, :metadata unless match && match[1] == oid
29
+ match[2].to_i
30
+ end
31
+ end
32
+
33
+ def self.decode(entries, output)
34
+ unless entries.is_a?(Array) && entries.all? { |entry|
35
+ entry.is_a?(Array) && entry.size == 2 && entry[0].is_a?(String) && OID.match?(entry[0]) &&
36
+ entry[1].is_a?(Integer) && entry[1] >= 0 } && output.is_a?(String)
37
+ raise Invalid, :selector
38
+ end
39
+ output = output.b
40
+ offset = 0
41
+ entries.map do |oid, size|
42
+ ending = output.index("\n", offset)
43
+ unless ending && ending - offset <= 127 && output.byteslice(offset, ending - offset) == "#{oid} blob #{size}"
44
+ raise Invalid, :header
45
+ end
46
+ offset = ending + 1
47
+ content = output.byteslice(offset, size)
48
+ unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
49
+ Digest::SHA1.new.update("blob #{size}\0").update(content).hexdigest == oid
50
+ raise Invalid, :bytes
51
+ end
52
+ offset += size + 1
53
+ content
54
+ end.tap { raise Invalid, :trailing unless offset == output.bytesize }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/git_blob_batch"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ # Same cat-file batch dialect/codec as the canonical journal. Chunked
9
+ # bodies avoid retaining the entire expanded tree a second time.
10
+ module CandidateBlobBatch
11
+ BATCH_BYTES = 8 * 1024 * 1024
12
+ BATCH_OBJECTS = 512
13
+ MAX_OBJECTS = 20_000
14
+
15
+ private
16
+
17
+ def candidate_blob_sizes(repository, deadline, oids)
18
+ unless oids.is_a?(Array) && oids.size <= MAX_OBJECTS &&
19
+ oids.all? { |oid| oid.is_a?(String) && self.class::SHA.match?(oid) }
20
+ reject!("Candidate blob inventory is invalid or oversized")
21
+ end
22
+ oids.each_slice(64).flat_map do |slice|
23
+ raw = git(repository, deadline, "cat-file", "--batch-check", stdin_data: slice.join("\n") + "\n", limit: slice.size * 128)
24
+ Atoms::GitBlobBatch.sizes(slice, raw)
25
+ end
26
+ rescue Atoms::GitBlobBatch::Invalid => error
27
+ reject!("Candidate blob batch #{error.reason} differs")
28
+ end
29
+
30
+ def each_candidate_blob(repository, deadline, entries)
31
+ batch = []
32
+ bytes = 0
33
+ entries.each do |entry|
34
+ if !batch.empty? && (batch.size >= BATCH_OBJECTS || bytes + entry.last > BATCH_BYTES)
35
+ yield_candidate_blob_batch(repository, deadline, batch) { |oid, content| yield oid, content }
36
+ batch = []
37
+ bytes = 0
38
+ end
39
+ batch << entry
40
+ bytes += entry.last
41
+ end
42
+ yield_candidate_blob_batch(repository, deadline, batch) { |oid, content| yield oid, content } unless batch.empty?
43
+ end
44
+
45
+ def yield_candidate_blob_batch(repository, deadline, entries)
46
+ limit = entries.sum { |_oid, size| size + 128 }
47
+ raw = git(repository, deadline, "cat-file", "--batch", stdin_data: entries.map { |oid, _size| "#{oid}\n" }.join, limit: limit)
48
+ Atoms::GitBlobBatch.decode(entries, raw).zip(entries).each { |content, (oid, _size)| yield oid, content }
49
+ rescue Atoms::GitBlobBatch::Invalid => error
50
+ reject!("Candidate blob batch #{error.reason} differs")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Compare retained Git objects in a private bare repository. Historical
7
+ # links are object bytes, never paths to materialize or follow. This view
8
+ # supplies evidence; it does not classify a change or grant acceptance.
9
+ # Host: CandidateTransfer supplies bounded Git/admission, byte encoding,
10
+ # private-root storage, monotonic deadlines and rejection helpers.
11
+ module CandidateHeadComparison
12
+ class ReadView
13
+ def initialize(owner:, repository:, reviewed:, candidate:, deadline:)
14
+ @owner, @repository, @reviewed, @candidate, @deadline = owner, repository, reviewed, candidate, deadline
15
+ @thread, @active = Thread.current, true
16
+ end
17
+
18
+ def close = @active = false
19
+
20
+ def context
21
+ unless @active && Thread.current.equal?(@thread)
22
+ raise AttemptErrors::EvidenceUnavailable, "head comparison escaped its source lifetime"
23
+ end
24
+ @owner.send(:head_comparison_context, @repository, @deadline, @reviewed, @candidate)
25
+ rescue Timeout::Error
26
+ raise AttemptErrors::EvidenceUnavailable, "head comparison deadline expired"
27
+ end
28
+ end
29
+
30
+ def with_head_comparison(before:, after:, before_base:, after_base:)
31
+ raise ArgumentError, "head comparison requires a block" unless block_given?
32
+ [before, after].each do |input|
33
+ unless input.is_a?(Hash) && input.keys.sort == %i[bytes head sha256 size tree] &&
34
+ input[:tree].is_a?(String) && input[:tree].match?(self.class::SHA)
35
+ reject!("Head comparison candidate binding is invalid")
36
+ end
37
+ end
38
+ [before_base, after_base].each do |base|
39
+ reject!("Head comparison base is invalid") unless base.is_a?(String) && base.match?(self.class::SHA)
40
+ end
41
+ deadline = monotonic + self.class::DEADLINE
42
+ # Each complete bundle is independently verified against an empty
43
+ # repository before either can contribute objects to the comparison.
44
+ admit(**before.reject { |key, _| key == :tree }, deadline: deadline) do |_old_repo, _old_deadline, reviewed|
45
+ reject!("Reviewed tree differs from accepted tree") unless reviewed.fetch("tree") == before[:tree]
46
+ admit(**after.reject { |key, _| key == :tree }, deadline: deadline) do |repository, _new_deadline, candidate|
47
+ reject!("Candidate tree differs from accepted tree") unless candidate.fetch("tree") == after[:tree]
48
+ Tempfile.create(["reviewed-", ".bundle"], @root) do |file|
49
+ file.binmode
50
+ file.write(reviewed.fetch("bundle")); file.flush
51
+ git(repository, deadline, "fetch", "--no-tags", file.path, "#{before[:head]}:refs/heads/reviewed")
52
+ reader = ReadView.new(owner: self, repository: repository,
53
+ reviewed: {"head" => before[:head], "base" => before_base, "tree" => before[:tree]},
54
+ candidate: {"head" => after[:head], "base" => after_base, "tree" => after[:tree]}, deadline: deadline)
55
+ yield reader
56
+ ensure
57
+ reader&.close
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def head_comparison_context(repository, deadline, reviewed, candidate)
66
+ context = {"schema" => "ace.candidate-head-comparison/v1", "reviewed" => reviewed,
67
+ "candidate" => candidate, "inventories" => {}, "objects" => {}, "changes" => {}}
68
+ remaining = self.class::MAX_REVIEW_BYTES
69
+ {"reviewed_head" => reviewed["head"], "reviewed_base" => reviewed["base"],
70
+ "candidate_head" => candidate["head"], "candidate_base" => candidate["base"]}.each do |label, commit|
71
+ git(repository, deadline, "cat-file", "-e", "#{commit}^{commit}")
72
+ tree = git(repository, deadline, "rev-parse", "#{commit}^{tree}").strip
73
+ inventory = head_comparison_inventory(repository, deadline, tree)
74
+ remaining -= JSON.generate(inventory).bytesize
75
+ reject!("Complete head comparison context is oversized") if remaining.negative?
76
+ context.fetch("inventories")[label] = {"tree" => tree, "entries" => inventory}
77
+ end
78
+ oids = context.fetch("inventories").values.flat_map { |inventory| inventory.fetch("entries").map { |entry| entry.fetch("oid") } }.uniq
79
+ sizes = oids.zip(candidate_blob_sizes(repository, deadline, oids)).to_h
80
+ reject!("Complete head comparison object content is oversized") if sizes.values.sum > remaining
81
+ each_candidate_blob(repository, deadline, sizes.to_a) do |oid, content|
82
+ object = {"size" => content.bytesize, "sha256" => Digest::SHA256.hexdigest(content), "content" => review_bytes(content)}
83
+ remaining -= JSON.generate(object).bytesize
84
+ reject!("Complete head comparison context is oversized") if remaining.negative?
85
+ context.fetch("objects")[oid] = object
86
+ end
87
+ {"candidate_delta" => [reviewed["head"], candidate["head"]],
88
+ "base_delta" => [reviewed["base"], candidate["base"]],
89
+ "original_scope" => [reviewed["base"], reviewed["head"]],
90
+ "current_scope" => [candidate["base"], candidate["head"]]}.each do |label, revisions|
91
+ delta = head_comparison_delta(repository, deadline, revisions, remaining)
92
+ remaining -= JSON.generate(delta).bytesize
93
+ reject!("Complete head comparison context is oversized") if remaining.negative?
94
+ context.fetch("changes")[label] = delta
95
+ end
96
+ # Include structural JSON overhead in the final bound as well as all
97
+ # original bytes. No partial inventory or truncated patch is returned.
98
+ reject!("Complete head comparison context is oversized") if JSON.generate(context).bytesize > self.class::MAX_REVIEW_BYTES
99
+ context
100
+ rescue AttemptErrors::ReceiptRejected => error
101
+ reject!("Head comparison read failed: #{error.message}")
102
+ end
103
+
104
+ def head_comparison_inventory(repository, deadline, tree)
105
+ entries = git(repository, deadline, "ls-tree", "-r", "-z", tree, limit: self.class::MAX_BYTES).b.split("\0")
106
+ reject!("Complete head comparison inventory is oversized") if entries.size > self.class::MAX_OBJECTS
107
+ entries.map do |entry|
108
+ metadata, path = entry.split("\t", 2)
109
+ mode, kind, oid = metadata.to_s.split(" ")
110
+ # Inventory entries carry whole paths only; bound their bytes
111
+ # before JSON encoding or delta cross-checks consume them.
112
+ reject!("Complete head comparison path exceeds bounded length") unless path && !path.empty? &&
113
+ path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
114
+ unless kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(self.class::SHA)
115
+ reject!("Complete head comparison contains an unsupported Git entry")
116
+ end
117
+ {"path" => review_bytes(path), "mode" => mode, "oid" => oid}
118
+ end
119
+ end
120
+
121
+ def head_comparison_delta(repository, deadline, revisions, remaining)
122
+ options = ["--no-ext-diff", "--no-textconv", "--no-renames", "--ignore-submodules=none"]
123
+ patch = git(repository, deadline, "diff", *options, "--binary", "--full-index", "--src-prefix=a/", "--dst-prefix=b/",
124
+ *revisions, "--", limit: [remaining, 1].max).b
125
+ paths = git(repository, deadline, "diff", *options, "--name-only", "-z", *revisions, "--", limit: [remaining, 1].max).b.split("\0")
126
+ stats = git(repository, deadline, "diff", *options, "--numstat", "-z", *revisions, "--", limit: [remaining, 1].max).b.split("\0")
127
+ binary = stats.filter_map do |entry|
128
+ added, removed, path = entry.split("\t", 3)
129
+ reject!("Complete head comparison diff inventory is invalid") unless path && paths.include?(path)
130
+ path if added == "-" || removed == "-"
131
+ end
132
+ {"from" => revisions.first, "to" => revisions.last, "patch" => review_bytes(patch),
133
+ "patch_sha256" => Digest::SHA256.hexdigest(patch), "paths" => paths.map { |path| review_bytes(path) },
134
+ "binary_paths" => binary.map { |path| review_bytes(path) }}
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -6,7 +6,11 @@ require "fileutils"
6
6
  require "tmpdir"
7
7
  require "tempfile"
8
8
  require "ace/herdr/molecules/bounded_process"
9
+ require "ace/review/atoms/campaign_contract"
9
10
  require_relative "private_directory"
11
+ require_relative "../atoms/candidate_limits"
12
+ require_relative "candidate_head_comparison"
13
+ require_relative "candidate_blob_batch"
10
14
 
11
15
  module Ace
12
16
  module Assign
@@ -14,8 +18,17 @@ module Ace
14
18
  # Untrusted bundles enter a clean bare quarantine. Only complete verified
15
19
  # objects are retained; no uploader configuration or worktree is read.
16
20
  class CandidateTransfer
17
- MAX_BYTES = 64 * 1024 * 1024
18
- DEADLINE = 30
21
+ include CandidateHeadComparison
22
+ include CandidateBlobBatch
23
+ MAX_BYTES = Atoms::CandidateLimits::BYTES
24
+ MAX_REVIEW_BYTES = Ace::Review::Atoms::CampaignContract::MAX_HEAD_CONTEXT_BYTES
25
+ # Measured on the real 82,809,675-byte candidate (9,203 entries,
26
+ # 71,583,200 expanded bytes): two full admits alone need ~14s of
27
+ # bounded Git work and the complete comparison ~23s on a loaded host
28
+ # (evidence in .ace-tasks/8xf.t.ei9-wave-3 evidence). 30s failed
29
+ # closed under legitimate load; 60s keeps a finite secondary guard
30
+ # with measured headroom. Inputs stay byte/count-bounded separately.
31
+ DEADLINE = 60
19
32
  SHA = /\A[0-9a-f]{40}\z/
20
33
 
21
34
  class CampaignReadView
@@ -165,20 +178,29 @@ module Ace
165
178
  File.chmod(0700, destination)
166
179
  begin
167
180
  entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
168
- total = 0
169
- links = []
170
- entries.each do |entry|
181
+ reject!("Candidate file inventory is oversized") if entries.size > MAX_OBJECTS
182
+ parsed = entries.map do |entry|
171
183
  metadata, path = entry.split("\t", 2)
172
184
  mode, kind, oid = metadata.to_s.split(" ")
173
- parts = path.to_s.split("/", -1)
174
- unless path && !path.start_with?("/") &&
175
- parts.none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) } &&
176
- kind == "blob" && %w[100644 100755 120000].include?(mode) && oid.match?(SHA)
185
+ # Total path bytes are bounded before any component or
186
+ # filesystem prefix work consumes them.
187
+ reject!("Candidate path exceeds bounded length") unless path && !path.start_with?("/") &&
188
+ path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
189
+ parts = path.split("/", -1)
190
+ unless parts.none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
191
+ %w[. .. .git].include?(part.downcase) } &&
192
+ kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(SHA)
177
193
  reject!("Candidate contains unsafe filesystem entry")
178
194
  end
179
- content = git(repository, deadline, "cat-file", "blob", oid, limit: MAX_BYTES).b
180
- total += content.bytesize
181
- reject!("Candidate expanded content is oversized") if total > MAX_BYTES
195
+ [path, mode, oid]
196
+ end
197
+ by_oid = parsed.group_by(&:last)
198
+ sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
199
+ reject!("Candidate expanded content is oversized") if parsed.sum { |_path, _mode, oid| sizes.fetch(oid) } > MAX_BYTES
200
+ links = []
201
+ ordered = parsed.each
202
+ each_candidate_blob(repository, deadline, parsed.map { |_path, _mode, oid| [oid, sizes.fetch(oid)] }) do |oid, content|
203
+ path, mode, _oid = ordered.next
182
204
  target = File.join(destination, path)
183
205
  FileUtils.mkdir_p(File.dirname(target), mode: 0700)
184
206
  if mode == "120000"
@@ -242,7 +264,7 @@ module Ace
242
264
  result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
243
265
  entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
244
266
  separator = first ? "" : ","
245
- reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > MAX_BYTES
267
+ reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > Atoms::CandidateLimits::REVIEW_SNAPSHOT_BYTES
246
268
  subject << separator << entry
247
269
  first = false
248
270
  end
@@ -256,19 +278,26 @@ module Ace
256
278
  def prepared_files(repository:, deadline:, head:)
257
279
  entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
258
280
  reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
259
- total = 0
260
- files = entries.to_h do |entry|
281
+ parsed = entries.map do |entry|
261
282
  metadata, path = entry.split("\t", 2)
262
283
  mode, kind, oid = metadata.to_s.split(" ")
263
- unless path && mode == "100644" && kind == "blob" && oid.match?(SHA) &&
264
- !path.start_with?("/") && path.split("/", -1).none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) }
284
+ reject!("Prepared tree path exceeds bounded length") unless path && !path.start_with?("/") &&
285
+ path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
286
+ unless mode == "100644" && kind == "blob" && oid&.match?(SHA) &&
287
+ path.split("/", -1).none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
288
+ %w[. .. .git].include?(part.downcase) }
265
289
  reject!("Prepared tree contains unsafe entry")
266
290
  end
267
- size = git(repository, deadline, "cat-file", "-s", oid).strip
268
- reject!("Prepared text is oversized") unless size.match?(/\A[0-9]+\z/) && size.to_i.between?(1, 1024 * 1024)
269
- total += size.to_i
270
- reject!("Prepared expanded content is oversized") if total > MAX_BYTES
271
- [path, git(repository, deadline, "cat-file", "blob", oid, limit: 1024 * 1024).b]
291
+ [path, oid]
292
+ end
293
+ by_oid = parsed.group_by(&:last)
294
+ sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
295
+ reject!("Prepared text is oversized") unless sizes.values.all? { |size| size.between?(1, 1024 * 1024) }
296
+ reject!("Prepared expanded content is oversized") if parsed.sum { |_path, oid| sizes.fetch(oid) } > Atoms::CandidateLimits::PREPARED_BYTES
297
+ files = {}
298
+ ordered = parsed.each
299
+ each_candidate_blob(repository, deadline, parsed.map { |_path, oid| [oid, sizes.fetch(oid)] }) do |_oid, content|
300
+ files[ordered.next.first] = content
272
301
  end
273
302
  reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
274
303
  files
@@ -289,18 +318,19 @@ module Ace
289
318
  PrivateDirectory.verify!(@root)
290
319
  end
291
320
 
292
- def git(repository, deadline, *arguments, limit: 65_536)
321
+ def git(repository, deadline, *arguments, limit: 65_536, stdin_data: "")
293
322
  remaining = deadline - monotonic
294
323
  raise Timeout::Error if remaining <= 0
295
324
  # Git variables may redirect object/config storage even in a clean
296
325
  # repository. Strip every inherited Git setting and global config.
297
326
  environment = ENV.keys.to_h { |key| [key, nil] }
298
327
  environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
328
+ "GIT_ATTR_NOSYSTEM" => "1",
299
329
  "GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
300
330
  "GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
301
331
  result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
302
- "-c", "core.hooksPath=/dev/null", "-c", "protocol.file.allow=always", *arguments],
303
- stdin_data: "", timeout_s: remaining, output_limit: limit)
332
+ "-c", "core.hooksPath=/dev/null", "-c", "core.attributesFile=/dev/null", "-c", "protocol.file.allow=always", *arguments],
333
+ stdin_data: stdin_data, timeout_s: remaining, output_limit: limit)
304
334
  reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
305
335
  result.stdout
306
336
  end