Skip to main content

orch/store/
helpers.rs

1//! Store helper functions used by the engine/CLI.
2//!
3//! These helpers operate on the unified SQLite task store (`TaskStore`) but accept
4//! `Option<Arc<TaskStore>>` because parts of the engine can run before the store is
5//! initialized.
6
7use crate::store::{CostEstimate, MemoryEntry, Task, TaskStore, TokenUsage};
8use anyhow::anyhow;
9use std::sync::Arc;
10
11/// Result type for helpers that distinguish "not found" from "read error".
12pub type StoreResult<T> = anyhow::Result<Option<T>>;
13
14fn token_i64_to_u64_non_negative(tokens: i64) -> u64 {
15    u64::try_from(tokens.max(0)).unwrap_or(0)
16}
17
18/// Resolve a task's numeric store ID from its external identifier.
19///
20/// Returns `None` if the store is unavailable, the task is not found, or the
21/// lookup fails (with a warning logged in that case).
22pub async fn resolve_store_id(
23    store: &Option<Arc<TaskStore>>,
24    repo: &str,
25    task_id: &str,
26) -> Option<i64> {
27    let s = store.as_ref()?;
28    match s.resolve_task_id(repo, task_id).await {
29        Ok(Some(id)) => Some(id),
30        Ok(None) => None,
31        Err(e) => {
32            tracing::warn!(task_id, error = %e, "failed to resolve task id for store operations");
33            None
34        }
35    }
36}
37
38/// Load the full `Task` record from the store.
39///
40/// Returns `None` if the store is unavailable, the task cannot be resolved,
41/// or the task row cannot be loaded.
42pub async fn opt_store_get_task_by_id(
43    store: &Option<Arc<TaskStore>>,
44    store_id: i64,
45) -> Option<Task> {
46    let s = store.as_ref()?;
47    match s.get(store_id).await {
48        Ok(task) => Some(task),
49        Err(e) => {
50            tracing::warn!(store_id, error = %e, "failed to fetch task from store");
51            None
52        }
53    }
54}
55
56/// Load the full `Task` record from the store.
57///
58/// Returns `None` if the store is unavailable, the task cannot be resolved,
59/// or the task row cannot be loaded.
60pub async fn opt_store_get_task(
61    store: &Option<Arc<TaskStore>>,
62    repo: &str,
63    task_id: &str,
64) -> Option<Task> {
65    let s = store.as_ref()?;
66    let store_id = match s.resolve_task_id(repo, task_id).await {
67        Ok(Some(id)) => id,
68        Ok(None) => return None,
69        Err(e) => {
70            tracing::warn!(task_id, error = %e, "failed to resolve task id in store");
71            return None;
72        }
73    };
74    opt_store_get_task_by_id(store, store_id).await
75}
76
77/// Write fields to the task store.
78///
79/// `store` may be None if the store isn't initialized yet.
80pub async fn store_set_by_id(
81    store: &Option<&Arc<TaskStore>>,
82    store_id: i64,
83    store_fields: &[(&str, serde_json::Value)],
84) -> anyhow::Result<()> {
85    if let Some(store) = store {
86        store.set_fields(store_id, store_fields).await?;
87    }
88    Ok(())
89}
90
91/// Write fields to the task store.
92///
93/// `store` may be None if the store isn't initialized yet.
94pub async fn store_set(
95    store: &Option<Arc<TaskStore>>,
96    repo: &str,
97    task_id: &str,
98    store_fields: &[(&str, serde_json::Value)],
99) {
100    if let Some(ref store) = store {
101        match store.resolve_task_id(repo, task_id).await {
102            Ok(Some(store_id)) => {
103                if let Err(e) = store.set_fields(store_id, store_fields).await {
104                    tracing::warn!(task_id, error = %e, "store set_fields failed");
105                }
106            }
107            Ok(None) => {} // task not in store — no-op is expected
108            Err(e) => {
109                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
110            }
111        }
112    }
113}
114
115/// Write fields to the task store, returning an error if the write fails.
116///
117/// Unlike `store_set`, this variant propagates failures so callers can
118/// abort further processing when a critical write (e.g. a watermark) fails.
119/// Returns `Ok(())` when `store` is `None` (store not yet initialized).
120pub async fn store_set_result_by_id(
121    store: &Option<Arc<TaskStore>>,
122    store_id: i64,
123    store_fields: &[(&str, serde_json::Value)],
124) -> anyhow::Result<()> {
125    if let Some(ref store) = store {
126        store.set_fields(store_id, store_fields).await?;
127    }
128    Ok(())
129}
130
131/// Write fields to the task store, returning an error if the write fails.
132///
133/// Unlike `store_set`, this variant propagates failures so callers can
134/// abort further processing when a critical write (e.g. a watermark) fails.
135/// Returns `Ok(())` when `store` is `None` (store not yet initialized).
136pub async fn store_set_result(
137    store: &Option<Arc<TaskStore>>,
138    repo: &str,
139    task_id: &str,
140    store_fields: &[(&str, serde_json::Value)],
141) -> anyhow::Result<()> {
142    if let Some(ref store) = store {
143        let store_id = store
144            .resolve_task_id(repo, task_id)
145            .await?
146            .ok_or_else(|| anyhow::anyhow!("task {} not found in store", task_id))?;
147        store.set_fields(store_id, store_fields).await?;
148    }
149    Ok(())
150}
151
152/// Touch `updated_at` to now so the stuck-task timer is reset after a session exits.
153///
154/// No-op when the store is unavailable or the task cannot be resolved.
155#[allow(dead_code)]
156pub async fn store_touch_updated_at_by_id(store: &Option<Arc<TaskStore>>, store_id: i64) {
157    if let Some(ref s) = store {
158        if let Err(e) = s.touch_updated_at(store_id).await {
159            tracing::warn!(store_id, error = %e, "store touch_updated_at failed");
160        }
161    }
162}
163
164/// Touch `updated_at` to now so the stuck-task timer is reset after a session exits.
165///
166/// No-op when the store is unavailable or the task cannot be resolved.
167pub async fn store_touch_updated_at(store: &Option<Arc<TaskStore>>, repo: &str, task_id: &str) {
168    if let Some(ref s) = store {
169        match s.resolve_task_id(repo, task_id).await {
170            Ok(Some(store_id)) => {
171                if let Err(e) = s.touch_updated_at(store_id).await {
172                    tracing::warn!(task_id, error = %e, "store touch_updated_at failed");
173                } else {
174                    // Logged on success (not just failure) so a stuck-task reclaim race
175                    // can be diagnosed from logs by checking whether this touch actually
176                    // landed before the reclaim's own age check ran.
177                    tracing::debug!(task_id, store_id, "store touch_updated_at succeeded");
178                }
179            }
180            Ok(None) => {} // task not in store — no-op is expected
181            Err(e) => {
182                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
183            }
184        }
185    }
186}
187
188/// Append a lifecycle activity event to a task timeline.
189#[allow(clippy::too_many_arguments)]
190pub async fn store_log_activity(
191    store: &Option<Arc<TaskStore>>,
192    repo: &str,
193    task_id: &str,
194    event_type: &str,
195    from_status: Option<&str>,
196    to_status: Option<&str>,
197    agent: Option<&str>,
198    model: Option<&str>,
199    details: Option<&serde_json::Value>,
200) {
201    if let Some(ref s) = store {
202        match s.resolve_task_id(repo, task_id).await {
203            Ok(Some(store_id)) => {
204                if let Err(e) = s
205                    .append_activity(
206                        store_id,
207                        event_type,
208                        from_status,
209                        to_status,
210                        agent,
211                        model,
212                        details,
213                    )
214                    .await
215                {
216                    tracing::warn!(task_id, event_type, error = %e, "store append_activity failed");
217                }
218            }
219            Ok(None) => {} // task not in store — no-op is expected
220            Err(e) => {
221                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
222            }
223        }
224    }
225}
226
227pub async fn review_session_expected(store: &Arc<TaskStore>, repo: &str, task_id: &str) -> bool {
228    match store.resolve_task_id(repo, task_id).await {
229        Ok(Some(store_id)) => store
230            .get(store_id)
231            .await
232            .map(|task| task.review_session_expected)
233            .unwrap_or(false),
234        Ok(None) | Err(_) => false,
235    }
236}
237
238pub async fn set_review_session_expected(
239    store: &Arc<TaskStore>,
240    repo: &str,
241    task_id: &str,
242    expected: bool,
243) {
244    store_set(
245        &Some(Arc::clone(store)),
246        repo,
247        task_id,
248        &[("review_session_expected", serde_json::json!(expected))],
249    )
250    .await;
251}
252
253/// Increment a counter in the task store.
254///
255/// Uses `store.increment()` for an atomic SQL `field + 1`.
256/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
257/// store is unavailable, the task cannot be resolved, or the underlying
258/// SQL increment failed. Callers must handle errors explicitly — this
259/// helper no longer silently returns 0 on error.
260pub async fn store_increment_by_id(
261    store: &Option<Arc<TaskStore>>,
262    store_id: i64,
263    field: &str,
264) -> anyhow::Result<u64> {
265    let s = store
266        .as_ref()
267        .ok_or_else(|| anyhow!("task store unavailable"))?;
268    match s.increment(store_id, field).await {
269        Ok(new_val) => Ok(new_val as u64),
270        Err(e) => Err(anyhow!("store.increment failed: {}", e)),
271    }
272}
273
274/// Increment a counter in the task store.
275///
276/// Uses `store.increment()` for an atomic SQL `field + 1`.
277/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
278/// store is unavailable, the task cannot be resolved, or the underlying
279/// SQL increment failed. Callers must handle errors explicitly — this
280/// helper no longer silently returns 0 on error.
281pub async fn store_increment(
282    store: &Option<Arc<TaskStore>>,
283    repo: &str,
284    task_id: &str,
285    field: &str,
286) -> anyhow::Result<u64> {
287    let s = store
288        .as_ref()
289        .ok_or_else(|| anyhow!("task store unavailable"))?;
290    match s.resolve_task_id(repo, task_id).await {
291        Ok(Some(store_id)) => store_increment_by_id(store, store_id, field).await,
292        Ok(None) => Err(anyhow!("task not present in store: {}/{}", repo, task_id)),
293        Err(e) => {
294            tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
295            Err(anyhow!("resolve_task_id failed: {}", e))
296        }
297    }
298}
299
300/// Read a specific string field from a task record.
301pub async fn get_task_field(
302    store: &Option<Arc<TaskStore>>,
303    repo: &str,
304    task_id: &str,
305    field: &str,
306) -> Option<String> {
307    let task = opt_store_get_task(store, repo, task_id).await?;
308    match field {
309        "no_code_last_agent" => Some(task.no_code_last_agent),
310        _ => {
311            tracing::warn!(task_id, field, "get_task_field: unknown field");
312            None
313        }
314    }
315}
316
317/// Read a specific string field from a task record (store always available).
318pub async fn get_task_field_direct(
319    store: &Arc<TaskStore>,
320    repo: &str,
321    task_id: &str,
322    field: &str,
323) -> Option<String> {
324    let store_opt: &Option<Arc<TaskStore>> = &Some(store.clone());
325    get_task_field(store_opt, repo, task_id, field).await
326}
327
328/// Reset all task counters in the task store.
329pub async fn store_reset_counters(store: &Option<Arc<TaskStore>>, repo: &str, task_id: &str) {
330    if let Some(ref store) = store {
331        match store.resolve_task_id(repo, task_id).await {
332            Ok(Some(store_id)) => {
333                if let Err(e) = store.reset_counters(store_id).await {
334                    tracing::warn!(task_id, err = %e, "store reset_counters failed");
335                }
336            }
337            Ok(None) => {} // task not in store — no-op is expected
338            Err(e) => {
339                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
340            }
341        }
342    }
343}
344
345/// Reset transient failure/retry counters, preserving `review_cycles`.
346///
347/// Use this after a `RequestChanges` review decision so that per-attempt
348/// noise is cleared without undoing the cycle count set by `handle_review_changes`.
349pub async fn store_reset_failure_counters(
350    store: &Option<Arc<TaskStore>>,
351    repo: &str,
352    task_id: &str,
353) {
354    if let Some(ref store) = store {
355        match store.resolve_task_id(repo, task_id).await {
356            Ok(Some(store_id)) => {
357                if let Err(e) = store.reset_failure_counters(store_id).await {
358                    tracing::warn!(task_id, err = %e, "failed to reset failure counters after review dispatch");
359                }
360            }
361            Ok(None) => {} // task not in store — no-op is expected
362            Err(e) => {
363                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
364            }
365        }
366    }
367}
368
369/// Get token usage from the store, surfacing DB read errors.
370pub async fn get_token_usage_result(
371    store: &Option<Arc<TaskStore>>,
372    repo: &str,
373    task_id: &str,
374) -> StoreResult<TokenUsage> {
375    let s = store
376        .as_ref()
377        .ok_or_else(|| anyhow!("task store unavailable"))?;
378    let store_id = s
379        .resolve_task_id(repo, task_id)
380        .await?
381        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
382    let task = s.get(store_id).await?;
383    Ok(Some(TokenUsage {
384        input_tokens: token_i64_to_u64_non_negative(task.input_tokens),
385        output_tokens: token_i64_to_u64_non_negative(task.output_tokens),
386    }))
387}
388
389/// Get token usage from the store.
390///
391/// Returns `TokenUsage::default()` when the store is unavailable or the task is not found.
392/// DB read errors are logged as warnings and return the default.
393pub async fn get_token_usage(
394    store: &Option<Arc<TaskStore>>,
395    repo: &str,
396    task_id: &str,
397) -> TokenUsage {
398    match get_token_usage_result(store, repo, task_id).await {
399        Ok(Some(usage)) => usage,
400        Ok(None) => TokenUsage::default(),
401        Err(e) => {
402            tracing::warn!(task_id, error = %e, "get_token_usage failed — returning zero usage");
403            TokenUsage::default()
404        }
405    }
406}
407
408/// Get cost estimate from the store, surfacing DB read errors.
409pub async fn get_cost_estimate_result(
410    store: &Option<Arc<TaskStore>>,
411    repo: &str,
412    task_id: &str,
413) -> StoreResult<CostEstimate> {
414    let s = store
415        .as_ref()
416        .ok_or_else(|| anyhow!("task store unavailable"))?;
417    let store_id = s
418        .resolve_task_id(repo, task_id)
419        .await?
420        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
421    let task = s.get(store_id).await?;
422    Ok(Some(CostEstimate {
423        input_cost_usd: task.input_cost_usd,
424        output_cost_usd: task.output_cost_usd,
425        total_cost_usd: task.total_cost_usd,
426    }))
427}
428
429/// Get cost estimate from the store.
430///
431/// Returns `CostEstimate::default()` when the store is unavailable or the task is not found.
432/// DB read errors are logged as warnings and return the default.
433pub async fn get_cost_estimate(
434    store: &Option<Arc<TaskStore>>,
435    repo: &str,
436    task_id: &str,
437) -> CostEstimate {
438    match get_cost_estimate_result(store, repo, task_id).await {
439        Ok(Some(cost)) => cost,
440        Ok(None) => CostEstimate::default(),
441        Err(e) => {
442            tracing::warn!(task_id, error = %e, "get_cost_estimate failed — returning zero cost");
443            CostEstimate::default()
444        }
445    }
446}
447
448/// Get recent memory entries for a task, surfacing DB read errors.
449pub async fn get_recent_memory_result(
450    store: &Option<Arc<TaskStore>>,
451    repo: &str,
452    task_id: &str,
453    max: usize,
454) -> StoreResult<Vec<MemoryEntry>> {
455    let s = store
456        .as_ref()
457        .ok_or_else(|| anyhow!("task store unavailable"))?;
458    let store_id = s
459        .resolve_task_id(repo, task_id)
460        .await?
461        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
462    let entries = s.recent_memory(store_id, max).await?;
463    Ok(Some(entries))
464}
465
466/// Get recent memory entries for a task.
467///
468/// Returns an empty vec when the store is unavailable or the task is not found.
469/// DB read errors are logged as warnings and return an empty vec.
470pub async fn get_recent_memory(
471    store: &Option<Arc<TaskStore>>,
472    repo: &str,
473    task_id: &str,
474    max: usize,
475) -> Vec<MemoryEntry> {
476    match get_recent_memory_result(store, repo, task_id, max).await {
477        Ok(Some(entries)) => entries,
478        Ok(None) => Vec::new(),
479        Err(e) => {
480            tracing::warn!(task_id, error = %e, "get_recent_memory failed — returning empty memory");
481            Vec::new()
482        }
483    }
484}