1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
5#[serde(rename_all = "snake_case")]
6pub enum CompletionDecision {
7 Completed,
8 NeedsUserInput,
9 Blocked,
10 BudgetExhausted,
11 Interrupted,
12 Failed,
13 #[default]
14 Incomplete,
15 NeedsVerification,
16}
17
18impl CompletionDecision {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 Self::Completed => "completed",
22 Self::NeedsUserInput => "needs_user_input",
23 Self::Blocked => "blocked",
24 Self::BudgetExhausted => "budget_exhausted",
25 Self::Interrupted => "interrupted",
26 Self::Failed => "failed",
27 Self::Incomplete => "incomplete",
28 Self::NeedsVerification => "needs_verification",
29 }
30 }
31
32 pub fn emoji(self) -> &'static str {
33 match self {
34 Self::Completed => "✅",
35 Self::NeedsUserInput => "❓",
36 Self::Blocked => "⏸",
37 Self::BudgetExhausted => "⚠",
38 Self::Interrupted => "⛔",
39 Self::Failed => "❌",
40 Self::Incomplete => "⚠",
41 Self::NeedsVerification => "🔎",
42 }
43 }
44
45 pub fn headline(self) -> &'static str {
46 match self {
47 Self::Completed => "Completed — request satisfied and verified.",
48 Self::NeedsUserInput => "Needs input — more information is still required.",
49 Self::Blocked => "Blocked — waiting on approval or another dependency.",
50 Self::BudgetExhausted => {
51 "Stopped before completion — the iteration budget was exhausted."
52 }
53 Self::Interrupted => "Stopped — the run was interrupted.",
54 Self::Failed => "Failed — the run ended unexpectedly.",
55 Self::Incomplete => "Incomplete — work is still pending.",
56 Self::NeedsVerification => "Needs verification — concrete evidence is still missing.",
57 }
58 }
59
60 pub fn compact_label(self) -> &'static str {
61 match self {
62 Self::Completed => "done",
63 Self::NeedsUserInput => "reply needed",
64 Self::Blocked => "blocked",
65 Self::BudgetExhausted => "budget hit",
66 Self::Interrupted => "interrupted",
67 Self::Failed => "failed",
68 Self::Incomplete => "incomplete",
69 Self::NeedsVerification => "verify",
70 }
71 }
72
73 pub fn operator_hint(self) -> Option<&'static str> {
74 match self {
75 Self::NeedsUserInput => Some("Reply below and EdgeCrab can continue immediately."),
76 Self::Blocked => Some("Resolve the dependency or approval to let the run advance."),
77 Self::Incomplete => {
78 Some("The harness kept the run honest because unfinished work remained.")
79 }
80 Self::NeedsVerification => {
81 Some("The finish line only counts once there is concrete evidence.")
82 }
83 _ => None,
84 }
85 }
86
87 pub fn is_success(self) -> bool {
88 matches!(self, Self::Completed)
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
94#[serde(rename_all = "snake_case")]
95pub enum ExitReason {
96 ModelReturnedFinalText,
97 #[default]
98 NoMoreToolCalls,
99 BudgetExhausted,
100 Interrupted,
101 AwaitingClarification,
102 AwaitingApproval,
103 PendingTasks,
104 VerificationPending,
105 ToolFailure,
106 ModelError,
107}
108
109impl ExitReason {
110 pub fn as_str(self) -> &'static str {
111 match self {
112 Self::ModelReturnedFinalText => "model_returned_final_text",
113 Self::NoMoreToolCalls => "no_more_tool_calls",
114 Self::BudgetExhausted => "budget_exhausted",
115 Self::Interrupted => "interrupted",
116 Self::AwaitingClarification => "awaiting_clarification",
117 Self::AwaitingApproval => "awaiting_approval",
118 Self::PendingTasks => "pending_tasks",
119 Self::VerificationPending => "verification_pending",
120 Self::ToolFailure => "tool_failure",
121 Self::ModelError => "model_error",
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
128pub struct VerificationSummary {
129 #[serde(default)]
130 pub required: bool,
131 #[serde(default)]
132 pub evidence_present: bool,
133 #[serde(default)]
134 pub evidence: Vec<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub debt_reason: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
141pub struct RunOutcome {
142 pub state: CompletionDecision,
143 pub exit_reason: ExitReason,
144 pub user_summary: String,
145 #[serde(default)]
146 pub evidence: Vec<String>,
147 #[serde(default)]
148 pub verification: VerificationSummary,
149 #[serde(default)]
150 pub active_tasks: usize,
151 #[serde(default)]
152 pub blocked_tasks: usize,
153}
154
155impl RunOutcome {
156 pub fn new(
157 state: CompletionDecision,
158 exit_reason: ExitReason,
159 user_summary: impl Into<String>,
160 ) -> Self {
161 Self {
162 state,
163 exit_reason,
164 user_summary: user_summary.into(),
165 evidence: Vec::new(),
166 verification: VerificationSummary::default(),
167 active_tasks: 0,
168 blocked_tasks: 0,
169 }
170 }
171
172 pub fn is_success(&self) -> bool {
173 self.state.is_success()
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ReportedTaskStatus {
180 pub status: TaskStatusKind,
181 pub summary: String,
182 #[serde(default)]
183 pub evidence: Vec<String>,
184 #[serde(default)]
185 pub remaining_steps: Vec<String>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
190#[serde(rename_all = "snake_case")]
191pub enum TaskStatusKind {
192 InProgress,
193 Blocked,
194 Completed,
195}
196
197impl TaskStatusKind {
198 pub fn as_str(self) -> &'static str {
199 match self {
200 Self::InProgress => "in_progress",
201 Self::Blocked => "blocked",
202 Self::Completed => "completed",
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn completion_decision_string_labels_are_stable() {
213 assert_eq!(CompletionDecision::Completed.as_str(), "completed");
214 assert_eq!(CompletionDecision::Completed.emoji(), "✅");
215 assert_eq!(CompletionDecision::Completed.compact_label(), "done");
216 assert_eq!(
217 CompletionDecision::NeedsVerification.as_str(),
218 "needs_verification"
219 );
220 }
221
222 #[test]
223 fn run_outcome_defaults_to_incomplete() {
224 let outcome = RunOutcome::default();
225 assert_eq!(outcome.state, CompletionDecision::Incomplete);
226 assert_eq!(outcome.exit_reason, ExitReason::NoMoreToolCalls);
227 }
228
229 #[test]
230 fn reported_task_status_round_trips() {
231 let status = ReportedTaskStatus {
232 status: TaskStatusKind::Completed,
233 summary: "tests passed".into(),
234 evidence: vec!["cargo test --workspace".into()],
235 remaining_steps: Vec::new(),
236 };
237
238 let json = serde_json::to_string(&status).expect("json");
239 let parsed: ReportedTaskStatus = serde_json::from_str(&json).expect("parse");
240 assert_eq!(parsed.status, TaskStatusKind::Completed);
241 assert_eq!(parsed.evidence.len(), 1);
242 }
243}