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 speccoder_core::scope::ScopeFinding;
5use speccoder_core::verify::Verdict;
6use speccoder_core::{CommandStatus, RunReport};
7
8use crate::progress;
9
10use super::{add_usage, monitor_summary};
11
12/// What the attempt changed beyond the paths the task line `title` names
13/// (speccoder's own acceptance tests exempt).
14pub(crate) fn scope_of(report: &RunReport, title: &str) -> ScopeFinding {
15    let exempt: Vec<String> = report
16        .acceptance
17        .as_ref()
18        .and_then(|a| a["files"].as_array())
19        .into_iter()
20        .flatten()
21        .filter_map(|f| f.as_str().map(str::to_owned))
22        .collect();
23    let changed: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
24    let named = speccoder_core::scope::named_paths(title);
25    speccoder_core::scope::finding(&named, &changed, &exempt)
26}
27
28/// The outcome and reason an attempt that did not pass is saved with.
29pub(crate) fn saved_outcome(
30    report: &RunReport,
31    attempt_status: &str,
32    suspected: bool,
33) -> (String, String) {
34    let outcome = match attempt_status {
35        _ if suspected => "suspected_gaming".to_owned(),
36        "completed" | "integrity" => "verification_failed".to_owned(),
37        other => other.to_owned(),
38    };
39    let reason = match &report.verdict {
40        _ if suspected => monitor_summary(report.monitor.as_ref()),
41        Some(Verdict::Failed { reasons }) => reasons.join("; "),
42        _ => report
43            .reason
44            .clone()
45            .unwrap_or_else(|| attempt_status.to_owned()),
46    };
47    (outcome, reason)
48}
49
50/// The command's status and message for a finished task run.
51pub(crate) fn status_and_message(
52    report: &RunReport,
53    attempt_status: &str,
54    suspected: bool,
55) -> (CommandStatus, String) {
56    match (&report.verdict, attempt_status) {
57        (_, "over_strict") => (
58            CommandStatus::NeedsHuman,
59            format!(
60                "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}`)",
61                report.reason.clone().unwrap_or_default(),
62                task = report.task
63            ),
64        ),
65        (_, "acceptance_weak") => (
66            CommandStatus::NeedsHuman,
67            format!(
68                "the checks passed, but the {}; the attempt is saved, not ticked",
69                report.reason.clone().unwrap_or_default()
70            ),
71        ),
72        _ if suspected => (
73            CommandStatus::NeedsHuman,
74            format!(
75                "the checks passed, but the gaming monitor suspects: {}; the attempt is saved, not ticked — review it, then `speccoder task accept` or run again",
76                monitor_summary(report.monitor.as_ref())
77            ),
78        ),
79        (_, "incomplete") => (
80            CommandStatus::NeedsHuman,
81            format!(
82                "attempt incomplete by the model's own account{}",
83                if report.changes.is_empty() {
84                    "; no files changed"
85                } else {
86                    ""
87                }
88            ),
89        ),
90        (Some(Verdict::Failed { reasons }), _) => (
91            CommandStatus::VerificationFailed,
92            format!("verdict: failed — {}", reasons.join("; ")),
93        ),
94        (Some(v), _) => (CommandStatus::Ok, format!("verdict: {}", verdict_word(v))),
95        (
96            None,
97            "blocked" | "needs_approval" | "budget_exhausted" | "denied" | "interrupted"
98            | "held_out" | "intent_changed" | "judge_changed" | "checkout_changed" | "git_changed",
99        ) => (
100            CommandStatus::NeedsHuman,
101            format!(
102                "attempt {attempt_status}: {}",
103                report
104                    .reason
105                    .clone()
106                    .or_else(|| report
107                        .pending
108                        .as_ref()
109                        .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
110                    .unwrap_or_default()
111            ),
112        ),
113        (None, _) => (
114            CommandStatus::RunFailed,
115            format!(
116                "attempt failed: {}",
117                report.reason.clone().unwrap_or_default()
118            ),
119        ),
120    }
121}
122
123/// `verified`, `regression_only` or `failed`.
124pub(crate) fn verdict_word(v: &Verdict) -> String {
125    serde_json::to_value(v)
126        .ok()
127        .and_then(|v| v["verdict"].as_str().map(str::to_owned))
128        .unwrap_or_default()
129}
130
131/// The progress event that ends a task run; `message` is already redacted.
132pub(crate) fn finished_event(
133    task: &str,
134    report: &RunReport,
135    attempt_status: &str,
136    suspected: bool,
137    message: String,
138) -> progress::Event {
139    let total = add_usage(
140        report.usage.as_ref(),
141        report.acceptance.as_ref().and_then(|a| a.get("usage")),
142    );
143    let tokens = |k: &str| total.as_ref().and_then(|u| u[k].as_u64()).unwrap_or(0);
144    progress::Event::TaskFinished {
145        task: task.to_owned(),
146        // The verdict for a completed attempt; otherwise how the attempt ended
147        // (an incomplete attempt with green checks is not a pass).
148        outcome: if suspected {
149            "suspected_gaming".to_owned()
150        } else if attempt_status == "completed" {
151            report
152                .verdict
153                .as_ref()
154                .map_or_else(|| attempt_status.to_owned(), verdict_word)
155        } else {
156            attempt_status.to_owned()
157        },
158        message,
159        run: (!report.run_id.is_empty()).then(|| report.run_id.clone()),
160        input_tokens: tokens("input_tokens"),
161        output_tokens: tokens("output_tokens"),
162        cache_read_tokens: tokens("cache_read_tokens"),
163        cache_write_tokens: tokens("cache_write_tokens"),
164        usd: None,
165    }
166}
167
168/// A run report with nothing established yet.
169pub(crate) fn bare_report(
170    feature: &str,
171    task: &str,
172    worktree: &str,
173    attempt_status: &str,
174) -> RunReport {
175    RunReport {
176        run_id: String::new(),
177        feature: feature.to_owned(),
178        task: task.to_owned(),
179        worktree: worktree.to_owned(),
180        attempt_status: attempt_status.to_owned(),
181        model_report: None,
182        reason: None,
183        pending: None,
184        tool_calls: Vec::new(),
185        changes: Vec::new(),
186        verification: None,
187        verdict: None,
188        usage: None,
189        integrity: Vec::new(),
190        ticked: false,
191        commit: None,
192        acceptance: None,
193        earlier: Vec::new(),
194        retry_stop: None,
195        saved_attempt: None,
196        set_aside: None,
197        notices: Vec::new(),
198        monitor: None,
199        mutation: None,
200        samples: Vec::new(),
201        scope: None,
202        attestation: None,
203        instructions: Vec::new(),
204        strength: None,
205    }
206}
207
208/// Why a convergence task's gap is not demonstrated: no examples, no tests
209/// written, or tests that do not fail on an assertion before any change.
210pub(crate) fn undemonstrated_why(acceptance: Option<&super::PreparedAcceptance>) -> String {
211    match acceptance {
212        None => "the spec has no acceptance examples to test it against".to_owned(),
213        Some(a) => match (a.report["skipped"].as_str(), a.report["note"].as_str()) {
214            (Some(s), _) => format!("no acceptance tests ({s})"),
215            (None, Some(note)) => match a.report["excerpt"].as_str().and_then(|e| e.lines().next())
216            {
217                Some(first) => format!("{note}: {first}"),
218                None => note.to_owned(),
219            },
220            (None, None) => "its acceptance tests already pass before any change".to_owned(),
221        },
222    }
223}