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(BlockingState::runner_recovery(
89            match self.scope {
90                ResumeScope::RunSession => "run_session",
91                ResumeScope::ContinueSession => "continue_session",
92            },
93            reason,
94            self.task_id.clone(),
95            self.message.clone(),
96            self.detail.clone(),
97        ))
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ResumeBehavior {
103    Prompt,
104    AutoResume,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum ResumeDecisionMode {
109    Preview,
110    Execute,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ResumeResolution {
115    pub resume_task_id: Option<String>,
116    pub completed_count: u32,
117    pub decision: Option<ResumeDecision>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct RunSessionDecisionOptions<'a> {
122    pub timeout_hours: Option<u64>,
123    pub behavior: ResumeBehavior,
124    pub non_interactive: bool,
125    pub explicit_task_id: Option<&'a str>,
126    pub announce_missing_session: bool,
127    pub mode: ResumeDecisionMode,
128}
129
130pub fn resolve_run_session_decision(
131    cache_dir: &Path,
132    queue_file: &QueueFile,
133    options: RunSessionDecisionOptions<'_>,
134) -> Result<ResumeResolution> {
135    let validation = check_session(cache_dir, queue_file, options.timeout_hours)?;
136    let can_prompt = !options.non_interactive && std::io::stdin().is_terminal();
137    let timeout_threshold = options
138        .timeout_hours
139        .unwrap_or(crate::constants::timeouts::DEFAULT_SESSION_TIMEOUT_HOURS);
140
141    let resolution = match validation {
142        SessionValidationResult::NoSession => ResumeResolution {
143            resume_task_id: None,
144            completed_count: 0,
145            decision: options.announce_missing_session.then(|| ResumeDecision {
146                status: ResumeStatus::FallingBackToFreshInvocation,
147                scope: ResumeScope::RunSession,
148                reason: ResumeReason::NoSession,
149                task_id: None,
150                message: "Resume: no interrupted session was found; starting a fresh run."
151                    .to_string(),
152                detail: "No persisted session state exists under .ralph/cache/session.jsonc."
153                    .to_string(),
154            }),
155        },
156        SessionValidationResult::Valid(session) => {
157            if let Some(explicit_task_id) = options.explicit_task_id
158                && explicit_task_id.trim() != session.task_id
159            {
160                ResumeResolution {
161                    resume_task_id: None,
162                    completed_count: 0,
163                    decision: Some(ResumeDecision {
164                        status: ResumeStatus::FallingBackToFreshInvocation,
165                        scope: ResumeScope::RunSession,
166                        reason: ResumeReason::ExplicitTaskSelectionOverridesSession,
167                        task_id: Some(session.task_id.clone()),
168                        message: format!(
169                            "Resume: starting fresh because task {explicit_task_id} was explicitly selected instead of interrupted task {}.",
170                            session.task_id
171                        ),
172                        detail: format!(
173                            "Saved session belongs to {}, so Ralph will honor the explicit task selection.",
174                            session.task_id
175                        ),
176                    }),
177                }
178            } else {
179                match options.behavior {
180                    ResumeBehavior::AutoResume => ResumeResolution {
181                        resume_task_id: Some(session.task_id.clone()),
182                        completed_count: session.tasks_completed_in_loop,
183                        decision: Some(ResumeDecision {
184                            status: ResumeStatus::ResumingSameSession,
185                            scope: ResumeScope::RunSession,
186                            reason: ResumeReason::SessionValid,
187                            task_id: Some(session.task_id.clone()),
188                            message: format!(
189                                "Resume: continuing the interrupted session for task {}.",
190                                session.task_id
191                            ),
192                            detail: format!(
193                                "Saved session is current and will resume from phase {} with {} completed loop task(s).",
194                                session.current_phase, session.tasks_completed_in_loop
195                            ),
196                        }),
197                    },
198                    ResumeBehavior::Prompt if !can_prompt => ResumeResolution {
199                        resume_task_id: None,
200                        completed_count: 0,
201                        decision: Some(ResumeDecision {
202                            status: ResumeStatus::RefusingToResume,
203                            scope: ResumeScope::RunSession,
204                            reason: ResumeReason::ResumeConfirmationRequired,
205                            task_id: Some(session.task_id.clone()),
206                            message: format!(
207                                "Resume: refusing to guess because task {} has an interrupted session and confirmation is unavailable.",
208                                session.task_id
209                            ),
210                            detail: "Re-run interactively to choose resume vs fresh, or pass --resume to continue automatically when safe.".to_string(),
211                        }),
212                    },
213                    ResumeBehavior::Prompt => {
214                        if prompt_session_recovery(&session, options.non_interactive)? {
215                            ResumeResolution {
216                                resume_task_id: Some(session.task_id.clone()),
217                                completed_count: session.tasks_completed_in_loop,
218                                decision: Some(ResumeDecision {
219                                    status: ResumeStatus::ResumingSameSession,
220                                    scope: ResumeScope::RunSession,
221                                    reason: ResumeReason::SessionValid,
222                                    task_id: Some(session.task_id.clone()),
223                                    message: format!(
224                                        "Resume: continuing the interrupted session for task {}.",
225                                        session.task_id
226                                    ),
227                                    detail: format!(
228                                        "Saved session is current and will resume from phase {} with {} completed loop task(s).",
229                                        session.current_phase, session.tasks_completed_in_loop
230                                    ),
231                                }),
232                            }
233                        } else {
234                            maybe_clear_session(cache_dir, options.mode)?;
235                            ResumeResolution {
236                                resume_task_id: None,
237                                completed_count: 0,
238                                decision: Some(ResumeDecision {
239                                    status: ResumeStatus::FallingBackToFreshInvocation,
240                                    scope: ResumeScope::RunSession,
241                                    reason: ResumeReason::SessionDeclined,
242                                    task_id: Some(session.task_id.clone()),
243                                    message: format!(
244                                        "Resume: starting fresh after declining the interrupted session for task {}.",
245                                        session.task_id
246                                    ),
247                                    detail: "The saved session remains readable, but Ralph will begin a new invocation instead of reusing it.".to_string(),
248                                }),
249                            }
250                        }
251                    }
252                }
253            }
254        }
255        SessionValidationResult::Stale { reason } => {
256            maybe_clear_session(cache_dir, options.mode)?;
257            ResumeResolution {
258                resume_task_id: None,
259                completed_count: 0,
260                decision: Some(ResumeDecision {
261                    status: ResumeStatus::FallingBackToFreshInvocation,
262                    scope: ResumeScope::RunSession,
263                    reason: ResumeReason::SessionStale,
264                    task_id: None,
265                    message: "Resume: starting fresh because the saved session is stale."
266                        .to_string(),
267                    detail: reason,
268                }),
269            }
270        }
271        SessionValidationResult::Timeout { hours, session } => {
272            if !can_prompt {
273                ResumeResolution {
274                    resume_task_id: None,
275                    completed_count: 0,
276                    decision: Some(ResumeDecision {
277                        status: ResumeStatus::RefusingToResume,
278                        scope: ResumeScope::RunSession,
279                        reason: ResumeReason::SessionTimedOutRequiresConfirmation,
280                        task_id: Some(session.task_id.clone()),
281                        message: format!(
282                            "Resume: refusing to continue timed-out session {} without explicit confirmation.",
283                            session.task_id
284                        ),
285                        detail: format!(
286                            "The saved session is {hours} hour(s) old, exceeding the configured {timeout_threshold}-hour safety threshold."
287                        ),
288                    }),
289                }
290            } else if prompt_session_recovery_timeout(
291                &session,
292                hours,
293                timeout_threshold,
294                options.non_interactive,
295            )? {
296                ResumeResolution {
297                    resume_task_id: Some(session.task_id.clone()),
298                    completed_count: session.tasks_completed_in_loop,
299                    decision: Some(ResumeDecision {
300                        status: ResumeStatus::ResumingSameSession,
301                        scope: ResumeScope::RunSession,
302                        reason: ResumeReason::SessionTimedOutConfirmed,
303                        task_id: Some(session.task_id.clone()),
304                        message: format!(
305                            "Resume: continuing timed-out session {} after explicit confirmation.",
306                            session.task_id
307                        ),
308                        detail: format!(
309                            "The saved session is {hours} hour(s) old, above the configured {timeout_threshold}-hour threshold."
310                        ),
311                    }),
312                }
313            } else {
314                maybe_clear_session(cache_dir, options.mode)?;
315                ResumeResolution {
316                    resume_task_id: None,
317                    completed_count: 0,
318                    decision: Some(ResumeDecision {
319                        status: ResumeStatus::FallingBackToFreshInvocation,
320                        scope: ResumeScope::RunSession,
321                        reason: ResumeReason::SessionDeclined,
322                        task_id: Some(session.task_id.clone()),
323                        message: format!(
324                            "Resume: starting fresh after declining timed-out session {}.",
325                            session.task_id
326                        ),
327                        detail: format!(
328                            "The saved session is {hours} hour(s) old, above the configured {timeout_threshold}-hour threshold."
329                        ),
330                    }),
331                }
332            }
333        }
334    };
335
336    Ok(resolution)
337}
338
339fn maybe_clear_session(cache_dir: &Path, mode: ResumeDecisionMode) -> Result<()> {
340    if matches!(mode, ResumeDecisionMode::Execute) {
341        clear_session(cache_dir)?;
342    }
343    Ok(())
344}