ace-test-runner-e2e 0.40.5 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +38 -6
- data/lib/ace/test/end_to_end_runner/molecules/bwrap_sandbox_backend.rb +11 -5
- data/lib/ace/test/end_to_end_runner/molecules/failure_finder.rb +59 -10
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +49 -3
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +2 -1
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +27 -3
- data/lib/ace/test/end_to_end_runner/molecules/release_manifest.rb +212 -0
- data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +5 -0
- data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +252 -3
- data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +25 -1
- data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +287 -14
- data/lib/ace/test/end_to_end_runner/version.rb +1 -1
- data/lib/ace/test/end_to_end_runner.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '023288b5afdd9d9303ed0a4b439503062611cb9cd364e29f68aef91dab4a85c6'
|
|
4
|
+
data.tar.gz: 30026fd012aa74ebe994d0f41b0bcd809d6bf0599f4a842ad087756e88592d8b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '062688fc71b5c282c9789c0c60cd1242a4b454436f6f98cfb39475fccc33025da06962c5e18c85728766dd59c3fe49aaff35bc987c4585c75b5218d5027c0dd1'
|
|
7
|
+
data.tar.gz: c06d63cd12734b3ac962e0699ecab8934e7f8797ae92b8ee0cf4fef2c3992c980e1e2f664b93e7d844b2bf7564b1003513d8b1c0ec51808e7fee6742d9a49645
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.41.0] - 2026-09-29
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Pipeline verdict integrity: a passing report requires a completed verifier response covering every selected test case; an incomplete verdict set becomes an explicit ERROR (`incomplete_verdict_set`) instead of an inferred PASS.
|
|
14
|
+
- Pipeline failures record `failure_phase`, `failure_category`, and structured execution evidence in the ERROR report; sessions whose provider process began and then ended without a confirmed completion are marked `uncertain_execution: true` in metadata.yml.
|
|
15
|
+
- The full-suite automatic retry (retry-failures-once) excludes uncertain scenarios — their side effects are never replayed. They stay in the final tally as errors and are listed by `FailureFinder#find_uncertain_scenarios` for explicit reconciliation. Explicit single-run `--only-failures` keeps them runnable (deliberate human reconciliation).
|
|
16
|
+
- `read_agent_result` (single and package runs) and the suite metadata reconcile keep a fresh pipeline ERROR authoritative: retained metadata.yml can no longer upgrade an errored execution to PASS.
|
|
17
|
+
- Verifier failures report `infrastructure-error` (runner/repair failures keep `runner-error`).
|
|
18
|
+
- TS-MONO-001-style scenarios can declare a `release-manifest` setup action: the runner
|
|
19
|
+
carries only the explicitly allowed `ACE_RELEASE_MANIFEST` host input (absolute path,
|
|
20
|
+
defaulting to the source root) into deterministic setup, which validates the strict
|
|
21
|
+
schema-version-1 manifest and copies its exact bytes into the sandbox before any
|
|
22
|
+
install goal can run. Invalid or missing input fails setup, and a failed deterministic
|
|
23
|
+
setup now aborts the scenario with an ERROR result instead of degrading to an
|
|
24
|
+
LLM-driven sandbox.
|
|
25
|
+
|
|
26
|
+
## [0.40.6] - 2026-09-29
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- macOS E2E runs keep provider auth: sandbox HOME/TMPDIR/XDG_RUNTIME_DIR/TMUX_TMPDIR overrides now apply only when the bwrap sandbox is supported.
|
|
30
|
+
- Prepared env clears RUBYOPT/RUBYLIB with empty strings so ambient loader variables cannot leak into runner/verifier children (plain delete does not unset through env-hash spawns, and nil-unset is unreliable once RubyGems re-reads the ambient environment).
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- Runner system prompt: scenario-prescribed isolation (for example `env -i` and direct executables) must be executed exactly as written.
|
|
34
|
+
- Verifier system prompt: pre-created placeholder files are input contracts, not postcondition evidence.
|
|
35
|
+
|
|
36
|
+
### Technical
|
|
37
|
+
- Regression test proving a contaminated parent RUBYLIB cannot reach backend children.
|
|
10
38
|
|
|
11
39
|
## [0.40.5] - 2026-09-02
|
|
12
40
|
|
|
@@ -116,7 +116,11 @@ module Ace
|
|
|
116
116
|
timeout: options[:timeout],
|
|
117
117
|
tags: tags,
|
|
118
118
|
exclude_tags: exclude_tags,
|
|
119
|
-
verify: options[:verify]
|
|
119
|
+
verify: options[:verify],
|
|
120
|
+
# An explicit --only-failures run is deliberate human
|
|
121
|
+
# reconciliation: uncertain scenarios stay selectable there.
|
|
122
|
+
# The automatic retry pass below always excludes them.
|
|
123
|
+
include_uncertain_failures: only_failures
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
results = run_suite_with_retry(
|
|
@@ -190,20 +194,35 @@ module Ace
|
|
|
190
194
|
)
|
|
191
195
|
return annotated unless retry_failures_once && suite_failed?(initial_results)
|
|
192
196
|
|
|
197
|
+
# Scenarios whose provider session ended uncertainly are excluded
|
|
198
|
+
# from replay by the failure finder; account for them explicitly
|
|
199
|
+
# so they stay visible failures instead of vanishing.
|
|
200
|
+
uncertain = orchestrator.uncertain_scenarios(packages: run_options[:packages])
|
|
201
|
+
unless uncertain.empty?
|
|
202
|
+
output.puts "#{uncertain.length} scenario(s) ended in uncertain execution and will not be retried (side effects are not replayed): #{uncertain.join(", ")}"
|
|
203
|
+
output.puts "Reconcile those sessions manually before rerunning them."
|
|
204
|
+
end
|
|
205
|
+
|
|
193
206
|
output.puts "Retrying failed scenarios once..."
|
|
194
|
-
retry_results = orchestrator.run(
|
|
195
|
-
|
|
207
|
+
retry_results = orchestrator.run(
|
|
208
|
+
run_options.merge(only_failures: true, include_uncertain_failures: false)
|
|
209
|
+
)
|
|
210
|
+
if retry_results[:total].zero? && uncertain.empty?
|
|
196
211
|
raise Ace::Support::Cli::Error.new(
|
|
197
212
|
"Retry pass found no failed test scenarios from attempt 1; aborting instead of silently passing"
|
|
198
213
|
)
|
|
199
214
|
end
|
|
200
215
|
|
|
216
|
+
retry_results = with_uncertain_accounting(retry_results, uncertain)
|
|
217
|
+
|
|
201
218
|
flaky_scenarios = recovered_flaky_scenarios(initial_results, retry_results)
|
|
202
|
-
remaining_failures = failure_scenarios(retry_results)
|
|
219
|
+
remaining_failures = (failure_scenarios(retry_results) + uncertain).uniq.sort
|
|
203
220
|
final_report_path = write_retry_summary_report(initial_results, retry_results)
|
|
204
221
|
output.puts "Final Report: #{final_report_path}" if final_report_path
|
|
205
222
|
|
|
206
|
-
if remaining_failures.empty?
|
|
223
|
+
if (remaining_failures - uncertain).empty? && !uncertain.empty?
|
|
224
|
+
output.puts "All retried scenario(s) recovered; #{uncertain.length} uncertain scenario(s) still require reconciliation"
|
|
225
|
+
elsif remaining_failures.empty?
|
|
207
226
|
output.puts "#{flaky_scenarios.length} scenario(s) recovered on retry and were marked flaky"
|
|
208
227
|
else
|
|
209
228
|
output.puts "#{remaining_failures.length} scenario(s) still failing after retry"
|
|
@@ -217,10 +236,23 @@ module Ace
|
|
|
217
236
|
remaining_failures: remaining_failures,
|
|
218
237
|
initial_report_path: initial_results[:report_path],
|
|
219
238
|
retry_report_path: retry_results[:report_path],
|
|
220
|
-
report_path: final_report_path || retry_results[:report_path]
|
|
239
|
+
report_path: final_report_path || retry_results[:report_path],
|
|
240
|
+
uncertain_scenarios: uncertain
|
|
221
241
|
)
|
|
222
242
|
end
|
|
223
243
|
|
|
244
|
+
# Keep uncertain scenarios in the final tally. The retry pass does
|
|
245
|
+
# not run them (by design), so without this they would disappear
|
|
246
|
+
# from counts and a suite with uncertain sessions could exit 0.
|
|
247
|
+
def with_uncertain_accounting(retry_results, uncertain_ids)
|
|
248
|
+
return retry_results if uncertain_ids.empty?
|
|
249
|
+
|
|
250
|
+
tally = retry_results.dup
|
|
251
|
+
tally[:total] = tally[:total].to_i + uncertain_ids.size
|
|
252
|
+
tally[:errors] = tally[:errors].to_i + uncertain_ids.size
|
|
253
|
+
tally
|
|
254
|
+
end
|
|
255
|
+
|
|
224
256
|
def write_retry_summary_report(initial_results, retry_results)
|
|
225
257
|
build_retry_report_writer.write_retry_summary(
|
|
226
258
|
initial_results: initial_results,
|
|
@@ -49,13 +49,19 @@ module Ace
|
|
|
49
49
|
|
|
50
50
|
def prepared_env(base_env = {})
|
|
51
51
|
env = stringify_keys(base_env)
|
|
52
|
-
|
|
52
|
+
# Empty strings clear inherited loader vars for children. A plain
|
|
53
|
+
# delete leaves the parent's value leaking through env-hash spawns,
|
|
54
|
+
# and nil-unset is unreliable once RubyGems auto-activation re-reads
|
|
55
|
+
# the ambient environment.
|
|
56
|
+
STRIPPED_ENV_KEYS.each { |key| env[key] = "" }
|
|
53
57
|
env["PROJECT_ROOT_PATH"] = @sandbox_root
|
|
54
58
|
env["ACE_E2E_SOURCE_ROOT"] ||= @source_root if @source_root
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
if self.class.supported?
|
|
60
|
+
env["HOME"] = sandbox_home
|
|
61
|
+
env["TMPDIR"] = sandbox_tmp
|
|
62
|
+
env["XDG_RUNTIME_DIR"] = sandbox_runtime_dir
|
|
63
|
+
env["TMUX_TMPDIR"] ||= sandbox_runtime_dir
|
|
64
|
+
end
|
|
59
65
|
env["BUNDLE_GEMFILE"] ||= File.join(@sandbox_root, ".ace-local", "e2e-runtime", "Gemfile")
|
|
60
66
|
env["ACE_CONFIG_PATH"] ||= File.join(@sandbox_root, ".ace")
|
|
61
67
|
env["BUNDLE_APP_CONFIG"] ||= bundler_app_config
|
|
@@ -6,15 +6,23 @@ module Ace
|
|
|
6
6
|
module Test
|
|
7
7
|
module EndToEndRunner
|
|
8
8
|
module Molecules
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
# Scans cache for failed test cases from previous E2E test runs
|
|
10
|
+
#
|
|
11
|
+
# Reads metadata.yml files from .ace-local/test-e2e/*-reports/ directories
|
|
12
|
+
# and extracts failed_test_cases arrays. Used by --only-failures CLI flag
|
|
13
|
+
# to re-run only tests that failed previously.
|
|
14
|
+
#
|
|
15
|
+
# Scenarios whose most recent run ended in uncertain execution (the provider
|
|
16
|
+
# session began and then ended without a confirmed completion) are excluded
|
|
17
|
+
# from find_failures_by_scenario — the automatic retry surface — because
|
|
18
|
+
# re-running them would replay side effects. They are listed by
|
|
19
|
+
# find_uncertain_scenarios instead so callers can surface an actionable
|
|
20
|
+
# diagnostic. Explicit single-run --only-failures paths keep them included:
|
|
21
|
+
# invoking it is a deliberate human reconciliation decision.
|
|
22
|
+
#
|
|
23
|
+
# Note: This is a Molecule (not an Atom) because it performs filesystem
|
|
24
|
+
# I/O via Dir.glob and YAML file reading.
|
|
25
|
+
class FailureFinder
|
|
18
26
|
CACHE_DIR = ".ace-local/test-e2e"
|
|
19
27
|
METADATA_FILE = "metadata.yml"
|
|
20
28
|
REPORTS_SUFFIX = "-reports"
|
|
@@ -81,11 +89,17 @@ module Ace
|
|
|
81
89
|
# Like find_failures_by_package but preserves per-scenario granularity.
|
|
82
90
|
# Callers can use this to re-run full failed scenarios.
|
|
83
91
|
#
|
|
92
|
+
# By default, scenarios whose most recent run is marked
|
|
93
|
+
# uncertain_execution are excluded — replaying them would repeat
|
|
94
|
+
# unverified side effects. The explicit reconciliation path passes
|
|
95
|
+
# include_uncertain: true to select them deliberately.
|
|
96
|
+
#
|
|
84
97
|
# @param packages [Array<String>] Package names to scan
|
|
85
98
|
# @param base_dir [String] Base directory to search from (default: current dir)
|
|
99
|
+
# @param include_uncertain [Boolean] keep uncertain scenarios selectable
|
|
86
100
|
# @return [Hash{String => Hash{String => Array<String>}}]
|
|
87
101
|
# Package name => { test-id => failed TC IDs }
|
|
88
|
-
def find_failures_by_scenario(packages:, base_dir: Dir.pwd)
|
|
102
|
+
def find_failures_by_scenario(packages:, base_dir: Dir.pwd, include_uncertain: false)
|
|
89
103
|
metadata_files = discover_metadata_files(base_dir)
|
|
90
104
|
return {} if metadata_files.empty?
|
|
91
105
|
|
|
@@ -96,6 +110,8 @@ module Ace
|
|
|
96
110
|
|
|
97
111
|
scenario_failures = {}
|
|
98
112
|
most_recent.each do |entry|
|
|
113
|
+
next if !include_uncertain && uncertain_execution?(entry[:data])
|
|
114
|
+
|
|
99
115
|
test_id = entry[:data]["test-id"]
|
|
100
116
|
failed_ids = extract_failed_test_cases(entry[:data])
|
|
101
117
|
scenario_failures[test_id] = failed_ids unless failed_ids.empty?
|
|
@@ -106,6 +122,32 @@ module Ace
|
|
|
106
122
|
result
|
|
107
123
|
end
|
|
108
124
|
|
|
125
|
+
# Find scenarios whose most recent run ended in uncertain execution,
|
|
126
|
+
# grouped by package. These need manual reconciliation before rerun;
|
|
127
|
+
# they are excluded from find_failures_by_scenario.
|
|
128
|
+
#
|
|
129
|
+
# @param packages [Array<String>] Package names to scan
|
|
130
|
+
# @param base_dir [String] Base directory to search from (default: current dir)
|
|
131
|
+
# @return [Hash{String => Array<String>}] Package name => uncertain test-ids
|
|
132
|
+
def find_uncertain_scenarios(packages:, base_dir: Dir.pwd)
|
|
133
|
+
metadata_files = discover_metadata_files(base_dir)
|
|
134
|
+
return {} if metadata_files.empty?
|
|
135
|
+
|
|
136
|
+
result = {}
|
|
137
|
+
packages.each do |package|
|
|
138
|
+
package_metadata = filter_by_package(metadata_files, package)
|
|
139
|
+
most_recent = most_recent_per_test(package_metadata)
|
|
140
|
+
|
|
141
|
+
uncertain = most_recent
|
|
142
|
+
.select { |entry| uncertain_execution?(entry[:data]) }
|
|
143
|
+
.map { |entry| entry[:data]["test-id"] }
|
|
144
|
+
.sort
|
|
145
|
+
|
|
146
|
+
result[package] = uncertain unless uncertain.empty?
|
|
147
|
+
end
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
109
151
|
private
|
|
110
152
|
|
|
111
153
|
# Discover all metadata.yml files in the cache directory
|
|
@@ -169,6 +211,13 @@ module Ace
|
|
|
169
211
|
entries.flat_map { |entry| extract_failed_test_cases(entry[:data]) }.uniq
|
|
170
212
|
end
|
|
171
213
|
|
|
214
|
+
# True when the latest run of this scenario ended in uncertain
|
|
215
|
+
# execution (the provider session began and ended without a
|
|
216
|
+
# confirmed completion; side effects may have happened).
|
|
217
|
+
def uncertain_execution?(data)
|
|
218
|
+
data["uncertain_execution"] == true
|
|
219
|
+
end
|
|
220
|
+
|
|
172
221
|
# Extract failed test case IDs from a single metadata hash
|
|
173
222
|
#
|
|
174
223
|
# Returns specific TC IDs when available, or ["*"] as a wildcard
|
|
@@ -42,7 +42,12 @@ module Ace
|
|
|
42
42
|
# @return [Models::TestResult]
|
|
43
43
|
def execute(scenario:, cli_args:, sandbox_path:, report_dir:, env_vars: nil, test_cases: nil)
|
|
44
44
|
started_at = Time.now
|
|
45
|
+
phase = :setup
|
|
45
46
|
FileUtils.mkdir_p(report_dir)
|
|
47
|
+
# A retry in the same report directory must start with neither
|
|
48
|
+
# terminal marker present: only this attempt's outcome may remain.
|
|
49
|
+
FileUtils.rm_f(File.join(report_dir, ".host-pipeline-complete.json"))
|
|
50
|
+
FileUtils.rm_f(File.join(report_dir, ".host-pipeline-failed.json"))
|
|
46
51
|
write_command_record(report_dir, "runner", provider: @provider, cli_args: cli_args)
|
|
47
52
|
write_tc_manifests(report_dir, scenario, test_cases: test_cases)
|
|
48
53
|
|
|
@@ -72,6 +77,7 @@ module Ace
|
|
|
72
77
|
test_cases: test_cases,
|
|
73
78
|
artifact_contract: declared_artifact_contract(scenario, test_cases: test_cases)
|
|
74
79
|
)
|
|
80
|
+
phase = :runner
|
|
75
81
|
runner_response = run_llm(
|
|
76
82
|
prompt_path: runner[:prompt_path],
|
|
77
83
|
system_path: runner[:system_path],
|
|
@@ -101,6 +107,7 @@ module Ace
|
|
|
101
107
|
artifact_contract: artifact_contract,
|
|
102
108
|
repair_mode: true
|
|
103
109
|
)
|
|
110
|
+
phase = :repair
|
|
104
111
|
repair_response = run_llm(
|
|
105
112
|
prompt_path: repair_runner[:prompt_path],
|
|
106
113
|
system_path: repair_runner[:system_path],
|
|
@@ -126,6 +133,7 @@ module Ace
|
|
|
126
133
|
artifact_contract: artifact_contract
|
|
127
134
|
)
|
|
128
135
|
write_command_record(report_dir, "verifier", provider: @verifier_provider, cli_args: cli_args)
|
|
136
|
+
phase = :verifier
|
|
129
137
|
verifier_response = run_llm(
|
|
130
138
|
prompt_path: verifier[:prompt_path],
|
|
131
139
|
system_path: verifier[:system_path],
|
|
@@ -137,13 +145,14 @@ module Ace
|
|
|
137
145
|
fallback: query_fallback_for(@verifier_provider)
|
|
138
146
|
)
|
|
139
147
|
|
|
140
|
-
@report_generator.generate(
|
|
148
|
+
report = @report_generator.generate(
|
|
141
149
|
scenario: scenario,
|
|
142
150
|
verifier_output: verifier_response[:text],
|
|
143
151
|
report_dir: report_dir,
|
|
144
152
|
provider: @verifier_provider,
|
|
145
153
|
started_at: started_at,
|
|
146
154
|
completed_at: Time.now,
|
|
155
|
+
expected_test_case_ids: selected_test_case_ids(scenario, test_cases),
|
|
147
156
|
metadata: base_metadata(
|
|
148
157
|
report_dir,
|
|
149
158
|
runner_observations: runner_observations,
|
|
@@ -151,8 +160,26 @@ module Ace
|
|
|
151
160
|
initial_artifact_contract: initial_artifact_contract
|
|
152
161
|
)
|
|
153
162
|
)
|
|
163
|
+
# Host-side completion record, written after the report: the
|
|
164
|
+
# runner-writable metadata.yml alone cannot prove the pipeline
|
|
165
|
+
# finished. A rescue below never writes this file.
|
|
166
|
+
File.write(File.join(report_dir, ".host-pipeline-complete.json"), JSON.generate({
|
|
167
|
+
"scenario" => scenario.test_id,
|
|
168
|
+
"runner_provider" => @provider,
|
|
169
|
+
"verifier_provider" => @verifier_provider,
|
|
170
|
+
"completed_at" => Time.now.utc.iso8601
|
|
171
|
+
}))
|
|
172
|
+
report
|
|
154
173
|
rescue => e
|
|
155
174
|
begin
|
|
175
|
+
# A failed pipeline leaves an explicit failure record instead of
|
|
176
|
+
# a completion record; finalization treats absence as failure.
|
|
177
|
+
File.write(File.join(report_dir, ".host-pipeline-failed.json"), JSON.generate({
|
|
178
|
+
"scenario" => scenario.test_id,
|
|
179
|
+
"failure_phase" => phase.to_s,
|
|
180
|
+
"error" => "#{e.class}: #{e.message}",
|
|
181
|
+
"completed_at" => Time.now.utc.iso8601
|
|
182
|
+
}))
|
|
156
183
|
@report_generator.write_failure_report(
|
|
157
184
|
scenario: scenario,
|
|
158
185
|
report_dir: report_dir,
|
|
@@ -160,8 +187,8 @@ module Ace
|
|
|
160
187
|
started_at: started_at || Time.now,
|
|
161
188
|
completed_at: Time.now,
|
|
162
189
|
error_message: "#{e.class}: #{e.message}",
|
|
163
|
-
failure_category: "runner-error",
|
|
164
|
-
metadata: base_metadata(report_dir)
|
|
190
|
+
failure_category: phase == :verifier ? "infrastructure-error" : "runner-error",
|
|
191
|
+
metadata: base_metadata(report_dir).merge(failure_evidence_metadata(e, phase))
|
|
165
192
|
)
|
|
166
193
|
rescue => write_error
|
|
167
194
|
Models::TestResult.new(
|
|
@@ -288,6 +315,25 @@ module Ace
|
|
|
288
315
|
Array(scenario.test_cases).select { |tc| wanted.include?(tc.tc_id.to_s.upcase) }
|
|
289
316
|
end
|
|
290
317
|
|
|
318
|
+
# TC ids the verifier must cover for the verdict set to be complete.
|
|
319
|
+
def selected_test_case_ids(scenario, test_cases)
|
|
320
|
+
select_test_cases(scenario, test_cases).map(&:tc_id)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Structured failure metadata for the error report. When the failure
|
|
324
|
+
# carries execution evidence (the provider session began and ended
|
|
325
|
+
# without a confirmed completion), the scenario is marked uncertain:
|
|
326
|
+
# automatic retries must not replay its side effects.
|
|
327
|
+
def failure_evidence_metadata(error, phase)
|
|
328
|
+
metadata = {"failure_phase" => phase.to_s}
|
|
329
|
+
evidence = Ace::LLM::Atoms::ErrorClassifier.execution_evidence_for(error)
|
|
330
|
+
return metadata unless evidence
|
|
331
|
+
|
|
332
|
+
metadata["uncertain_execution"] = true if evidence.uncertain_execution?
|
|
333
|
+
metadata["execution_evidence"] = evidence.to_h
|
|
334
|
+
metadata
|
|
335
|
+
end
|
|
336
|
+
|
|
291
337
|
def base_metadata(report_dir, runner_observations: nil, artifact_contract: nil, initial_artifact_contract: nil)
|
|
292
338
|
metadata = {
|
|
293
339
|
"runner_provider" => @provider,
|
|
@@ -17,7 +17,7 @@ module Ace
|
|
|
17
17
|
Rules:
|
|
18
18
|
- Execute each goal in order
|
|
19
19
|
- Treat the initial working directory as SANDBOX_ROOT; if a goal needs commands in a created worktree, cd there for execution but keep any declared outcome artifacts under SANDBOX_ROOT/results
|
|
20
|
-
- Preserve the sandbox runtime environment; do not reset PATH, HOME, or other provided env vars
|
|
20
|
+
- Preserve the sandbox runtime environment for ACE tooling; do not reset PATH, HOME, or other provided env vars on your own initiative. When a goal prescribes its own isolation (for example `env -i` or direct executables for the tool under test), execute it exactly as written — do not drop `env -i` or substitute partial environment overrides
|
|
21
21
|
- If `ACE_E2E_SANDBOX_RUNTIME_ROOT` is set, make sure command execution uses `$ACE_E2E_SANDBOX_RUNTIME_ROOT/bin` on PATH in the shell where you run scenario commands
|
|
22
22
|
- Run `ace-*` commands directly; do not wrap them with `timeout`, `env -i`, or other execution wrappers that can change behavior or hide diagnostics
|
|
23
23
|
- Do not bypass the public CLI with repo-local executables such as `./exe/ace-*`, `bin/ace-*`, or `ruby .../exe/ace-*`
|
|
@@ -41,6 +41,7 @@ module Ace
|
|
|
41
41
|
- Treat declared artifacts and helper filenames as hints, not as the source of truth
|
|
42
42
|
- If a helper file is missing or stale, inspect the sandbox directly before failing the goal
|
|
43
43
|
- Use artifact mtimes to detect runner ordering mistakes; if postcondition captures are older than the primary command's stdout/stderr/exit, classify the goal as `runner-error` unless direct sandbox state proves a product failure after the command completed
|
|
44
|
+
- Pre-created placeholder files required by the goal contract are input contracts, not postcondition evidence; their emptiness or older mtimes never fails a goal on their own
|
|
44
45
|
- Use read-only commands in the sandbox when they materially improve confidence (for example: git log/status/show, ls/find/cat)
|
|
45
46
|
- Do not speculate beyond the provided sandbox evidence and runner observations
|
|
46
47
|
- For each failed goal, include a category:
|
|
@@ -31,9 +31,12 @@ module Ace
|
|
|
31
31
|
# @param provider [String]
|
|
32
32
|
# @param started_at [Time]
|
|
33
33
|
# @param completed_at [Time]
|
|
34
|
+
# @param expected_test_case_ids [Array<String>, nil] TC ids the verifier must cover
|
|
34
35
|
# @return [Models::TestResult]
|
|
35
|
-
def generate(scenario:, verifier_output:, report_dir:, provider:, started_at:, completed_at:,
|
|
36
|
+
def generate(scenario:, verifier_output:, report_dir:, provider:, started_at:, completed_at:,
|
|
37
|
+
expected_test_case_ids: nil, metadata: {})
|
|
36
38
|
parsed = parse_verifier_output(verifier_output, scenario)
|
|
39
|
+
parsed = enforce_verdict_completeness(parsed, expected_test_case_ids)
|
|
37
40
|
merged_metadata = metadata.merge(parsed[:metadata] || {})
|
|
38
41
|
|
|
39
42
|
result = Models::TestResult.new(
|
|
@@ -94,9 +97,30 @@ module Ace
|
|
|
94
97
|
result.with_report_dir(report_dir)
|
|
95
98
|
end
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# A passing verdict requires one valid verdict per selected test case.
|
|
103
|
+
# A verifier response covering only some of them stays an explicit
|
|
104
|
+
# ERROR — bundle output must never synthesize the missing verdicts.
|
|
105
|
+
def enforce_verdict_completeness(parsed, expected_test_case_ids)
|
|
106
|
+
expected = Array(expected_test_case_ids).map { |id| id.to_s.upcase }
|
|
107
|
+
return parsed if expected.empty? || parsed[:status] == "error"
|
|
108
|
+
|
|
109
|
+
verdict_ids = parsed[:test_cases].map { |tc| tc[:id].to_s.upcase }
|
|
110
|
+
missing = expected - verdict_ids
|
|
111
|
+
return parsed if missing.empty?
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
status: "error",
|
|
115
|
+
test_cases: parsed[:test_cases],
|
|
116
|
+
summary: "Incomplete verifier verdict set (#{verdict_ids.size}/#{expected.size} test cases)",
|
|
117
|
+
error: "Verifier response is missing verdicts for: #{missing.join(", ")}",
|
|
118
|
+
observations: parsed[:observations],
|
|
119
|
+
metadata: (parsed[:metadata] || {}).merge("incomplete_verdict_set" => true)
|
|
120
|
+
}
|
|
121
|
+
end
|
|
98
122
|
|
|
99
|
-
|
|
123
|
+
def parse_verifier_output(text, scenario)
|
|
100
124
|
goals = parse_goal_sections(text, scenario)
|
|
101
125
|
return build_result_from_goals(goals, text) unless goals.empty?
|
|
102
126
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "rubygems/version"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Test
|
|
10
|
+
module EndToEndRunner
|
|
11
|
+
module Molecules
|
|
12
|
+
# Validates the frozen release installation manifest used by release
|
|
13
|
+
# verification scenarios (TS-MONO-001).
|
|
14
|
+
#
|
|
15
|
+
# The manifest is an exact-version contract: schema_version 1, a full
|
|
16
|
+
# source commit, and a nonempty package array. Every package entry
|
|
17
|
+
# carries a unique ACE gem name, one exact artifact version (never a
|
|
18
|
+
# requirement), the full source commit the artifact was built from,
|
|
19
|
+
# and an optional explicit supersession list of earlier versions of
|
|
20
|
+
# the same package.
|
|
21
|
+
#
|
|
22
|
+
# Validation is strict on purpose: unknown fields, missing fields,
|
|
23
|
+
# duplicate names, non-exact versions, malformed SHAs, and
|
|
24
|
+
# contradictory supersession entries all fail before any install
|
|
25
|
+
# attempt is made.
|
|
26
|
+
class ReleaseManifest
|
|
27
|
+
SCHEMA_VERSION = 1
|
|
28
|
+
GEM_NAME_PATTERN = /\Aace-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
|
|
29
|
+
SOURCE_SHA_PATTERN = /\A[0-9a-f]{40}\z/
|
|
30
|
+
|
|
31
|
+
# Raised for any manifest content or source-path failure. Setup
|
|
32
|
+
# converts this into a setup failure before installation runs.
|
|
33
|
+
class Invalid < StandardError; end
|
|
34
|
+
|
|
35
|
+
# Validate the manifest at +path+ and return its parsed content.
|
|
36
|
+
# @raise [Invalid] when the file is missing, unreadable,
|
|
37
|
+
# malformed, or violates the schema.
|
|
38
|
+
def self.load_validated(path)
|
|
39
|
+
data = parse_json(path)
|
|
40
|
+
validate_schema!(data)
|
|
41
|
+
data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate the manifest at +source_path+ and copy the exact bytes
|
|
45
|
+
# to +target_path+, creating the target directory when needed.
|
|
46
|
+
# +required_packages+ names the gems the calling scenario's proof
|
|
47
|
+
# depends on; the manifest must cover every one of them. Entries
|
|
48
|
+
# may be plain names or {name, supersedes} maps requiring an
|
|
49
|
+
# explicit supersession declaration. The buffer is read once so
|
|
50
|
+
# the validated content, the written copy, and the returned digest
|
|
51
|
+
# are the same bytes even if the source file changes mid-flight.
|
|
52
|
+
# @raise [Invalid]
|
|
53
|
+
def self.validate_and_copy(source_path:, target_path:, required_packages: nil)
|
|
54
|
+
raw = read_source(source_path)
|
|
55
|
+
data = parse_buffer(raw, source_path)
|
|
56
|
+
validate_schema!(data)
|
|
57
|
+
entries = data["packages"].to_h { |package| [package["name"], package] }
|
|
58
|
+
Array(required_packages).each do |required|
|
|
59
|
+
name, supersedes = required_packages_entry(required)
|
|
60
|
+
entry = entries[name]
|
|
61
|
+
raise Invalid, "release manifest is missing required package: #{name}" unless entry
|
|
62
|
+
|
|
63
|
+
missing = Array(supersedes) - Array(entry["supersedes"])
|
|
64
|
+
next if missing.empty?
|
|
65
|
+
|
|
66
|
+
raise Invalid,
|
|
67
|
+
"release manifest does not declare that #{name} #{entry["artifact_version"]} supersedes #{missing.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
70
|
+
File.binwrite(target_path, raw)
|
|
71
|
+
Digest::SHA256.hexdigest(raw)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def read_source(path)
|
|
78
|
+
raise Invalid, "release manifest is missing: #{path}" unless File.file?(path)
|
|
79
|
+
raise Invalid, "release manifest is not readable: #{path}" unless File.readable?(path)
|
|
80
|
+
|
|
81
|
+
raw = File.binread(path)
|
|
82
|
+
raise Invalid, "release manifest is empty: #{path}" if raw.strip.empty?
|
|
83
|
+
|
|
84
|
+
raw
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_buffer(raw, path)
|
|
88
|
+
data = JSON.parse(raw)
|
|
89
|
+
raise Invalid, "release manifest must be a JSON object: #{path}" unless data.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
data
|
|
92
|
+
rescue JSON::ParserError => e
|
|
93
|
+
raise Invalid, "release manifest is not valid JSON: #{path} (#{e.message})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_json(path)
|
|
97
|
+
parse_buffer(read_source(path), path)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_schema!(data)
|
|
101
|
+
reject_unknown_keys!(data, %w[schema_version source_sha packages], "manifest")
|
|
102
|
+
|
|
103
|
+
schema_version = data["schema_version"]
|
|
104
|
+
unless schema_version.is_a?(Integer) && schema_version == SCHEMA_VERSION
|
|
105
|
+
raise Invalid,
|
|
106
|
+
"unsupported schema_version: #{schema_version.inspect} (expected #{SCHEMA_VERSION})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
validate_source_sha!(data["source_sha"], "manifest source_sha")
|
|
110
|
+
|
|
111
|
+
packages = data["packages"]
|
|
112
|
+
raise Invalid, "packages must be a nonempty array" unless packages.is_a?(Array) && !packages.empty?
|
|
113
|
+
|
|
114
|
+
seen = {}
|
|
115
|
+
packages.each_with_index do |entry, index|
|
|
116
|
+
name = validate_package!(entry, index)
|
|
117
|
+
raise Invalid, "duplicate package name: #{name}" if seen.key?(name)
|
|
118
|
+
|
|
119
|
+
seen[name] = true
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_package!(entry, index)
|
|
124
|
+
label = "packages[#{index}]"
|
|
125
|
+
raise Invalid, "#{label} must be an object" unless entry.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
reject_unknown_keys!(entry, %w[name artifact_version source_sha supersedes], label)
|
|
128
|
+
|
|
129
|
+
name = entry["name"]
|
|
130
|
+
unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
|
|
131
|
+
raise Invalid, "#{label} has invalid ACE gem name: #{name.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
validate_exact_version!(entry["artifact_version"], "#{label}(#{name}) artifact_version")
|
|
135
|
+
validate_source_sha!(entry["source_sha"], "#{label}(#{name}) source_sha")
|
|
136
|
+
validate_supersedes!(entry["supersedes"], entry["artifact_version"], "#{label}(#{name})")
|
|
137
|
+
name
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_exact_version!(raw, label)
|
|
141
|
+
unless raw.is_a?(String) && !raw.strip.empty?
|
|
142
|
+
raise Invalid, "#{label} must be an exact version string, got #{raw.inspect}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
version = begin
|
|
146
|
+
Gem::Version.new(raw)
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
raise Invalid, "#{label} is not a valid version: #{raw.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
return if version.to_s == raw
|
|
152
|
+
|
|
153
|
+
raise Invalid,
|
|
154
|
+
"#{label} must be an exact version, not a requirement or shorthand: #{raw.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_source_sha!(raw, label)
|
|
158
|
+
return if raw.is_a?(String) && raw.match?(SOURCE_SHA_PATTERN)
|
|
159
|
+
|
|
160
|
+
raise Invalid, "#{label} must be a full 40-character commit SHA, got #{raw.inspect}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_supersedes!(raw, artifact_version, label)
|
|
164
|
+
return if raw.nil?
|
|
165
|
+
|
|
166
|
+
unless raw.is_a?(Array)
|
|
167
|
+
raise Invalid, "#{label} supersedes must be an array of version strings"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
artifact = Gem::Version.new(artifact_version)
|
|
171
|
+
seen = {}
|
|
172
|
+
raw.each do |entry|
|
|
173
|
+
validate_exact_version!(entry, "#{label} supersedes entry")
|
|
174
|
+
raise Invalid, "#{label} supersedes duplicates version #{entry}" if seen.key?(entry)
|
|
175
|
+
|
|
176
|
+
seen[entry] = true
|
|
177
|
+
next unless Gem::Version.new(entry) >= artifact
|
|
178
|
+
|
|
179
|
+
raise Invalid,
|
|
180
|
+
"#{label} supersedes must list earlier versions of the same package: #{entry} >= #{artifact_version}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reject_unknown_keys!(hash, allowed, label)
|
|
185
|
+
unknown = hash.keys - allowed
|
|
186
|
+
return if unknown.empty?
|
|
187
|
+
|
|
188
|
+
raise Invalid, "#{label} has unknown fields: #{unknown.sort.join(", ")}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def required_packages_entry(required)
|
|
192
|
+
case required
|
|
193
|
+
when String
|
|
194
|
+
[required, nil]
|
|
195
|
+
when Hash
|
|
196
|
+
reject_unknown_keys!(required, %w[name supersedes], "required package entry")
|
|
197
|
+
name = required["name"]
|
|
198
|
+
unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
|
|
199
|
+
raise Invalid, "required package entry has invalid ACE gem name: #{name.inspect}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
[name, required["supersedes"]]
|
|
203
|
+
else
|
|
204
|
+
raise Invalid, "required package entries must be names or {name, supersedes} maps"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|