Skip to main content

ralph/contracts/
blocking.rs

1//! Purpose: Define canonical operator-facing blocked/waiting/stalled state contracts.
2//!
3//! Responsibilities:
4//! - Provide the stable wire model for why Ralph is not making progress.
5//! - Centralize human-readable narration reused by CLI, machine, and app surfaces.
6//! - Keep coarse operator state distinct from per-task runnability details.
7//!
8//! Scope:
9//! - Stable serde/schemars contracts and small constructor helpers.
10//!
11//! Usage:
12//! - Construct `BlockingState` values when queue analysis, lock contention, CI, runner/session
13//!   recovery, or operator-guided continuation explains the current lack of progress.
14//!
15//! Invariants/Assumptions:
16//! - `BlockingReason` is coarse system-level classification, not a per-task blocker dump.
17//! - `message` is the short operator-facing summary; `detail` carries supporting context.
18//! - Fields remain machine-safe and versioned through the surrounding contract documents.
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum BlockingStatus {
26    Waiting,
27    Blocked,
28    Stalled,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum BlockingReason {
34    Idle {
35        include_draft: bool,
36    },
37    DependencyBlocked {
38        blocked_tasks: usize,
39    },
40    ScheduleBlocked {
41        blocked_tasks: usize,
42        #[serde(skip_serializing_if = "Option::is_none")]
43        next_runnable_at: Option<String>,
44        #[serde(skip_serializing_if = "Option::is_none")]
45        seconds_until_next_runnable: Option<i64>,
46    },
47    LockBlocked {
48        #[serde(skip_serializing_if = "Option::is_none")]
49        lock_path: Option<String>,
50        #[serde(skip_serializing_if = "Option::is_none")]
51        owner: Option<String>,
52        #[serde(skip_serializing_if = "Option::is_none")]
53        owner_pid: Option<u32>,
54    },
55    CiBlocked {
56        #[serde(skip_serializing_if = "Option::is_none")]
57        exit_code: Option<i32>,
58        #[serde(skip_serializing_if = "Option::is_none")]
59        pattern: Option<String>,
60    },
61    RunnerRecovery {
62        scope: String,
63        reason: String,
64        #[serde(skip_serializing_if = "Option::is_none")]
65        task_id: Option<String>,
66    },
67    OperatorRecovery {
68        scope: String,
69        reason: String,
70        #[serde(skip_serializing_if = "Option::is_none")]
71        suggested_command: Option<String>,
72    },
73    MixedQueue {
74        dependency_blocked: usize,
75        schedule_blocked: usize,
76        status_filtered: usize,
77    },
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(deny_unknown_fields)]
82pub struct BlockingState {
83    pub status: BlockingStatus,
84    pub reason: BlockingReason,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub task_id: Option<String>,
87    pub message: String,
88    pub detail: String,
89}
90
91impl BlockingState {
92    pub fn new(
93        status: BlockingStatus,
94        reason: BlockingReason,
95        task_id: Option<String>,
96        message: impl Into<String>,
97        detail: impl Into<String>,
98    ) -> Self {
99        Self {
100            status,
101            reason,
102            task_id,
103            message: message.into(),
104            detail: detail.into(),
105        }
106    }
107
108    pub fn idle(include_draft: bool) -> Self {
109        let message = if include_draft {
110            "Ralph is idle: no todo or draft tasks are available."
111        } else {
112            "Ralph is idle: no todo tasks are available."
113        };
114        let detail = if include_draft {
115            "The queue currently has no runnable todo or draft candidates; Ralph is waiting for new work."
116        } else {
117            "The queue currently has no runnable todo candidates; Ralph is waiting for new work."
118        };
119        Self::new(
120            BlockingStatus::Waiting,
121            BlockingReason::Idle { include_draft },
122            None,
123            message,
124            detail,
125        )
126    }
127
128    pub fn dependency_blocked(blocked_tasks: usize) -> Self {
129        Self::new(
130            BlockingStatus::Blocked,
131            BlockingReason::DependencyBlocked { blocked_tasks },
132            None,
133            "Ralph is blocked by unfinished dependencies.",
134            format!("{blocked_tasks} candidate task(s) are waiting on dependency completion."),
135        )
136    }
137
138    pub fn schedule_blocked(
139        blocked_tasks: usize,
140        next_runnable_at: Option<String>,
141        seconds_until_next_runnable: Option<i64>,
142    ) -> Self {
143        let detail = match (&next_runnable_at, seconds_until_next_runnable) {
144            (Some(next_at), Some(seconds)) => format!(
145                "{blocked_tasks} candidate task(s) are scheduled for the future. The next one becomes runnable at {next_at} ({seconds}s remaining)."
146            ),
147            (Some(next_at), None) => format!(
148                "{blocked_tasks} candidate task(s) are scheduled for the future. The next known scheduled time is {next_at}."
149            ),
150            _ => {
151                format!("{blocked_tasks} candidate task(s) are scheduled for the future.")
152            }
153        };
154        Self::new(
155            BlockingStatus::Waiting,
156            BlockingReason::ScheduleBlocked {
157                blocked_tasks,
158                next_runnable_at,
159                seconds_until_next_runnable,
160            },
161            None,
162            "Ralph is waiting for scheduled work to become runnable.",
163            detail,
164        )
165    }
166
167    pub fn mixed_queue(
168        dependency_blocked: usize,
169        schedule_blocked: usize,
170        status_filtered: usize,
171    ) -> Self {
172        Self::new(
173            BlockingStatus::Blocked,
174            BlockingReason::MixedQueue {
175                dependency_blocked,
176                schedule_blocked,
177                status_filtered,
178            },
179            None,
180            "Ralph is blocked by a mix of dependency and schedule gates.",
181            format!(
182                "candidate blockers: dependencies={dependency_blocked}, schedule={schedule_blocked}, status_or_flags={status_filtered}."
183            ),
184        )
185    }
186
187    pub fn lock_blocked(
188        lock_path: Option<String>,
189        owner: Option<String>,
190        owner_pid: Option<u32>,
191    ) -> Self {
192        let detail = match (&owner, owner_pid, &lock_path) {
193            (Some(owner), Some(owner_pid), Some(lock_path)) => format!(
194                "Another Ralph process ({owner}, pid {owner_pid}) owns the queue lock at {lock_path}."
195            ),
196            (Some(owner), Some(owner_pid), None) => {
197                format!("Another Ralph process ({owner}, pid {owner_pid}) owns the queue lock.")
198            }
199            (_, _, Some(lock_path)) => {
200                format!("Another Ralph process owns the queue lock at {lock_path}.")
201            }
202            _ => "Another Ralph process currently owns the queue lock.".to_string(),
203        };
204        Self::new(
205            BlockingStatus::Stalled,
206            BlockingReason::LockBlocked {
207                lock_path,
208                owner,
209                owner_pid,
210            },
211            None,
212            "Ralph is stalled on queue lock contention.",
213            detail,
214        )
215    }
216
217    pub fn ci_blocked(exit_code: Option<i32>, pattern: Option<String>) -> Self {
218        let detail = match (&pattern, exit_code) {
219            (Some(pattern), Some(exit_code)) => {
220                format!("CI gate failed with exit code {exit_code}. Detected pattern: {pattern}.")
221            }
222            (Some(pattern), None) => format!("CI gate failed. Detected pattern: {pattern}."),
223            (None, Some(exit_code)) => {
224                format!("CI gate failed with exit code {exit_code}.")
225            }
226            (None, None) => "CI gate failed without a classified pattern.".to_string(),
227        };
228        Self::new(
229            BlockingStatus::Stalled,
230            BlockingReason::CiBlocked { exit_code, pattern },
231            None,
232            "Ralph is stalled on CI gate failure.",
233            detail,
234        )
235    }
236
237    pub fn runner_recovery(
238        scope: impl Into<String>,
239        reason: impl Into<String>,
240        task_id: Option<String>,
241        message: impl Into<String>,
242        detail: impl Into<String>,
243    ) -> Self {
244        Self::new(
245            BlockingStatus::Stalled,
246            BlockingReason::RunnerRecovery {
247                scope: scope.into(),
248                reason: reason.into(),
249                task_id: task_id.clone(),
250            },
251            task_id,
252            message,
253            detail,
254        )
255    }
256
257    pub fn operator_recovery(
258        status: BlockingStatus,
259        scope: impl Into<String>,
260        reason: impl Into<String>,
261        task_id: Option<String>,
262        message: impl Into<String>,
263        detail: impl Into<String>,
264        suggested_command: Option<String>,
265    ) -> Self {
266        Self::new(
267            status,
268            BlockingReason::OperatorRecovery {
269                scope: scope.into(),
270                reason: reason.into(),
271                suggested_command,
272            },
273            task_id,
274            message,
275            detail,
276        )
277    }
278}