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(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}