speccoder_runtime/pipeline/
judge.rs1use std::collections::BTreeSet;
6use std::path::Path;
7
8use serde_json::Value;
9use speccoder_core::RunReport;
10use speccoder_core::verify::{CheckStatus, Verdict, VerifyReport, compare_except, passes, verdict};
11
12use crate::tools::policy::{Object, Policy};
13use crate::{integrity, sandbox, verify, worktree};
14
15use super::{PreparedAcceptance, run_acceptance};
16
17pub(crate) fn approved_writes(granted: &[Object], tool_calls: &Value) -> BTreeSet<String> {
22 granted
23 .iter()
24 .filter_map(|g| g.path().map(str::to_owned))
25 .chain(
26 tool_calls
27 .as_array()
28 .into_iter()
29 .flatten()
30 .filter(|c| c["approved_by"] == "person")
31 .filter_map(|c| {
32 serde_json::from_value::<Object>(c["object"].clone())
33 .ok()?
34 .path()
35 .map(str::to_owned)
36 }),
37 )
38 .collect()
39}
40
41pub(crate) struct Audited<'a> {
43 pub(crate) wt_root: &'a Path,
44 pub(crate) policy: &'a Policy,
45 pub(crate) judged_before: &'a integrity::JudgeSnapshot,
47 pub(crate) raw_changes: &'a [worktree::Change],
48 pub(crate) patch: &'a str,
49 pub(crate) frozen: &'a [String],
50 pub(crate) frozen_additions: bool,
51 pub(crate) approved_writes: &'a BTreeSet<String>,
52 pub(crate) command_drift: &'a Value,
54}
55
56pub(crate) fn attempt_findings(a: &Audited<'_>) -> Vec<String> {
60 let unseen = a
61 .judged_before
62 .drift(
63 &integrity::JudgeSnapshot::take(a.wt_root, a.policy),
64 a.policy,
65 a.approved_writes,
66 )
67 .into_iter()
68 .filter(|d| !a.raw_changes.iter().any(|c| c.path == d.path))
69 .map(|d| format!("integrity: {} input {}: {}", d.class, d.change, d.path));
70 let frozen: BTreeSet<String> = a
71 .frozen
72 .iter()
73 .filter(|p| !a.approved_writes.contains(*p))
74 .cloned()
75 .collect();
76 let audited: Vec<worktree::Change> = a
78 .raw_changes
79 .iter()
80 .filter(|c| !a.policy.is_planning_write(&c.path))
81 .cloned()
82 .collect();
83 let mut findings: Vec<String> = integrity::audit_in(
84 a.policy.protected(),
85 &audited,
86 a.patch,
87 &frozen,
88 a.frozen_additions,
89 a.approved_writes,
90 )
91 .into_iter()
92 .map(|f| format!("integrity: {f}"))
93 .chain(unseen)
94 .collect();
95 for d in a.command_drift.as_array().into_iter().flatten() {
96 let finding = format!(
97 "integrity: judge input changed by a command: {} ({})",
98 d["path"].as_str().unwrap_or_default(),
99 d["change"].as_str().unwrap_or_default()
100 );
101 if !findings.contains(&finding) {
102 findings.push(finding);
103 }
104 }
105 findings
106}
107
108pub(crate) fn changes_of(raw: Vec<worktree::Change>) -> Vec<(String, String)> {
110 raw.into_iter()
111 .map(|c| {
112 let kind = match c.kind {
113 worktree::ChangeKind::Added => "added",
114 worktree::ChangeKind::Modified => "modified",
115 worktree::ChangeKind::Deleted => "deleted",
116 };
117 (c.path, kind.to_owned())
118 })
119 .collect()
120}
121
122pub(crate) fn judge_checks(
128 project: &Path,
129 runner: &sandbox::Runner,
130 (baseline, oracle): (&VerifyReport, &[String]),
131 acceptance: Option<&PreparedAcceptance>,
132 findings: &[String],
133 report: &mut RunReport,
134) -> Result<(), String> {
135 let mut current = verify::run_checks(project, Some(verify::config::Tier::Fast), runner)?;
136 let mut task_checks: Vec<&str> = Vec::new();
137 let mut uncollected_at_green = false;
138 if let Some(a) = acceptance.filter(|a| a.argv.is_some()) {
139 let argv = a.argv.as_deref().unwrap_or_default();
140 let all: Vec<&[String]> = std::iter::once(argv)
141 .chain(a.more_argv.iter().map(Vec::as_slice))
142 .collect();
143 let counted = std::cell::Cell::new(None);
144 let green = verify::rerun_once(run_all_acceptance(project, &all, runner, &counted), || {
145 run_all_acceptance(project, &all, runner, &counted)
146 });
147 let red_tests = a.report["red_tests"]
151 .as_u64()
152 .and_then(|n| usize::try_from(n).ok());
153 let required = red_tests
154 .unwrap_or_else(|| declared(project, &a.report))
155 .max(1);
156 if let Some(r) = report.acceptance.as_mut() {
157 r["green_tests"] = serde_json::json!(counted.get());
158 r["green_needs"] = serde_json::json!(required);
159 }
160 if a.red
161 && green.status == CheckStatus::Passed
162 && counted.get().is_none_or(|seen| seen < required)
163 {
164 uncollected_at_green = true;
165 }
166 if let Some(r) = report.acceptance.as_mut() {
167 r["green"] = serde_json::json!(green.status == CheckStatus::Passed);
168 if green.flaky {
169 r["green_flaky"] = serde_json::json!(true);
170 r["note"] = serde_json::json!("acceptance passed only on re-run");
171 }
172 }
173 if a.red {
174 task_checks.push("acceptance");
175 }
176 current.checks.push(green);
177 }
178 let cmp = compare_except(baseline, ¤t, oracle);
179 current.passed = passes(¤t.checks, Some(&cmp));
180 let mut judged = verdict(&task_checks, ¤t.checks, Some(&cmp));
181 if uncollected_at_green {
182 let mut reasons = vec![GREEN_UNCOLLECTED.to_owned()];
183 if let Verdict::Failed { reasons: more } = judged {
184 reasons.extend(more);
185 }
186 judged = Verdict::Failed { reasons };
187 }
188 report.verdict = Some(if findings.is_empty() {
189 judged
190 } else {
191 let mut reasons = findings.to_vec();
192 if let Verdict::Failed { reasons: more } = judged {
193 reasons.extend(more);
194 }
195 Verdict::Failed { reasons }
196 });
197 current.comparison = Some(cmp);
198 report.verification = Some(current);
199 Ok(())
200}
201
202fn declared(project: &Path, acceptance: &serde_json::Value) -> usize {
204 acceptance["files"]
205 .as_array()
206 .into_iter()
207 .flatten()
208 .filter_map(|f| f.as_str())
209 .filter_map(|f| {
210 let t = speccoder_core::collection::toolchain_of(f)?;
211 let text = std::fs::read_to_string(project.join(f)).ok()?;
212 Some(speccoder_core::collection::declared_tests(t, &text))
213 })
214 .sum()
215}
216
217pub(crate) const GREEN_UNCOLLECTED: &str = "acceptance tests not collected at green";
219
220fn run_all_acceptance(
224 project: &Path,
225 all: &[&[String]],
226 runner: &sandbox::Runner,
227 counted: &std::cell::Cell<Option<usize>>,
228) -> speccoder_core::verify::CheckResult {
229 let mut first = None;
230 for (i, argv) in all.iter().enumerate() {
231 let (r, summary) = super::acceptance::acceptance_run(project, argv, runner);
232 if i == 0 {
233 counted.set(speccoder_core::verify::collected_count(
234 &r,
235 summary.as_ref(),
236 ));
237 }
238 if r.status != CheckStatus::Passed {
239 return r;
240 }
241 first.get_or_insert(r);
242 }
243 first.unwrap_or_else(|| run_acceptance(project, &[], runner))
244}
245
246pub(crate) fn retry_stop(
248 findings: &[String],
249 verification: &VerifyReport,
250 last_fingerprint: Option<&str>,
251 fingerprint: &str,
252 attempt_no: u32,
253 max_attempts: u32,
254) -> Option<String> {
255 if !findings.is_empty() {
256 Some("integrity findings are not retried".to_owned())
257 } else if let Some(infra) = speccoder_core::retry::infrastructure(verification) {
258 Some(format!("not retried: {infra}"))
259 } else if last_fingerprint == Some(fingerprint) {
260 Some(format!("same failure twice: {fingerprint}"))
261 } else if attempt_no >= max_attempts {
262 Some(format!("{attempt_no} of {max_attempts} attempts used"))
263 } else {
264 None
265 }
266}
267
268pub(crate) fn monitor_attempt(
271 rt: &tokio::runtime::Runtime,
272 plane: &crate::substrate::Plane,
273 feature: &str,
274 task: &str,
275 input: agentplane::core::Tainted<Value>,
276 secret: Option<&str>,
277) -> (Value, bool) {
278 match rt.block_on(plane.monitor(feature, task, input)) {
279 Ok(o) => {
280 let out = o
281 .output
282 .as_ref()
283 .map(|v| v.peek().clone())
284 .unwrap_or(Value::Null);
285 let suspected = out["answer"]["suspected"] == true;
286 (
287 serde_json::json!({
288 "suspected": suspected,
289 "findings": out["answer"]["findings"],
290 "usage": out["usage"],
291 "run_id": o.run_id.to_string(),
292 }),
293 suspected,
294 )
295 }
296 Err(e) => (
297 serde_json::json!({ "error": super::redact(&e.0, secret) }),
298 false,
299 ),
300 }
301}