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