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//! - `observed_at` is optional RFC3339 UTC (`Z`) marking when this blocking snapshot was produced;
19//!   queue runnability uses the same instant as `QueueRunnabilityReport.now`.
20//! - Fields remain machine-safe and versioned through the surrounding contract documents.
21
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum BlockingStatus {
28    Waiting,
29    Blocked,
30    Stalled,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum BlockingReason {
36    Idle {
37        include_draft: bool,
38    },
39    DependencyBlocked {
40        blocked_tasks: usize,
41    },
42    ScheduleBlocked {
43        blocked_tasks: usize,
44        #[serde(skip_serializing_if = "Option::is_none")]
45        next_runnable_at: Option<String>,
46        #[serde(skip_serializing_if = "Option::is_none")]
47        seconds_until_next_runnable: Option<i64>,
48    },
49    LockBlocked {
50        #[serde(skip_serializing_if = "Option::is_none")]
51        lock_path: Option<String>,
52        #[serde(skip_serializing_if = "Option::is_none")]
53        owner: Option<String>,
54        #[serde(skip_serializing_if = "Option::is_none")]
55        owner_pid: Option<u32>,
56    },
57    CiBlocked {
58        #[serde(skip_serializing_if = "Option::is_none")]
59        exit_code: Option<i32>,
60        #[serde(skip_serializing_if = "Option::is_none")]
61        pattern: Option<String>,
62    },
63    RunnerRecovery {
64        scope: String,
65        reason: String,
66        #[serde(skip_serializing_if = "Option::is_none")]
67        task_id: Option<String>,
68    },
69    OperatorRecovery {
70        scope: String,
71        reason: String,
72        #[serde(skip_serializing_if = "Option::is_none")]
73        suggested_command: Option<String>,
74    },
75    MixedQueue {
76        dependency_blocked: usize,
77        schedule_blocked: usize,
78        status_filtered: usize,
79    },
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct BlockingState {
85    pub status: BlockingStatus,
86    pub reason: BlockingReason,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub task_id: Option<String>,
89    pub message: String,
90    pub detail: String,
91    /// When this blocking condition was observed (RFC3339 UTC), for staleness and automation.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub observed_at: Option<String>,
94}
95
96impl BlockingState {
97    pub fn new(
98        status: BlockingStatus,
99        reason: BlockingReason,
100        task_id: Option<String>,
101        message: impl Into<String>,
102        detail: impl Into<String>,
103    ) -> Self {
104        Self {
105            status,
106            reason,
107            task_id,
108            message: message.into(),
109            detail: detail.into(),
110            observed_at: None,
111        }
112    }
113
114    /// Attach the instant this blocking state was observed (RFC3339 UTC).
115    pub fn with_observed_at(mut self, observed_at: impl Into<String>) -> Self {
116        self.observed_at = Some(observed_at.into());
117        self
118    }
119
120    pub fn idle(include_draft: bool) -> Self {
121        let message = if include_draft {
122            "Ralph is idle: no todo or draft tasks are available."
123        } else {
124            "Ralph is idle: no todo tasks are available."
125        };
126        let detail = if include_draft {
127            "The queue currently has no runnable todo or draft candidates; Ralph is waiting for new work."
128        } else {
129            "The queue currently has no runnable todo candidates; Ralph is waiting for new work."
130        };
131        Self::new(
132            BlockingStatus::Waiting,
133            BlockingReason::Idle { include_draft },
134            None,
135            message,
136            detail,
137        )
138    }
139
140    pub fn dependency_blocked(blocked_tasks: usize) -> Self {
141        Self::new(
142            BlockingStatus::Blocked,
143            BlockingReason::DependencyBlocked { blocked_tasks },
144            None,
145            "Ralph is blocked by unfinished dependencies.",
146            format!("{blocked_tasks} candidate task(s) are waiting on dependency completion."),
147        )
148    }
149
150    pub fn schedule_blocked(
151        blocked_tasks: usize,
152        next_runnable_at: Option<String>,
153        seconds_until_next_runnable: Option<i64>,
154    ) -> Self {
155        let detail = match (&next_runnable_at, seconds_until_next_runnable) {
156            (Some(next_at), Some(seconds)) => format!(
157                "{blocked_tasks} candidate task(s) are scheduled for the future. The next one becomes runnable at {next_at} ({seconds}s remaining)."
158            ),
159            (Some(next_at), None) => format!(
160                "{blocked_tasks} candidate task(s) are scheduled for the future. The next known scheduled time is {next_at}."
161            ),
162            _ => {
163                format!("{blocked_tasks} candidate task(s) are scheduled for the future.")
164            }
165        };
166        Self::new(
167            BlockingStatus::Waiting,
168            BlockingReason::ScheduleBlocked {
169                blocked_tasks,
170                next_runnable_at,
171                seconds_until_next_runnable,
172            },
173            None,
174            "Ralph is waiting for scheduled work to become runnable.",
175            detail,
176        )
177    }
178
179    pub fn mixed_queue(
180        dependency_blocked: usize,
181        schedule_blocked: usize,
182        status_filtered: usize,
183    ) -> Self {
184        Self::new(
185            BlockingStatus::Blocked,
186            BlockingReason::MixedQueue {
187                dependency_blocked,
188                schedule_blocked,
189                status_filtered,
190            },
191            None,
192            "Ralph is blocked by a mix of dependency and schedule gates.",
193            format!(
194                "candidate blockers: dependencies={dependency_blocked}, schedule={schedule_blocked}, status_or_flags={status_filtered}."
195            ),
196        )
197    }
198
199    pub fn lock_blocked(
200        lock_path: Option<String>,
201        owner: Option<String>,
202        owner_pid: Option<u32>,
203    ) -> Self {
204        let detail = match (&owner, owner_pid, &lock_path) {
205            (Some(owner), Some(owner_pid), Some(lock_path)) => format!(
206                "Another Ralph process ({owner}, pid {owner_pid}) owns the queue lock at {lock_path}."
207            ),
208            (Some(owner), Some(owner_pid), None) => {
209                format!("Another Ralph process ({owner}, pid {owner_pid}) owns the queue lock.")
210            }
211            (_, _, Some(lock_path)) => {
212                format!("Another Ralph process owns the queue lock at {lock_path}.")
213            }
214            _ => "Another Ralph process currently owns the queue lock.".to_string(),
215        };
216        Self::new(
217            BlockingStatus::Stalled,
218            BlockingReason::LockBlocked {
219                lock_path,
220                owner,
221                owner_pid,
222            },
223            None,
224            "Ralph is stalled on queue lock contention.",
225            detail,
226        )
227    }
228
229    pub fn ci_blocked(exit_code: Option<i32>, pattern: Option<String>) -> Self {
230        let detail = match (&pattern, exit_code) {
231            (Some(pattern), Some(exit_code)) => {
232                format!("CI gate failed with exit code {exit_code}. Detected pattern: {pattern}.")
233            }
234            (Some(pattern), None) => format!("CI gate failed. Detected pattern: {pattern}."),
235            (None, Some(exit_code)) => {
236                format!("CI gate failed with exit code {exit_code}.")
237            }
238            (None, None) => "CI gate failed without a classified pattern.".to_string(),
239        };
240        Self::new(
241            BlockingStatus::Stalled,
242            BlockingReason::CiBlocked { exit_code, pattern },
243            None,
244            "Ralph is stalled on CI gate failure.",
245            detail,
246        )
247    }
248
249    pub fn runner_recovery(
250        scope: impl Into<String>,
251        reason: impl Into<String>,
252        task_id: Option<String>,
253        message: impl Into<String>,
254        detail: impl Into<String>,
255    ) -> Self {
256        Self::new(
257            BlockingStatus::Stalled,
258            BlockingReason::RunnerRecovery {
259                scope: scope.into(),
260                reason: reason.into(),
261                task_id: task_id.clone(),
262            },
263            task_id,
264            message,
265            detail,
266        )
267    }
268
269    pub fn operator_recovery(
270        status: BlockingStatus,
271        scope: impl Into<String>,
272        reason: impl Into<String>,
273        task_id: Option<String>,
274        message: impl Into<String>,
275        detail: impl Into<String>,
276        suggested_command: Option<String>,
277    ) -> Self {
278        Self::new(
279            status,
280            BlockingReason::OperatorRecovery {
281                scope: scope.into(),
282                reason: reason.into(),
283                suggested_command,
284            },
285            task_id,
286            message,
287            detail,
288        )
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn observed_at_serializes_and_round_trips() {
298        let state = BlockingState::idle(false).with_observed_at("2026-04-19T15:30:00.123456789Z");
299        let json = serde_json::to_value(&state).expect("serialize");
300        assert_eq!(json["observed_at"], "2026-04-19T15:30:00.123456789Z");
301        let back: BlockingState = serde_json::from_value(json).expect("deserialize");
302        assert_eq!(back, state);
303    }
304
305    #[test]
306    fn observed_at_defaults_to_none_when_absent_in_json() {
307        let json = serde_json::json!({
308            "status": "waiting",
309            "reason": { "kind": "idle", "include_draft": false },
310            "message": "m",
311            "detail": "d"
312        });
313        let state: BlockingState = serde_json::from_value(json).expect("deserialize");
314        assert!(state.observed_at.is_none());
315    }
316}