Skip to main content

speccoder_runtime/pipeline/
outcome.rs

1//! How a task run ends: its scope finding, what a failed attempt is saved
2//! as, the command's status and message, and the progress event.
3
4use serde_json::Value;
5use speccoder_core::scope::ScopeFinding;
6use speccoder_core::verify::Verdict;
7use speccoder_core::{CommandStatus, RunReport};
8
9use crate::progress;
10
11use super::{add_usage, monitor_summary};
12
13/// What the attempt changed beyond the paths the task line `title` names
14/// (speccoder's own acceptance tests exempt).
15pub(crate) fn scope_of(report: &RunReport, title: &str) -> ScopeFinding {
16    let exempt: Vec<String> = report
17        .acceptance
18        .as_ref()
19        .and_then(|a| a["files"].as_array())
20        .into_iter()
21        .flatten()
22        .filter_map(|f| f.as_str().map(str::to_owned))
23        .collect();
24    let changed: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
25    let named = speccoder_core::scope::named_paths(title);
26    speccoder_core::scope::finding(&named, &changed, &exempt)
27}
28
29/// The outcome and reason an attempt that did not pass is saved with.
30pub(crate) fn saved_outcome(
31    report: &RunReport,
32    attempt_status: &str,
33    suspected: bool,
34) -> (String, String) {
35    let outcome = match attempt_status {
36        _ if suspected => "suspected_gaming".to_owned(),
37        "completed" | "integrity" => "verification_failed".to_owned(),
38        other => other.to_owned(),
39    };
40    let reason = match &report.verdict {
41        _ if suspected => monitor_summary(report.monitor.as_ref()),
42        Some(Verdict::Failed { reasons }) => failed_because(report, reasons),
43        _ => report
44            .reason
45            .clone()
46            .unwrap_or_else(|| attempt_status.to_owned()),
47    };
48    (outcome, reason)
49}
50
51/// The command's status and message for a finished task run.
52pub(crate) fn status_and_message(
53    report: &RunReport,
54    attempt_status: &str,
55    suspected: bool,
56) -> (CommandStatus, String) {
57    match (&report.verdict, attempt_status) {
58        (_, "over_strict") => (
59            CommandStatus::NeedsHuman,
60            format!(
61                "the checks passed, but {}; the attempt is held, not ticked — rule on it (`speccoder task rule {task} --tests-right --reason …`), regenerate the tests (`speccoder run --task {task} --fresh-acceptance`) or accept it (`speccoder task accept {task}`)",
62                report.reason.clone().unwrap_or_default(),
63                task = report.task
64            ),
65        ),
66        (_, "acceptance_weak") => (
67            CommandStatus::NeedsHuman,
68            format!(
69                "the checks passed, but the {}; the attempt is saved, not ticked",
70                report.reason.clone().unwrap_or_default()
71            ),
72        ),
73        _ if suspected => (
74            CommandStatus::NeedsHuman,
75            format!(
76                "the checks passed, but the gaming monitor suspects: {}; the attempt is saved, not ticked — review it, then `speccoder task accept` or run again",
77                monitor_summary(report.monitor.as_ref())
78            ),
79        ),
80        (_, "incomplete") => (
81            CommandStatus::NeedsHuman,
82            format!(
83                "attempt incomplete — {}{}",
84                incomplete_because(report.model_report.as_ref()),
85                if report.changes.is_empty() {
86                    "; no files changed"
87                } else {
88                    ""
89                }
90            ),
91        ),
92        (None, "collection_unknown") => (
93            CommandStatus::NeedsHuman,
94            format!(
95                "{}; the attempt is saved, not ticked",
96                report.reason.clone().unwrap_or_default()
97            ),
98        ),
99        (Some(Verdict::Failed { reasons }), _) => (
100            CommandStatus::VerificationFailed,
101            format!("verdict: failed — {}", failed_because(report, reasons)),
102        ),
103        (Some(v), _) => (CommandStatus::Ok, format!("verdict: {}", verdict_word(v))),
104        // A call nobody could ask about: named, with how to let it run.
105        (None, "needs_approval") if report.pending.as_ref().is_some_and(Value::is_object) => (
106            CommandStatus::NeedsHuman,
107            format!(
108                "attempt needs_approval: {}",
109                crate::tools::policy::unasked(
110                    report.pending.as_ref().unwrap_or(&Value::Null),
111                    &format!(
112                        "approve it (`speccoder task approve {}`), or run with prompts (without `--no-prompt`) to be asked",
113                        report.task
114                    ),
115                )
116            ),
117        ),
118        (
119            None,
120            "blocked" | "needs_approval" | "budget_exhausted" | "denied" | "interrupted"
121            | "held_out" | "intent_changed" | "judge_changed" | "checkout_changed" | "git_changed",
122        ) => (
123            CommandStatus::NeedsHuman,
124            format!(
125                "attempt {attempt_status}: {}",
126                report
127                    .reason
128                    .clone()
129                    .or_else(|| report
130                        .pending
131                        .as_ref()
132                        .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
133                    .unwrap_or_default()
134            ),
135        ),
136        (None, _) => (
137            CommandStatus::RunFailed,
138            format!(
139                "attempt failed: {}",
140                report.reason.clone().unwrap_or_default()
141            ),
142        ),
143    }
144}
145
146/// The verdict's reasons, a failed check's with what it said first
147/// (``check `acceptance` failed: No module named pytest``).
148fn failed_because(report: &RunReport, reasons: &[String]) -> String {
149    let checks = report
150        .verification
151        .as_ref()
152        .map(|v| v.checks.as_slice())
153        .unwrap_or_default();
154    reasons
155        .iter()
156        .map(|r| {
157            let named = r
158                .strip_prefix("check `")
159                .and_then(|n| n.split_once('`'))
160                .map(|(n, _)| n);
161            match named
162                .and_then(|n| checks.iter().find(|c| c.name == n))
163                .and_then(speccoder_core::retry::first_cause)
164            {
165                Some(cause) => format!("{r}: {cause}"),
166                None => r.clone(),
167            }
168        })
169        .collect::<Vec<_>>()
170        .join("; ")
171}
172
173/// What the model's closing report says is missing — its notes, else its
174/// summary — quoted as its claim, cut at 400 characters.
175fn incomplete_because(model_report: Option<&Value>) -> String {
176    let report = model_report.unwrap_or(&Value::Null);
177    let notes: Vec<&str> = report["notes"]
178        .as_array()
179        .into_iter()
180        .flatten()
181        .filter_map(Value::as_str)
182        .map(str::trim)
183        .filter(|n| !n.is_empty())
184        .collect();
185    let said = if notes.is_empty() {
186        report["summary"]
187            .as_str()
188            .unwrap_or_default()
189            .trim()
190            .to_owned()
191    } else {
192        notes.join(" ")
193    };
194    if said.is_empty() {
195        return "the model says it did not finish".to_owned();
196    }
197    let said = crate::approvals::present::escape(&said.replace('\n', " "));
198    let cut: String = said.chars().take(400).collect();
199    let more = if cut.len() < said.len() { "…" } else { "" };
200    format!("the model says: “{cut}{more}”")
201}
202
203/// `verified`, `regression_only` or `failed`.
204pub(crate) fn verdict_word(v: &Verdict) -> String {
205    serde_json::to_value(v)
206        .ok()
207        .and_then(|v| v["verdict"].as_str().map(str::to_owned))
208        .unwrap_or_default()
209}
210
211/// The progress event that ends a task run; `message` is already redacted.
212pub(crate) fn finished_event(
213    task: &str,
214    report: &RunReport,
215    attempt_status: &str,
216    suspected: bool,
217    message: String,
218) -> progress::Event {
219    let total = add_usage(
220        report.usage.as_ref(),
221        report.acceptance.as_ref().and_then(|a| a.get("usage")),
222    );
223    let tokens = |k: &str| total.as_ref().and_then(|u| u[k].as_u64()).unwrap_or(0);
224    progress::Event::TaskFinished {
225        task: task.to_owned(),
226        // The verdict for a completed attempt; otherwise how the attempt ended
227        // (an incomplete attempt with green checks is not a pass).
228        outcome: if suspected {
229            "suspected_gaming".to_owned()
230        } else if attempt_status == "completed" {
231            report
232                .verdict
233                .as_ref()
234                .map_or_else(|| attempt_status.to_owned(), verdict_word)
235        } else {
236            attempt_status.to_owned()
237        },
238        message,
239        run: (!report.run_id.is_empty()).then(|| report.run_id.clone()),
240        input_tokens: tokens("input_tokens"),
241        output_tokens: tokens("output_tokens"),
242        cache_read_tokens: tokens("cache_read_tokens"),
243        cache_write_tokens: tokens("cache_write_tokens"),
244        usd: None,
245        regression: report.regression.clone(),
246    }
247}
248
249/// A run report with nothing established yet.
250pub(crate) fn bare_report(
251    feature: &str,
252    task: &str,
253    worktree: &str,
254    attempt_status: &str,
255) -> RunReport {
256    RunReport {
257        run_id: String::new(),
258        feature: feature.to_owned(),
259        task: task.to_owned(),
260        worktree: worktree.to_owned(),
261        attempt_status: attempt_status.to_owned(),
262        model_report: None,
263        reason: None,
264        pending: None,
265        tool_calls: Vec::new(),
266        changes: Vec::new(),
267        verification: None,
268        verdict: None,
269        usage: None,
270        integrity: Vec::new(),
271        ticked: false,
272        commit: None,
273        acceptance: None,
274        earlier: Vec::new(),
275        retry_stop: None,
276        saved_attempt: None,
277        set_aside: None,
278        notices: Vec::new(),
279        agent: None,
280        monitor: None,
281        mutation: None,
282        samples: Vec::new(),
283        scope: None,
284        attestation: None,
285        instructions: Vec::new(),
286        strength: None,
287        bug: None,
288        collection: None,
289        regression: None,
290        share: None,
291        context: None,
292    }
293}
294
295/// Why a convergence task's gap is not demonstrated: no examples, no tests
296/// written, or tests that do not fail on an assertion before any change.
297pub(crate) fn undemonstrated_why(acceptance: Option<&super::PreparedAcceptance>) -> String {
298    match acceptance {
299        None => "the spec has no acceptance examples to test it against".to_owned(),
300        Some(a) => match (a.report["skipped"].as_str(), a.report["note"].as_str()) {
301            (Some(s), _) => format!("no acceptance tests ({s})"),
302            (None, Some(note)) => match a.report["excerpt"].as_str().and_then(|e| e.lines().next())
303            {
304                Some(first) => format!("{note}: {first}"),
305                None => note.to_owned(),
306            },
307            (None, None) => "its acceptance tests already pass before any change".to_owned(),
308        },
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use serde_json::json;
316
317    #[test]
318    fn a_failed_check_says_what_it_said_first() {
319        use speccoder_core::verify::{CheckResult, CheckStatus, VerifyReport};
320        let mut report = bare_report("f", "T007", "w", "completed");
321        report.verification = Some(VerifyReport {
322            root: String::new(),
323            tier: "fast".to_owned(),
324            checks: vec![CheckResult {
325                name: "acceptance".to_owned(),
326                argv: vec!["python3".to_owned()],
327                status: CheckStatus::Failed,
328                exit_code: Some(1),
329                duration_ms: 0,
330                junit: false,
331                tests: None,
332                stdout_tail: String::new(),
333                stderr_tail: "/usr/bin/python3: No module named pytest\n".to_owned(),
334                truncated: false,
335                note: None,
336                flaky: false,
337            }],
338            comparison: None,
339            passed: false,
340        });
341        let reasons = [
342            "check `acceptance` failed".to_owned(),
343            "regression: t is failed".to_owned(),
344        ];
345        assert_eq!(
346            failed_because(&report, &reasons),
347            "check `acceptance` failed: /usr/bin/python3: No module named pytest; regression: t is failed"
348        );
349    }
350
351    #[test]
352    fn an_incomplete_attempt_quotes_what_the_model_says_is_missing() {
353        let notes =
354            json!({ "summary": "s", "notes": ["", "pytest is \u{1b}[31mmissing", "so I stopped"] });
355        assert_eq!(
356            incomplete_because(Some(&notes)),
357            "the model says: “pytest is ␛[31mmissing so I stopped”"
358        );
359        let summary = json!({ "summary": "only the plumbing\nso far", "notes": [] });
360        assert_eq!(
361            incomplete_because(Some(&summary)),
362            "the model says: “only the plumbing so far”"
363        );
364        assert_eq!(incomplete_because(None), "the model says it did not finish");
365        let long = json!({ "notes": ["é".repeat(500)] });
366        assert!(incomplete_because(Some(&long)).ends_with("é…”"));
367    }
368}