1use 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}