ace-test-runner-e2e 0.40.6 → 0.42.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: 8d69fa4e48a31e4dd0f92cf961dfcc9b1c2fca7b392e61519add56ab806bc492
4
- data.tar.gz: d07a32cde8ab19937798129a563d65ef97884caf0158ce0dd5c6f757fdec352c
3
+ metadata.gz: f8c03365266949f68dd3364885060b9c27b4087d3ff84b0b42ac5c231ab092ed
4
+ data.tar.gz: ea5b5009323c90d85ec27e417ae012ea09c2af8838b248bf8057caf2fd9174cd
5
5
  SHA512:
6
- metadata.gz: d6c7df27616d263629bcda75f2a3ff6eb098170115a8ad3a21cab96ff3e860a380f555c9d06603baef3f6922a7616880392c79cace2888ae89da41d40e55a275
7
- data.tar.gz: 39158b36f9a7b20d5c1e7ee8c5ae27b488482e4724d63758234a59700ca98ec67c8ac03e6783d5b1369b2fafa027db8013f188ad2c70716a330f2ac4bfeeb12b
6
+ metadata.gz: e3a65486f96beb2288ca5648d16305da412e1d90f69afc6586818db90f029e8d3dbb3037ca3a780619cf0b067d18d2c96b739dcdf724c7a4dd6ad0a090b00dce
7
+ data.tar.gz: 34c8755617d888e23307c21c53b21fd0de777195c6c6ac77d7eaf79cc3cc0efe8ba9fd5b8e1741e5ddb29ccaea7d9c181eb10c80b541d026d24d2713edadda9d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.42.0] - 2026-10-04
11
+
12
+ ### Added
13
+ - The `tmux-session` setup step is replaced by `runtime-session`: `runtime: tmux` (default) keeps the detached-session behavior and exports `ACE_TMUX_SESSION` + `ACE_RUNTIME=tmux`; `runtime: herdr` inherits the caller's live herdr environment and exports `ACE_RUNTIME=herdr`, failing explicitly when no herdr context is live (no fabricated stand-in).
14
+
15
+ ## [0.41.0] - 2026-09-29
16
+
17
+ ### Changed
18
+ - 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.
19
+ - 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.
20
+ - 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).
21
+ - `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.
22
+ - Verifier failures report `infrastructure-error` (runner/repair failures keep `runner-error`).
23
+ - TS-MONO-001-style scenarios can declare a `release-manifest` setup action: the runner
24
+ carries only the explicitly allowed `ACE_RELEASE_MANIFEST` host input (absolute path,
25
+ defaulting to the source root) into deterministic setup, which validates the strict
26
+ schema-version-1 manifest and copies its exact bytes into the sandbox before any
27
+ install goal can run. Invalid or missing input fails setup, and a failed deterministic
28
+ setup now aborts the scenario with an ERROR result instead of degrading to an
29
+ LLM-driven sandbox.
30
+
10
31
  ## [0.40.6] - 2026-09-29
11
32
 
12
33
  ### Fixed
@@ -112,17 +112,18 @@ Available directives:
112
112
  - `copy-fixtures` — Copy fixtures/ directory into sandbox
113
113
  - `write-file:` — Write inline content to a file (`path:` + `content:`)
114
114
  - `agent-env:` — Environment variables passed to the runner/verifier agent subprocess (not setup commands)
115
- - `tmux-session` — Create a detached tmux session
116
- - String form: `tmux-session` (uses scenario-based naming)
117
- - Hash form: `tmux-session: { name-source: run-id }` (uses unique E2E run ID as session name)
118
- - Runner teardown removes the created session after test execution
115
+ - `runtime-session` — Establish a live terminal runtime context for the scenario
116
+ - String form: `runtime-session` (tmux session with scenario-based naming)
117
+ - Hash form: `runtime-session: { name-source: run-id }` (tmux session named by the E2E run ID)
118
+ - Hash form: `runtime-session: { runtime: herdr }` (inherits the caller's live HERDR_SESSION/HERDR_PANE; fails explicitly when absent)
119
+ - tmux sessions export ACE_TMUX_SESSION; both runtimes export ACE_RUNTIME. tmux sessions are removed by runner teardown after test execution
119
120
 
120
121
  Example:
121
122
 
122
123
  ```yaml
123
124
  setup:
124
125
  - git-init
125
- - tmux-session:
126
+ - runtime-session:
126
127
  name-source: run-id
127
128
  - run: "cp ${ACE_E2E_SOURCE_ROOT:-$PROJECT_ROOT_PATH}/mise.toml mise.toml && mise trust mise.toml"
128
129
  - copy-fixtures
