Skip to main content

ralph/session/
decision.rs

1//! Operator-facing resume decision model and session-resolution helpers.
2//!
3//! Responsibilities:
4//! - Convert low-level session validation into explicit resume/fresh/refusal decisions.
5//! - Preserve machine-readable decision state for CLI/app surfaces.
6//! - Apply session-cache mutations only when the caller is executing a real run.
7//!
8//! Not handled here:
9//! - Session persistence IO details.
10//! - Queue/task execution.
11//! - Continue-session runner resumption.
12//!
13//! Invariants/assumptions:
14//! - Timed-out sessions always require explicit confirmation.
15//! - Non-interactive prompt-required cases refuse instead of guessing.
16//! - Preview callers must not mutate session cache state.
17
18use std::io::IsTerminal;
19use std::path::Path;
20
21use anyhow::Result;
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24
25use crate::contracts::{BlockingState, QueueFile};
26
27use super::{
28    SessionValidationResult, check_session, clear_session, prompt_session_recovery,
29    prompt_session_recovery_timeout,
30};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum ResumeStatus {
35    ResumingSameSession,
36    FallingBackToFreshInvocation,
37    RefusingToResume,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "snake_case")]
42pub enum ResumeScope {
43    RunSession,
44    ContinueSession,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum ResumeReason {
50    NoSession,
51    SessionValid,
52    SessionTimedOutConfirmed,
53    SessionStale,
54    SessionDeclined,
55    ResumeConfirmationRequired,
56    SessionTimedOutRequiresConfirmation,
57    ExplicitTaskSelectionOverridesSession,
58    ResumeTargetMissing,
59    ResumeTargetTerminal,
60    RunnerSessionInvalid,
61    MissingRunnerSessionId,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct ResumeDecision {
67    pub status: ResumeStatus,
68    pub scope: ResumeScope,
69    pub reason: ResumeReason,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub task_id: Option<String>,
72    pub message: String,
73    pub detail: String,
74}
75
76impl ResumeDecision {
77    pub fn blocking_state(&self) -> Option<BlockingState> {
78        let reason = match self.reason {
79            ResumeReason::RunnerSessionInvalid => "runner_session_invalid",
80            ResumeReason::MissingRunnerSessionId => "missing_runner_session_id",
81            ResumeReason::ResumeConfirmationRequired => "resume_confirmation_required",
82            ResumeReason::SessionTimedOutRequiresConfirmation => {
83                "session_timed_out_requires_confirmation"
84            }
85            _ => return None,
86        };
87
88        Some(
89            BlockingState::runner_recovery(
90                match self.scope {
91                    ResumeScope::RunSession => "run_session",
92                    ResumeScope::ContinueSession => "continue_session",
93                },
94                reason,
95                self.task_id.clone(),
96                self.message.clone(),
97                self.detail.clone(),
98            )
99            .with_observed_at(crate::timeutil::now_utc_rfc3339_or_fallback()),
100        )
101    }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum ResumeBehavior {
106    Prompt,
107    AutoResume,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ResumeDecisionMode {
112    Preview,
113    Execute,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct ResumeResolution {
118    pub resume_task_id: Option<String>,
119    pub completed_count: u32,
120    pub decision: Option<ResumeDecision>,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct RunSessionDecisionOptions<'a> {
125    pub timeout_hours: Option<u64>,
126    pub behavior: ResumeBehavior,
127    pub non_interactive: bool,
128    pub explicit_task_id: Option<&'a str>,
129    pub announce_missing_session: bool,
130    pub mode: ResumeDecisionMode,
131}
132
133pub fn resolve_run_session_decision(
134    cache_dir: &Path,
135    queue_file: &QueueFile,
136    options: RunSessionDecisionOptions<'_>,
137) -> Result<ResumeResolution> {
138    let validation = check_session(cache_dir, queue_file, options.timeout_hours)?;
139    let can_prompt = !options.non_interactive && std::io::stdin().is_terminal();
140    let timeout_threshold = options
141        .timeout_hours
142        .unwrap_or(crate::constants::timeouts::DEFAULT_SESSION_TIMEOUT_HOURS);
143
144    let resolution = match validation {
145        SessionValidationResult::NoSession => ResumeResolution {
146            resume_task_id: None,
147            completed_count: 0,
148            decision: options.announce_missing_session.then(|| ResumeDecision {
149                status: ResumeStatus::FallingBackToFreshInvocation,
150                scope: ResumeScope::RunSession,
151                reason: ResumeReason::NoSession,
152                task_id: None,
153                message: "Resume: no interrupted session was found; starting a fresh run."
154                    .to_string(),
155                detail: "No persisted session state exists under .ralph/cache/session.jsonc."
156                    .to_string(),
157            }),
158        },
159        SessionValidationResult::Valid(session) => {
160            if let Some(explicit_task_id) = options.explicit_task_id
161                && explicit_task_id.trim() != session.task_id
162            {
163                ResumeResolution {
164                    resume_task_id: None,
165                    completed_count: 0,
166                    decision: Some(ResumeDecision {
167                        status: ResumeStatus::FallingBackToFreshInvocation,
168                        scope: ResumeScope::RunSession,
169                        reason: ResumeReason::ExplicitTaskSelectionOverridesSession,
170                        task_id: Some(session.task_id.clone()),
171                        message: format!(
172                            "Resume: starting fresh because task {explicit_task_id} was explicitly selected instead of interrupted task {}.",
173                            session.task_id
174                        ),
175                        detail: format!(
176                            "Saved session belongs to {}, so Ralph will honor the explicit task selection.",
177                            session.task_id
178                        ),
179                    }),
180                }
181            } else {
182                match options.behavior {
183                    ResumeBehavior::AutoResume => ResumeResolution {
184                        resume_task_id: Some(session.task_id.clone()),
185                        completed_count: session.tasks_completed_in_loop,
186                        decision: Some(ResumeDecision {
187                            status: ResumeStatus::ResumingSameSession,
188                            scope: ResumeScope::RunSession,
189                            reason: ResumeReason::SessionValid,
190                            task_id: Some(session.task_id.clone()),
191                            message: format!(
192                                "Resume: continuing the interrupted session for task {}.",
193                                session.task_id
194                            ),
195                            detail: format!(
196                                "Saved session is current and will resume from phase {} with {} completed loop task(s).",
197                                session.current_phase, session.tasks_completed_in_loop
198                            ),
199                        }),
200                    },
201                    ResumeBehavior::Prompt if !can_prompt => ResumeResolution {
202                        resume_task_id: None,
203                        completed_count: 0,
204                        decision: Some(ResumeDecision {
205                            status: ResumeStatus::RefusingToResume,
206                            scope: ResumeScope::RunSession,
207                            reason: ResumeReason::ResumeConfirmationRequired,
208                            task_id: Some(session.task_id.clone()),
209                            message: format!(
210                                "Resume: refusing to guess because task {} has an interrupted session and confirmation is unavailable.",
211                                session.task_id
212                            ),
213                            detail: "Re-run interactively to choose resume vs fresh, or pass --resume to continue automatically when safe.".to_string(),
214                        }),
215                    },
216                    ResumeBehavior::Prompt => {
217                        if prompt_session_recovery(&session, options.non_interactive)? {
218                            ResumeResolution {
219                                resume_task_id: Some(session.task_id.clone()),
220                                completed_count: session.tasks_completed_in_loop,
221                                decision: Some(ResumeDecision {
222                                    status: ResumeStatus::ResumingSameSession,
223                                    scope: ResumeScope::RunSession,
224                                    reason: ResumeReason::SessionValid,
225                                    task_id: Some(session.task_id.clone()),
226                                    message: format!(
227                                        "Resume: continuing the interrupted session for task {}.",
228                                        session.task_id
229                                    ),
230                                    detail: format!(
231                                        "Saved session is current and will resume from phase {} with {} completed loop task(s).",
232                                        session.current_phase, session.tasks_completed_in_loop
233                                    ),
234                                }),
235                            }
236                        } else {
237                            maybe_clear_session(cache_dir, options.mode)?;
238                            ResumeResolution {
239                                resume_task_id: None,
240                                completed_count: 0,
241                                decision: Some(ResumeDecision {
242                                    status: ResumeStatus::FallingBackToFreshInvocation,
243                                    scope: ResumeScope::RunSession,
244                                    reason: ResumeReason::SessionDeclined,
245                                    task_id: Some(session.task_id.clone()),
246                                    message: format!(
247                                        "Resume: starting fresh after declining the interrupted session for task {}.",
248                                        session.task_id
249                                    ),
250                                    detail: "The saved session remains readable, but Ralph will begin a new invocation instead of reusing it.".to_string(),
251                                }),
252                            }
253                        }
254                    }
255                }
256            }
257        }
258        SessionValidationResult::Stale { reason } => {
259            maybe_clear_session(cache_dir, options.mode)?;
260            ResumeResolution {
261                resume_task_id: None,
262                completed_count: 0,
263                decision: Some(ResumeDecision {
264                    status: ResumeStatus::FallingBackToFreshInvocation,
265                    scope: ResumeScope::RunSession,
266                    reason: ResumeReason::SessionStale,
267                    task_id: None,
268                    message: "Resume: starting fresh because the saved session is stale."
269                        .to_string(),
270                    detail: reason,
271                }),
272            }
273        }
274        SessionValidationResult::Timeout { hours, session } => {
275            if !can_prompt {
276                ResumeResolution {
277                    resume_task_id: None,
278                    completed_count: 0,
279                    decision: Some(ResumeDecision {
280                        status: ResumeStatus::RefusingToResume,
281                        scope: ResumeScope::RunSession,
282                        reason: ResumeReason::SessionTimedOutRequiresConfirmation,
283                        task_id: Some(session.task_id.clone()),
284                        message: format!(
285                            "Resume: refusing to continue timed-out session {} without explicit confirmation.",
286                            session.task_id
287                        ),
288                        detail: format!(
289                            "The saved session is {hours} hour(s) old, exceeding the configured {timeout_threshold}-hour safety threshold."
290                        ),
291                    }),
292                }
293            } else if prompt_session_recovery_timeout(
294                &session,
295                hours,
296                timeout_threshold,
297                options.non_interactive,
298            )? {
299                ResumeResolution {
300                    resume_task_id: Some(session.task_id.clone()),
301                    completed_count: session.tasks_completed_in_loop,
302                    decision: Some(ResumeDecision {
303                        status: ResumeStatus::ResumingSameSession,
304                        scope: ResumeScope::RunSession,
305                        reason: ResumeReason::SessionTimedOutConfirmed,
306                        task_id: Some(session.task_id.clone()),
307                        message: format!(
308                            "Resume: continuing timed-out session {} after explicit confirmation.",
309                            session.task_id
310                        ),
311                        detail: format!(
312                            "The saved session is {hours} hour(s) old, above the configured {timeout_threshold}-hour threshold."
313                        ),
314                    }),
315                }
316            } else {
317                maybe_clear_session(cache_dir, options.mode)?;
318                ResumeResolution {
319                    resume_task_id: None,
320                    completed_count: 0,
321                    decision: Some(ResumeDecision {
322                        status: ResumeStatus::FallingBackToFreshInvocation,
323                        scope: ResumeScope::RunSession,
324                        reason: ResumeReason::SessionDeclined,
325                        task_id: Some(session.task_id.clone()),
326                        message: format!(
327                            "Resume: starting fresh after declining timed-out session {}.",
328                            session.task_id
329                        ),
330                        detail: format!(
331                            "The saved session is {hours} hour(s) old, above the configured {timeout_threshold}-hour threshold."
332                        ),
333                    }),
334                }
335            }
336        }
337    };
338
339    Ok(resolution)
340}
341
342fn maybe_clear_session(cache_dir: &Path, mode: ResumeDecisionMode) -> Result<()> {
343    if matches!(mode, ResumeDecisionMode::Execute) {
344        clear_session(cache_dir)?;
345    }
346    Ok(())
347}