@@ -51,14 +51,14 @@ requires:
51
51
  ruby: ">= 3.0"
52
52
 
53
53
  # Setup directives (executed before test cases)
54
- # Available: git-init, run:, copy-fixtures, write-file:, agent-env:, tmux-session
54
+ # Available: git-init, run:, copy-fixtures, write-file:, agent-env:, runtime-session
55
55
  # Rules:
56
56
  # - setup is fail-fast (do not use `|| true`)
57
57
  # - fixtures/setup own sandbox preparation; runner TCs do not re-implement setup
58
58
  setup:
59
59
  - git-init
60
60
  # Optional detached tmux session for test isolation (uses unique run ID)
61
- # - tmux-session:
61
+ # - runtime-session:
62
62
  # name-source: run-id
63
63
  - run: "cp ${ACE_E2E_SOURCE_ROOT:-$PROJECT_ROOT_PATH}/mise.toml mise.toml && mise trust mise.toml"
64
64
  # Uncomment as needed:
@@ -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(run_options.merge(only_failures: true))
195
- if retry_results[:total].zero?
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,
@@ -6,15 +6,23 @@ module Ace
6
6
  module Test
7
7
  module EndToEndRunner
8
8
  module Molecules
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
- # Note: This is a Molecule (not an Atom) because it performs filesystem
16
- # I/O via Dir.glob and YAML file reading.
17
- class FailureFinder
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,
@@ -163,9 +163,32 @@ module Ace
163
163
  match ? match[1].upcase : nil
164
164
  end
165
165
 
166
+ # Bundler machinery (installed gem trees, compact-index caches,
167
+ # config, shared-gem dirs) is install plumbing, not verifier
168
+ # evidence: `--full-index` alone pulls a ~97MB compact index, which
169
+ # previously got inlined into the verifier prompt whole.
170
+ BUNDLER_MACHINE_SEGMENTS = %w[
171
+ .bundle .gem bundler-cache bundler-home bundler-config
172
+ compact_index specifications cache bin
173
+ ].freeze
174
+ MAX_ARTIFACT_FILE_BYTES = 256 * 1024
175
+ MAX_ARTIFACT_CONTENT_BYTES = 2 * 1024 * 1024
176
+
177
+ def artifact_evidence?(relative_path)
178
+ return false if relative_path.start_with?(".bundle/", ".gem/")
179
+
180
+ BUNDLER_MACHINE_SEGMENTS.none? do |segment|
181
+ relative_path.include?("/#{segment}/") || relative_path.start_with?("#{segment}/")
182
+ end
183
+ end
184
+
166
185
  def build_artifact_section(sandbox_path)
167
186
  sandbox_path = File.expand_path(sandbox_path)
168
- files = Dir.glob(File.join(sandbox_path, "results", "**", "*")).select { |f| File.file?(f) }.sort
187
+ files = Dir.glob(File.join(sandbox_path, "results", "**", "*"))
188
+ .select { |f| File.file?(f) }
189
+ .map { |f| File.expand_path(f) }
190
+ .select { |f| artifact_evidence?(relative_path(f, sandbox_path)) }
191
+ .sort
169
192
  tree_entries = files.map { |f| relative_path(f, sandbox_path) }
170
193
 
171
194
  parts = []
@@ -186,12 +209,21 @@ module Ace
186
209
  parts << "## File contents"
187
210
  parts << ""
188
211
 
212
+ content_budget = MAX_ARTIFACT_CONTENT_BYTES
189
213
  files.each do |file|
214
+ size = File.size(file)
215
+ body = if size <= MAX_ARTIFACT_FILE_BYTES
216
+ safe_read(file)
217
+ else
218
+ "#{safe_read(file, MAX_ARTIFACT_FILE_BYTES)}\n... [truncated, #{size} bytes total]"
219
+ end
220
+ content_budget -= body.bytesize
190
221
  parts << "### `#{relative_path(file, sandbox_path)}`"
191
222
  parts << "```"
192
- parts << safe_read(file)
223
+ parts << body
193
224
  parts << "```"
194
225
  parts << ""
226
+ break if content_budget <= 0
195
227
  end
196
228
 
197
229
  parts.join("\n").rstrip
@@ -324,8 +356,9 @@ module Ace
324
356
  File.expand_path(path).sub("#{File.expand_path(root)}/", "")
325
357
  end
326
358
 
327
- def safe_read(path)
328
- File.binread(path).encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
359
+ def safe_read(path, limit = nil)
360
+ raw = limit ? File.binread(path, limit) : File.binread(path)
361
+ raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
329
362
  end
330
363
  end
331
364
  end
@@ -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:, metadata: {})
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
- private
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
- def parse_verifier_output(text, scenario)
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