Skip to main content

orch/store/
tasks.rs

1use super::*;
2use crate::security;
3use serde::{Deserialize, Serialize};
4use sqlx::Row;
5
6/// Explicit column list for `SELECT` queries on the `tasks` table.
7///
8/// Using `SELECT *` with `sqlx-sqlite`'s persistent prepared statements can
9/// cause OOB panics when the schema changes (new migration columns) because
10/// `sqlite3_column_count()` returns the updated count but the cached column
11/// metadata vector was built at prepare time. Explicit columns prevent the
12/// mismatch.
13pub(crate) const TASK_COLS: &str = "id, external_id, repo, origin, title, body, status, \
14    source, source_id, author, url, labels, agent, model, complexity, estimate, \
15    route_reason, agent_profile, selected_skills, route_attempts, attempts, \
16    branch, worktree, worktree_cleaned, summary, last_error, parent_id, \
17    block_reason, pr_number, pr_review_context, last_review_ts, review_ts_map, \
18    last_comment_review_ts, merge_conflict_retries, ci_merge_failures, \
19    pr_create_failures, review_agent_failures, review_cycles, input_tokens, \
20    output_tokens, input_cost_usd, output_cost_usd, total_cost_usd, \
21    model_reroute_chain, limit_reroute_chain, \
22    memory, delegations, created_at, updated_at, review_session_expected, \
23     review_invocations, needs_review_refires, push_failures, auto_unblock_count, auto_unblock_last_at, \
24     ci_recovery_count, auto_unblock_last_reason, no_code_reroutes, network_retries, no_code_last_agent";
25
26/// Number of columns in TASK_COLS (used for diagnostic verification).
27pub(crate) const TASK_COLS_COUNT: usize = 60;
28
29/// Explicit column list for `SELECT` queries on the `task_runs` table.
30const TASK_RUN_COLS: &str =
31    "id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, \
32    exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, \
33    total_cost_usd, duration_secs, started_at, completed_at";
34
35/// Explicit column list for `SELECT` queries on the `task_activity` table.
36const TASK_ACTIVITY_COLS: &str =
37    "id, task_id, timestamp, event_type, from_status, to_status, agent, model, details";
38
39/// Allowlist of columns that can be updated via `set_fields` and `batch_set_fields`.
40const ALLOWED_FIELDS: &[&str] = &[
41    "agent",
42    "model",
43    "complexity",
44    "route_reason",
45    "agent_profile",
46    "selected_skills",
47    "route_attempts",
48    "attempts",
49    "branch",
50    "worktree",
51    "worktree_cleaned",
52    "summary",
53    "last_error",
54    "parent_id",
55    "block_reason",
56    "pr_number",
57    "pr_review_context",
58    "last_review_ts",
59    "review_ts_map",
60    "last_comment_review_ts",
61    "merge_conflict_retries",
62    "ci_merge_failures",
63    "pr_create_failures",
64    "push_failures",
65    "review_agent_failures",
66    "review_cycles",
67    "review_invocations",
68    "needs_review_refires",
69    "review_session_expected",
70    "input_tokens",
71    "output_tokens",
72    "input_cost_usd",
73    "output_cost_usd",
74    "total_cost_usd",
75    "model_reroute_chain",
76    "limit_reroute_chain",
77    "memory",
78    "delegations",
79    "source",
80    "source_id",
81    "labels",
82    "auto_unblock_count",
83    "auto_unblock_last_at",
84    "auto_unblock_last_reason",
85    "ci_recovery_count",
86    "no_code_reroutes",
87    "network_retries",
88    "no_code_last_agent",
89];
90
91/// Allowlist of fields that can be incremented via `increment` and `batch_increment`.
92const INCREMENTABLE_FIELDS: &[&str] = &[
93    "attempts",
94    "route_attempts",
95    "merge_conflict_retries",
96    "ci_merge_failures",
97    "pr_create_failures",
98    "push_failures",
99    "review_agent_failures",
100    "review_cycles",
101    "review_invocations",
102    "needs_review_refires",
103    "auto_unblock_count",
104    "ci_recovery_count",
105    "no_code_reroutes",
106    "network_retries",
107];
108
109fn sanitize_activity_details(value: &serde_json::Value) -> serde_json::Value {
110    match value {
111        serde_json::Value::String(text) => serde_json::Value::String(security::redact(text)),
112        serde_json::Value::Array(items) => serde_json::Value::Array(
113            items
114                .iter()
115                .map(sanitize_activity_details)
116                .collect::<Vec<_>>(),
117        ),
118        serde_json::Value::Object(map) => serde_json::Value::Object(
119            map.iter()
120                .map(|(key, value)| (key.clone(), sanitize_activity_details(value)))
121                .collect(),
122        ),
123        other => other.clone(),
124    }
125}
126
127fn sanitize_task_field_value(col: &str, value: &serde_json::Value) -> serde_json::Value {
128    match (col, value) {
129        ("last_error" | "block_reason", serde_json::Value::String(text)) => {
130            serde_json::Value::String(security::redact(text))
131        }
132        _ => value.clone(),
133    }
134}
135
136fn sql_value_for_task_field(
137    col: &str,
138    value: &serde_json::Value,
139) -> anyhow::Result<Option<String>> {
140    let sanitized = sanitize_task_field_value(col, value);
141    match sanitized {
142        serde_json::Value::String(text) => Ok(Some(text)),
143        serde_json::Value::Number(number) => Ok(Some(number.to_string())),
144        serde_json::Value::Bool(boolean) => Ok(Some(if boolean { "1" } else { "0" }.to_string())),
145        serde_json::Value::Null => Ok(None),
146        other => Ok(Some(serde_json::to_string(&other)?)),
147    }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TaskStatus {
153    New,
154    Routed,
155    InProgress,
156    Done,
157    Blocked,
158    InReview,
159    NeedsReview,
160    Waiting,
161}
162
163impl TaskStatus {
164    pub fn as_str(&self) -> &'static str {
165        match self {
166            Self::New => "new",
167            Self::Routed => "routed",
168            Self::InProgress => "in_progress",
169            Self::Done => "done",
170            Self::Blocked => "blocked",
171            Self::InReview => "in_review",
172            Self::NeedsReview => "needs_review",
173            Self::Waiting => "waiting",
174        }
175    }
176
177    pub fn from_str(s: &str) -> Option<Self> {
178        match s {
179            "new" => Some(Self::New),
180            "routed" => Some(Self::Routed),
181            "in_progress" => Some(Self::InProgress),
182            "done" => Some(Self::Done),
183            "blocked" => Some(Self::Blocked),
184            "in_review" => Some(Self::InReview),
185            "needs_review" => Some(Self::NeedsReview),
186            "waiting" => Some(Self::Waiting),
187            _ => None,
188        }
189    }
190}
191
192/// Unified task record — single row in the `tasks` table.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct Task {
195    pub id: i64,
196    pub external_id: Option<String>,
197    pub repo: String,
198    pub origin: String,
199
200    pub title: String,
201    pub body: String,
202    pub status: TaskStatus,
203    pub source: String,
204    pub source_id: String,
205    pub author: String,
206    pub url: String,
207    pub labels: Vec<String>,
208
209    // Routing
210    pub agent: Option<String>,
211    pub model: Option<String>,
212    pub complexity: String,
213    /// Fibonacci effort estimate (1, 2, 3, 5, 8, 13, or 21). 0 means not provided.
214    pub estimate: u8,
215    pub route_reason: String,
216    pub agent_profile: String,
217    pub selected_skills: String,
218    pub route_attempts: i32,
219
220    // Execution
221    pub attempts: i32,
222    pub branch: String,
223    pub worktree: String,
224    pub worktree_cleaned: bool,
225    pub summary: String,
226    pub last_error: String,
227    pub parent_id: Option<i64>,
228    pub block_reason: Option<String>,
229
230    // PR Review
231    pub pr_number: Option<i32>,
232    pub pr_review_context: String,
233    pub last_review_ts: String,
234    pub review_ts_map: String,
235    pub last_comment_review_ts: String,
236    pub merge_conflict_retries: i32,
237    pub ci_merge_failures: i32,
238    pub pr_create_failures: i32,
239    pub push_failures: i32,
240    pub network_retries: i32,
241    pub review_agent_failures: i32,
242    pub review_cycles: i32,
243    pub review_invocations: i32,
244    pub review_session_expected: bool,
245    // NeedsReview re-fire counter (incremented by sync catch-up and reset on successful review cycles)
246    pub needs_review_refires: i32,
247
248    // Tokens & Cost
249    pub input_tokens: i64,
250    pub output_tokens: i64,
251    pub input_cost_usd: f64,
252    pub output_cost_usd: f64,
253    pub total_cost_usd: f64,
254
255    // Recovery
256    pub model_reroute_chain: String,
257    pub limit_reroute_chain: String,
258
259    // Structured data
260    pub memory: Vec<MemoryEntry>,
261    pub delegations: Vec<serde_json::Value>,
262
263    // Auto-unblock tracking
264    pub auto_unblock_count: i32,
265    pub auto_unblock_last_at: String,
266    pub auto_unblock_last_reason: String,
267
268    // CI-based auto-recovery tracking (separate from auto_unblock_count)
269    pub ci_recovery_count: i32,
270
271    // Done-without-PR circuit breaker
272    pub no_code_reroutes: i32,
273    /// Which agent produced the last no-code failure (used to detect same-agent loops).
274    pub no_code_last_agent: String,
275
276    // Timestamps
277    pub created_at: String,
278    pub updated_at: String,
279}
280
281/// Parameters for creating a new task.
282#[derive(Debug, Clone, Default)]
283pub struct NewTask {
284    pub external_id: Option<String>,
285    pub repo: String,
286    pub origin: String,
287    pub title: String,
288    pub body: String,
289    pub source: String,
290    pub source_id: String,
291    pub author: String,
292    pub url: String,
293    pub labels: Vec<String>,
294    /// Optional link to a parent task (e.g. the issue/PR a mention was posted on).
295    pub parent_id: Option<i64>,
296}
297
298/// A single run (agent execution, review, or routing attempt).
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct TaskRun {
301    pub id: i64,
302    pub task_id: i64,
303    pub attempt: i32,
304    pub run_type: String,
305    pub agent: String,
306    pub model: String,
307    pub command: String,
308    pub prompt: String,
309    pub env_vars: String,
310    pub exit_code: Option<i32>,
311    pub stdout: String,
312    pub stderr: String,
313    pub parsed_response: String,
314    pub outcome: String,
315    pub error: String,
316    pub input_tokens: i64,
317    pub output_tokens: i64,
318    pub total_cost_usd: f64,
319    pub duration_secs: f64,
320    pub started_at: String,
321    pub completed_at: Option<String>,
322}
323
324/// A single task lifecycle activity event.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct TaskActivity {
327    pub id: i64,
328    pub task_id: i64,
329    pub timestamp: String,
330    pub event_type: String,
331    pub from_status: Option<String>,
332    pub to_status: Option<String>,
333    pub agent: Option<String>,
334    pub model: Option<String>,
335    pub details: serde_json::Value,
336}
337
338/// Token usage for a run.
339#[derive(Debug, Clone, Copy, Default)]
340pub struct RunTokenUsage {
341    pub input_tokens: u64,
342    pub output_tokens: u64,
343    pub total_cost_usd: f64,
344    pub duration_secs: f64,
345}
346
347/// Parameters for starting a new run.
348#[derive(Debug, Clone)]
349pub struct StartRun<'a> {
350    pub task_id: i64,
351    pub attempt: i32,
352    pub run_type: &'a str,
353    pub agent: &'a str,
354    pub model: &'a str,
355    pub command: &'a str,
356    pub prompt: &'a str,
357}
358
359/// Parameters for completing a run.
360#[derive(Debug, Clone)]
361pub struct CompleteRun<'a> {
362    pub run_id: i64,
363    pub exit_code: Option<i32>,
364    pub stdout: &'a str,
365    pub stderr: &'a str,
366    pub parsed: &'a str,
367    pub outcome: &'a str,
368    pub error: &'a str,
369    pub tokens: RunTokenUsage,
370}
371
372/// Parameters for storing a route result.
373#[derive(Debug, Clone)]
374pub struct StoreRoute<'a> {
375    pub id: i64,
376    pub agent: &'a str,
377    pub model: Option<&'a str>,
378    pub complexity: &'a str,
379    /// Fibonacci effort estimate (1, 2, 3, 5, 8, 13, or 21). 0 means not provided.
380    pub estimate: u8,
381    pub reason: &'a str,
382    pub profile: &'a str,
383    pub skills: &'a str,
384}
385
386/// Parameters for upserting an external task.
387#[derive(Debug, Clone)]
388pub struct UpsertExternal<'a> {
389    pub repo: &'a str,
390    pub ext_id: &'a str,
391    pub title: &'a str,
392    pub body: &'a str,
393    pub author: &'a str,
394    pub url: &'a str,
395    pub labels: &'a [String],
396    pub origin: &'a str,
397}
398
399fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
400    labels
401        .iter()
402        .find_map(|label| label.strip_prefix("parent:"))
403        .filter(|parent| !parent.is_empty())
404}
405
406impl TaskStore {
407    /// Append a lifecycle activity event for a task.
408    #[allow(clippy::too_many_arguments)]
409    pub async fn append_activity(
410        &self,
411        task_id: i64,
412        event_type: &str,
413        from_status: Option<&str>,
414        to_status: Option<&str>,
415        agent: Option<&str>,
416        model: Option<&str>,
417        details: Option<&serde_json::Value>,
418    ) -> anyhow::Result<()> {
419        let details_json = match details {
420            Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
421            None => "{}".to_string(),
422        };
423        sqlx::query(
424            "INSERT INTO task_activity
425             (task_id, event_type, from_status, to_status, agent, model, details)
426             VALUES (?, ?, ?, ?, ?, ?, ?)",
427        )
428        .bind(task_id)
429        .bind(event_type)
430        .bind(from_status)
431        .bind(to_status)
432        .bind(agent)
433        .bind(model)
434        .bind(details_json)
435        .execute(&self.pool)
436        .await?;
437        Ok(())
438    }
439
440    /// Append a lifecycle activity event for a task within a transaction.
441    #[doc(hidden)]
442    #[allow(clippy::too_many_arguments)]
443    pub async fn append_activity_tx(
444        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
445        task_id: i64,
446        event_type: &str,
447        from_status: Option<&str>,
448        to_status: Option<&str>,
449        agent: Option<&str>,
450        model: Option<&str>,
451        details: Option<&serde_json::Value>,
452    ) -> anyhow::Result<()> {
453        let details_json = match details {
454            Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
455            None => "{}".to_string(),
456        };
457        sqlx::query(
458            "INSERT INTO task_activity
459              (task_id, event_type, from_status, to_status, agent, model, details)
460              VALUES (?, ?, ?, ?, ?, ?, ?)",
461        )
462        .bind(task_id)
463        .bind(event_type)
464        .bind(from_status)
465        .bind(to_status)
466        .bind(agent)
467        .bind(model)
468        .bind(details_json)
469        .execute(&mut **tx)
470        .await?;
471        Ok(())
472    }
473
474    /// Get task activity timeline in chronological order.
475    pub async fn get_activity(
476        &self,
477        task_id: i64,
478        limit: Option<usize>,
479    ) -> anyhow::Result<Vec<TaskActivity>> {
480        let rows = if let Some(limit) = limit {
481            let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
482            sqlx::query(&format!(
483                "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
484            ))
485            .bind(task_id)
486            .bind(limit_i64)
487            .fetch_all(&self.pool)
488            .await?
489        } else {
490            sqlx::query(&format!(
491                "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
492            ))
493            .bind(task_id)
494            .fetch_all(&self.pool)
495            .await?
496        };
497
498        rows.iter().map(Self::row_to_activity).collect()
499    }
500
501    pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
502        let labels_json = serde_json::to_string(&new.labels)?;
503        let row = sqlx::query(
504        "INSERT INTO tasks (external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id)
505         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
506         RETURNING id",
507    )
508    .bind(&new.external_id)
509    .bind(&new.repo)
510    .bind(&new.origin)
511    .bind(&new.title)
512    .bind(&new.body)
513    .bind(&new.source)
514    .bind(&new.source_id)
515    .bind(&new.author)
516    .bind(&new.url)
517    .bind(&labels_json)
518    .bind(new.parent_id)
519    .fetch_one(&self.pool)
520    .await?;
521
522        row.try_get("id")
523            .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
524    }
525
526    /// Create an internal task, returning its auto-generated ID.
527    ///
528    /// Convenience wrapper around `create()` that sets origin = "internal"
529    /// and generates `external_id = "internal:{id}"` after creation.
530    pub async fn create_internal(
531        &self,
532        repo: &str,
533        title: &str,
534        body: &str,
535        source: &str,
536        source_id: &str,
537        parent_id: Option<i64>,
538    ) -> anyhow::Result<i64> {
539        let id = self
540            .create(&NewTask {
541                external_id: None,
542                repo: repo.to_string(),
543                origin: "internal".to_string(),
544                title: title.to_string(),
545                body: body.to_string(),
546                source: source.to_string(),
547                source_id: source_id.to_string(),
548                author: String::new(),
549                url: String::new(),
550                labels: vec![],
551                parent_id,
552            })
553            .await?;
554        // Set external_id to "internal:{id}" so resolve_task_id can find it
555        sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
556            .bind(format!("internal:{id}"))
557            .bind(id)
558            .execute(&self.pool)
559            .await?;
560        Ok(id)
561    }
562
563    /// Get a task by its internal ID.
564    pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
565        let row = sqlx::query(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?"))
566            .bind(id)
567            .fetch_optional(&self.pool)
568            .await?
569            .with_context(|| format!("task {id} not found"))?;
570
571        Self::row_to_task(&row)
572    }
573
574    /// Get a task by its external ID within a repo.
575    pub async fn get_by_external_id(
576        &self,
577        repo: &str,
578        ext_id: &str,
579    ) -> anyhow::Result<Option<Task>> {
580        let row = sqlx::query(&format!(
581            "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
582        ))
583        .bind(repo)
584        .bind(ext_id)
585        .fetch_optional(&self.pool)
586        .await?;
587
588        match row {
589            Some(r) => Ok(Some(Self::row_to_task(&r)?)),
590            None => Ok(None),
591        }
592    }
593
594    /// Lightweight lookup: return only the numeric `id` for a given repo + external_id.
595    ///
596    /// Many call sites only need the internal store ID and were previously calling
597    /// `get_by_external_id()` which deserializes the full 60-column `Task` row. This
598    /// method avoids that work by selecting only `id`.
599    pub async fn resolve_id_by_external(
600        &self,
601        repo: &str,
602        ext_id: &str,
603    ) -> anyhow::Result<Option<i64>> {
604        let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
605            .bind(repo)
606            .bind(ext_id)
607            .fetch_optional(&self.pool)
608            .await?;
609        let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
610        Ok(id)
611    }
612
613    /// Resolve the parent task ID by PR number.
614    ///
615    /// When a mention arrives on a PR, we need to find the issue task that owns
616    /// this PR. The task store tracks `pr_number` on tasks, so we look up the
617    /// task whose `pr_number` matches the given PR number.
618    pub async fn resolve_id_by_pr_number(
619        &self,
620        repo: &str,
621        pr_number: i32,
622    ) -> anyhow::Result<Option<i64>> {
623        let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
624            .bind(repo)
625            .bind(pr_number)
626            .fetch_optional(&self.pool)
627            .await?;
628        let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
629        Ok(id)
630    }
631
632    /// Lightweight lookup: return only the `agent` column for a given repo + external_id.
633    /// Returns None when no row matches or the agent is NULL/empty.
634    pub async fn get_agent_by_external_id(
635        &self,
636        repo: &str,
637        ext_id: &str,
638    ) -> anyhow::Result<Option<String>> {
639        let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
640            .bind(repo)
641            .bind(ext_id)
642            .fetch_optional(&self.pool)
643            .await?;
644        let agent = row
645            .map(|r| r.try_get::<Option<String>, _>("agent"))
646            .transpose()?
647            .flatten();
648        Ok(agent)
649    }
650
651    /// Upsert an external task — insert if new, update title/body/labels if exists.
652    pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
653        let labels_json = serde_json::to_string(ext.labels)?;
654        let parent_id = match parent_external_id_from_labels(ext.labels) {
655            Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
656            None => None,
657        };
658        let row = sqlx::query(
659            "INSERT INTO tasks (external_id, repo, origin, title, body, author, url, labels, parent_id)
660         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
661         ON CONFLICT(repo, external_id) DO UPDATE SET
662            title = excluded.title,
663            body = excluded.body,
664            labels = excluded.labels,
665            url = excluded.url,
666            parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
667         RETURNING id",
668        )
669        .bind(ext.ext_id)
670        .bind(ext.repo)
671        .bind(ext.origin)
672        .bind(ext.title)
673        .bind(ext.body)
674        .bind(ext.author)
675        .bind(ext.url)
676        .bind(&labels_json)
677        .bind(parent_id)
678        .fetch_one(&self.pool)
679        .await?;
680
681        row.try_get("id")
682            .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
683    }
684
685    // ---------------------------------------------------------------
686    // Status
687    // ---------------------------------------------------------------
688
689    pub async fn update_status_and_fields(
690        &self,
691        id: i64,
692        status: TaskStatus,
693        updates: &[(&str, serde_json::Value)],
694    ) -> anyhow::Result<()> {
695        for (col, _) in updates {
696            anyhow::ensure!(
697                ALLOWED_FIELDS.contains(col),
698                "column {col} is not in the update allowlist"
699            );
700        }
701
702        let mut tx = self.pool.begin().await?;
703        let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
704            .bind(id)
705            .fetch_one(&mut *tx)
706            .await?;
707        let from_status: String = previous
708            .try_get("status")
709            .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
710        let agent: Option<String> = previous
711            .try_get("agent")
712            .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
713        let model: Option<String> = previous
714            .try_get("model")
715            .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
716
717        let mut set_parts = Vec::new();
718        let mut values: Vec<Option<String>> = Vec::new();
719
720        set_parts.push("status = ?".to_string());
721        values.push(Some(status.as_str().to_string()));
722
723        let mut block_reason_in_updates = false;
724        for (col, val) in updates {
725            if *col == "block_reason" {
726                block_reason_in_updates = true;
727            }
728            set_parts.push(format!("{col} = ?"));
729            match val {
730                serde_json::Value::String(s) => values.push(Some(s.clone())),
731                serde_json::Value::Number(n) => values.push(Some(n.to_string())),
732                serde_json::Value::Bool(b) => {
733                    values.push(Some(if *b { "1" } else { "0" }.to_string()));
734                }
735                serde_json::Value::Null => values.push(None),
736                other => values.push(Some(serde_json::to_string(other)?)),
737            }
738        }
739
740        if !block_reason_in_updates && status != TaskStatus::Blocked {
741            set_parts.push("block_reason = NULL".to_string());
742        }
743
744        set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
745        let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
746
747        let mut query = sqlx::query(&sql);
748        for v in &values {
749            query = query.bind(v.as_deref());
750        }
751        query = query.bind(id);
752        query.execute(&mut *tx).await?;
753
754        Self::append_activity_tx(
755            &mut tx,
756            id,
757            "status_change",
758            Some(from_status.as_str()),
759            Some(status.as_str()),
760            agent.as_deref(),
761            model.as_deref(),
762            None,
763        )
764        .await?;
765        tx.commit().await?;
766        Ok(())
767    }
768
769    /// Update the status of a task.
770    pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
771        let mut tx = self.pool.begin().await?;
772        let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
773            .bind(id)
774            .fetch_one(&mut *tx)
775            .await?;
776        let from_status: String = previous
777            .try_get("status")
778            .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
779        let agent: Option<String> = previous
780            .try_get("agent")
781            .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
782        let model: Option<String> = previous
783            .try_get("model")
784            .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
785        let sql = if status == TaskStatus::Blocked {
786            "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
787        } else {
788            "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
789        };
790        sqlx::query(sql)
791            .bind(status.as_str())
792            .bind(id)
793            .execute(&mut *tx)
794            .await?;
795
796        Self::append_activity_tx(
797            &mut tx,
798            id,
799            "status_change",
800            Some(from_status.as_str()),
801            Some(status.as_str()),
802            agent.as_deref(),
803            model.as_deref(),
804            None,
805        )
806        .await?;
807        tx.commit().await?;
808        Ok(())
809    }
810
811    /// Persist the external_id (e.g. a newly-created GitHub issue number) onto an existing task row.
812    pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
813        sqlx::query(
814            "UPDATE tasks SET external_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
815        )
816        .bind(external_id)
817        .bind(id)
818        .execute(&self.pool)
819        .await?;
820        Ok(())
821    }
822
823    /// Conditionally update the status of a task only if it currently has `expected` status.
824    ///
825    /// Returns `true` if the row was updated, `false` if the current status did not match
826    /// `expected` (i.e. a concurrent transition already moved the task elsewhere).
827    pub async fn update_status_if(
828        &self,
829        id: i64,
830        status: TaskStatus,
831        expected: TaskStatus,
832    ) -> anyhow::Result<bool> {
833        let mut tx = self.pool.begin().await?;
834        let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
835            .bind(id)
836            .fetch_one(&mut *tx)
837            .await?;
838        let from_status: String = previous
839            .try_get("status")
840            .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
841        let agent: Option<String> = previous
842            .try_get("agent")
843            .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
844        let model: Option<String> = previous
845            .try_get("model")
846            .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
847        let sql = if status == TaskStatus::Blocked {
848            "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
849        } else {
850            "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
851        };
852        let result = sqlx::query(sql)
853            .bind(status.as_str())
854            .bind(id)
855            .bind(expected.as_str())
856            .execute(&mut *tx)
857            .await?;
858        if result.rows_affected() == 0 {
859            return Ok(false);
860        }
861        Self::append_activity_tx(
862            &mut tx,
863            id,
864            "status_change",
865            Some(from_status.as_str()),
866            Some(status.as_str()),
867            agent.as_deref(),
868            model.as_deref(),
869            None,
870        )
871        .await?;
872        tx.commit().await?;
873        Ok(true)
874    }
875
876    /// Touch `updated_at` to now without changing any other field.
877    ///
878    /// Called by the runner immediately after a tmux session exits so that the
879    /// stuck-task recovery timer is reset before response_handler post-processing
880    /// (git push, PR creation, status update) begins.  Without this, a session
881    /// that ran longer than `no_session_stuck_timeout` (default 10 min) would be
882    /// re-dispatched by the tick while the runner is still writing results.
883    pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
884        sqlx::query(
885            "UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
886        )
887        .bind(id)
888        .execute(&self.pool)
889        .await?;
890        Ok(())
891    }
892
893    /// Reset a task back to `new`.
894    pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
895        let mut tx = self.pool.begin().await?;
896        let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
897            .bind(id)
898            .fetch_one(&mut *tx)
899            .await?;
900        let from_status: String = previous
901            .try_get("status")
902            .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
903        let agent: Option<String> = previous
904            .try_get("agent")
905            .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
906        let model: Option<String> = previous
907            .try_get("model")
908            .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
909        sqlx::query(
910            "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
911        )
912        .bind(id)
913        .execute(&mut *tx)
914        .await?;
915        let details = serde_json::json!({ "op": "reset_to_new" });
916        Self::append_activity_tx(
917            &mut tx,
918            id,
919            "status_change",
920            Some(from_status.as_str()),
921            Some(TaskStatus::New.as_str()),
922            agent.as_deref(),
923            model.as_deref(),
924            Some(&details),
925        )
926        .await?;
927        tx.commit().await?;
928        Ok(())
929    }
930
931    /// Update the block reason for a task.
932    pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
933        let value = reason
934            .map(|r| serde_json::Value::String(r.to_string()))
935            .unwrap_or(serde_json::Value::Null);
936        self.set_fields(id, &[("block_reason", value)]).await
937    }
938
939    /// List tasks by status within a repo.
940    pub async fn list_by_status(
941        &self,
942        repo: &str,
943        status: TaskStatus,
944    ) -> anyhow::Result<Vec<Task>> {
945        let rows = sqlx::query(&format!(
946            "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
947        ))
948        .bind(repo)
949        .bind(status.as_str())
950        .fetch_all(&self.pool)
951        .await?;
952
953        rows.iter().map(Self::row_to_task).collect()
954    }
955
956    /// List tasks that are linked to the given parent task.
957    pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
958        let rows = sqlx::query(&format!(
959            "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
960        ))
961        .bind(repo)
962        .bind(parent_id)
963        .fetch_all(&self.pool)
964        .await?;
965
966        rows.iter().map(Self::row_to_task).collect()
967    }
968
969    /// List external tasks by status within a repo (origin != 'internal').
970    pub async fn list_external_by_status(
971        &self,
972        repo: &str,
973        status: TaskStatus,
974    ) -> anyhow::Result<Vec<Task>> {
975        let rows = sqlx::query(
976            &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC"),
977        )
978        .bind(repo)
979        .bind(status.as_str())
980        .fetch_all(&self.pool)
981        .await?;
982
983        rows.iter().map(Self::row_to_task).collect()
984    }
985
986    /// List routable tasks (status = 'new') within a repo.
987    pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
988        self.list_by_status(repo, TaskStatus::New).await
989    }
990
991    /// List internal tasks by status within a repo (origin = 'internal').
992    pub async fn list_internal_by_status(
993        &self,
994        repo: &str,
995        status: TaskStatus,
996    ) -> anyhow::Result<Vec<Task>> {
997        let rows = sqlx::query(
998        &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC"),
999    )
1000    .bind(repo)
1001    .bind(status.as_str())
1002    .fetch_all(&self.pool)
1003    .await?;
1004
1005        rows.iter().map(Self::row_to_task).collect()
1006    }
1007
1008    /// List all internal tasks for a repo (origin = 'internal').
1009    pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1010        let rows = sqlx::query(
1011            &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC"),
1012        )
1013        .bind(repo)
1014        .fetch_all(&self.pool)
1015        .await?;
1016
1017        rows.iter().map(Self::row_to_task).collect()
1018    }
1019
1020    /// List internal tasks with a specific source value (origin = 'internal' AND source = ?).
1021    /// More efficient than `list_all_internal` + in-memory filter when only a subset is needed.
1022    pub async fn list_internal_by_source(
1023        &self,
1024        repo: &str,
1025        source: &str,
1026    ) -> anyhow::Result<Vec<Task>> {
1027        let rows = sqlx::query(
1028            &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC"),
1029        )
1030        .bind(repo)
1031        .bind(source)
1032        .fetch_all(&self.pool)
1033        .await?;
1034
1035        rows.iter().map(Self::row_to_task).collect()
1036    }
1037
1038    /// Return only the `source_id` values for internal tasks with a specific source,
1039    /// created within the last `since_days` days.
1040    /// Use this instead of `list_internal_by_source` when only deduplication is needed —
1041    /// avoids fetching all 57 task columns and bounds memory usage to a rolling window.
1042    pub async fn list_source_ids_by_source(
1043        &self,
1044        repo: &str,
1045        source: &str,
1046        since_days: i64,
1047    ) -> anyhow::Result<Vec<String>> {
1048        let cutoff = format!("-{since_days} days");
1049        let rows = sqlx::query(
1050            "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1051        )
1052        .bind(repo)
1053        .bind(source)
1054        .bind(&cutoff)
1055        .fetch_all(&self.pool)
1056        .await?;
1057
1058        let mut ids = Vec::with_capacity(rows.len());
1059        for row in &rows {
1060            ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1061                anyhow::anyhow!(
1062                    "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1063                )
1064            })?);
1065        }
1066        Ok(ids)
1067    }
1068
1069    /// List all active (non-done) tasks for a repo.
1070    pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1071        let rows = sqlx::query(
1072            &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC"),
1073        )
1074        .bind(repo)
1075        .fetch_all(&self.pool)
1076        .await?;
1077
1078        rows.iter().map(Self::row_to_task).collect()
1079    }
1080
1081    /// List all external tasks for a repo (origin != 'internal').
1082    pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1083        let rows = sqlx::query(
1084            &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC"),
1085        )
1086        .bind(repo)
1087        .fetch_all(&self.pool)
1088        .await?;
1089
1090        rows.iter().map(Self::row_to_task).collect()
1091    }
1092
1093    /// Check whether the store has any external tasks for a repo.
1094    pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1095        let has_external = sqlx::query_scalar::<_, i32>(
1096            "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1097        )
1098        .bind(repo)
1099        .fetch_optional(&self.pool)
1100        .await?
1101        .is_some();
1102        Ok(has_external)
1103    }
1104
1105    /// Return a set of external IDs for all external tasks in a repo.
1106    ///
1107    /// Used to eliminate N+1 queries when ingesting lists of external tasks.
1108    pub async fn existing_external_ids(
1109        &self,
1110        repo: &str,
1111    ) -> anyhow::Result<std::collections::HashSet<String>> {
1112        let rows: Vec<(String,)> = sqlx::query_as(
1113            "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1114        )
1115        .bind(repo)
1116        .fetch_all(&self.pool)
1117        .await?;
1118
1119        Ok(rows
1120            .into_iter()
1121            .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1122            .collect())
1123    }
1124
1125    /// List tasks for `orch service doctor`: active tasks + done tasks updated since `cutoff_str`.
1126    /// Much cheaper than `list_all` on repos with many historical done tasks.
1127    pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1128        let rows = sqlx::query(
1129            &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC"),
1130        )
1131        .bind(repo)
1132        .bind(cutoff_str)
1133        .fetch_all(&self.pool)
1134        .await?;
1135        rows.iter().map(Self::row_to_task).collect()
1136    }
1137
1138    /// List all tasks for a repo, ordered by creation time descending.
1139    pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1140        let rows = sqlx::query(&format!(
1141            "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1142        ))
1143        .bind(repo)
1144        .fetch_all(&self.pool)
1145        .await?;
1146        rows.iter().map(Self::row_to_task).collect()
1147    }
1148
1149    /// Return the distinct repo slugs present in the tasks table.
1150    pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1151        let rows =
1152            sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1153                .fetch_all(&self.pool)
1154                .await?;
1155        let mut repos = Vec::new();
1156        for row in &rows {
1157            repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1158                anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1159            })?);
1160        }
1161        Ok(repos)
1162    }
1163
1164    /// List all active (non-done) tasks across all repos.
1165    ///
1166    /// Used by the CLI when no project context is available (e.g. running from
1167    /// a worktree without a `.orch.yml`). Does not require a repo argument.
1168    pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1169        let rows = sqlx::query(&format!(
1170            "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1171        ))
1172        .fetch_all(&self.pool)
1173        .await?;
1174        rows.iter().map(Self::row_to_task).collect()
1175    }
1176
1177    /// List all tasks across all repos regardless of status.
1178    ///
1179    /// Used by the CLI when `--status all` is passed.
1180    pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1181        let rows = sqlx::query(&format!(
1182            "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1183        ))
1184        .fetch_all(&self.pool)
1185        .await?;
1186        rows.iter().map(Self::row_to_task).collect()
1187    }
1188
1189    /// List all active tasks matching an optional status filter, across all repos.
1190    ///
1191    /// Used by the CLI fallback path when no project context is available.
1192    pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1193        let rows = sqlx::query(&format!(
1194            "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1195        ))
1196        .bind(status.as_str())
1197        .fetch_all(&self.pool)
1198        .await?;
1199        rows.iter().map(Self::row_to_task).collect()
1200    }
1201
1202    /// Aggregate cost and token data for a repo.
1203    /// Returns (total_input_tokens, total_output_tokens, total_cost_usd).
1204    pub async fn set_fields(
1205        &self,
1206        id: i64,
1207        updates: &[(&str, serde_json::Value)],
1208    ) -> anyhow::Result<()> {
1209        if updates.is_empty() {
1210            return Ok(());
1211        }
1212
1213        for (col, _) in updates {
1214            anyhow::ensure!(
1215                ALLOWED_FIELDS.contains(col),
1216                "column {col} is not in the update allowlist"
1217            );
1218        }
1219
1220        // Build SET clause dynamically
1221        let mut set_parts = Vec::new();
1222        let mut values: Vec<Option<String>> = Vec::new();
1223
1224        for (col, val) in updates {
1225            set_parts.push(format!("{col} = ?"));
1226            values.push(sql_value_for_task_field(col, val)?);
1227        }
1228
1229        set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1230        let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1231
1232        let mut query = sqlx::query(&sql);
1233        for v in &values {
1234            query = query.bind(v.as_deref());
1235        }
1236        query = query.bind(id);
1237        query.execute(&self.pool).await?;
1238
1239        Ok(())
1240    }
1241
1242    /// Like `set_fields` but does NOT touch `updated_at`.
1243    ///
1244    /// Use this for internal bookkeeping writes (probe timestamps, counters) that
1245    /// must not make a stale task appear recently-modified to triage queries.
1246    pub async fn set_fields_silent(
1247        &self,
1248        id: i64,
1249        updates: &[(&str, serde_json::Value)],
1250    ) -> anyhow::Result<()> {
1251        if updates.is_empty() {
1252            return Ok(());
1253        }
1254
1255        for (col, _) in updates {
1256            anyhow::ensure!(
1257                ALLOWED_FIELDS.contains(col),
1258                "column {col} is not in the update allowlist"
1259            );
1260        }
1261
1262        let mut set_parts = Vec::new();
1263        let mut values: Vec<Option<String>> = Vec::new();
1264
1265        for (col, val) in updates {
1266            set_parts.push(format!("{col} = ?"));
1267            values.push(sql_value_for_task_field(col, val)?);
1268        }
1269
1270        let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1271
1272        let mut query = sqlx::query(&sql);
1273        for v in &values {
1274            query = query.bind(v.as_deref());
1275        }
1276        query = query.bind(id);
1277        query.execute(&self.pool).await?;
1278
1279        Ok(())
1280    }
1281
1282    /// Increment an integer field by 1, returning the new value.
1283    pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1284        anyhow::ensure!(
1285            INCREMENTABLE_FIELDS.contains(&field),
1286            "field {field} is not incrementable"
1287        );
1288
1289        let sql = format!(
1290        "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? RETURNING {field} AS val"
1291    );
1292
1293        let row = sqlx::query(&sql).bind(id).fetch_one(&self.pool).await?;
1294
1295        Ok(row.try_get("val")?)
1296    }
1297
1298    /// Reset all failure/retry counters to zero.
1299    pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1300        sqlx::query(
1301            "UPDATE tasks SET
1302            attempts = 0,
1303            route_attempts = 0,
1304            review_agent_failures = 0,
1305            merge_conflict_retries = 0,
1306            pr_create_failures = 0,
1307            push_failures = 0,
1308            network_retries = 0,
1309            ci_merge_failures = 0,
1310            review_cycles = 0,
1311            review_invocations = 0,
1312            review_session_expected = 0,
1313            auto_unblock_count = 0,
1314            auto_unblock_last_at = '',
1315            auto_unblock_last_reason = '',
1316            ci_recovery_count = 0,
1317            no_code_reroutes = 0,
1318            no_code_last_agent = '',
1319            needs_review_refires = 0,
1320            updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1321         WHERE id = ?",
1322        )
1323        .bind(id)
1324        .execute(&self.pool)
1325        .await?;
1326        Ok(())
1327    }
1328
1329    /// Reset transient failure/retry counters to zero, preserving
1330    /// `review_cycles`, `ci_merge_failures`, and `merge_conflict_retries`.
1331    ///
1332    /// Use this after `RequestChanges` to clear per-attempt noise without
1333    /// undoing the review-cycle count that `handle_review_changes` just set.
1334    /// `ci_merge_failures` and `merge_conflict_retries` are preserved here because
1335    /// they must accumulate across attempts to enforce their circuit breakers,
1336    /// just like `review_cycles`.
1337    ///
1338    /// NOTE: `attempts` and `review_invocations` are intentionally NOT reset here.
1339    /// Both must increase monotonically across the task's lifetime so that
1340    /// `(task_id, attempt, run_type)` keys in `task_runs` remain unique. Resetting
1341    /// either causes subsequent retries to overwrite earlier audit trail records via
1342    /// the ON CONFLICT UPSERT in `start_run()`.
1343    pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1344        sqlx::query(
1345            "UPDATE tasks SET
1346            route_attempts = 0,
1347            review_agent_failures = 0,
1348            pr_create_failures = 0,
1349            push_failures = 0,
1350            network_retries = 0,
1351            review_session_expected = 0,
1352            auto_unblock_count = 0,
1353            auto_unblock_last_at = '',
1354            auto_unblock_last_reason = '',
1355            ci_recovery_count = 0,
1356            no_code_reroutes = 0,
1357            no_code_last_agent = '',
1358            needs_review_refires = 0,
1359            updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1360         WHERE id = ?",
1361        )
1362        .bind(id)
1363        .execute(&self.pool)
1364        .await?;
1365        Ok(())
1366    }
1367
1368    // ---------------------------------------------------------------
1369    // Routing
1370    // ---------------------------------------------------------------
1371
1372    /// Store routing results for a task.
1373    pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1374        sqlx::query(
1375            "UPDATE tasks SET
1376            agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
1377            agent_profile = ?, selected_skills = ?,
1378            updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1379         WHERE id = ?",
1380        )
1381        .bind(route.agent)
1382        .bind(route.model)
1383        .bind(route.complexity)
1384        .bind(route.estimate as i64)
1385        .bind(route.reason)
1386        .bind(route.profile)
1387        .bind(route.skills)
1388        .bind(route.id)
1389        .execute(&self.pool)
1390        .await?;
1391        let details = serde_json::json!({
1392            "complexity": route.complexity,
1393            "estimate": route.estimate,
1394            "reason": route.reason,
1395            "skills": route.skills,
1396        });
1397        self.append_activity(
1398            route.id,
1399            "dispatch",
1400            None,
1401            None,
1402            Some(route.agent),
1403            route.model,
1404            Some(&details),
1405        )
1406        .await?;
1407        Ok(())
1408    }
1409
1410    /// Return `(external_id, estimate)` for all non-internal tasks that have a
1411    /// positive Fibonacci estimate stored in the database.
1412    ///
1413    /// Used by startup estimate reconciliation to push orch-stored estimates to
1414    /// the GitHub Projects board for tasks that were routed before the estimate
1415    /// sync feature was available or before `project_estimate_field_id` was
1416    /// configured.
1417    pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1418        let rows = sqlx::query(
1419            "SELECT external_id, estimate FROM tasks \
1420             WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1421             AND status NOT IN ('done', 'blocked')",
1422        )
1423        .fetch_all(&self.pool)
1424        .await?;
1425
1426        let mut result = Vec::new();
1427        for row in &rows {
1428            let external_id: String = row.try_get("external_id")?;
1429            let estimate_raw: i64 = row.try_get("estimate")?;
1430            if (1..=255).contains(&estimate_raw) {
1431                result.push((external_id, estimate_raw as u8));
1432            }
1433        }
1434        Ok(result)
1435    }
1436
1437    /// Set the Fibonacci estimate for a task (only if currently 0).
1438    ///
1439    /// Used during ingestion to populate `tasks.estimate` from GitHub Projects
1440    /// when the orch estimate is still 0. This avoids overwriting estimates
1441    /// already set by the router.
1442    pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1443        let result = sqlx::query("UPDATE tasks SET estimate = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND estimate = 0")
1444            .bind(estimate as i64)
1445            .bind(id)
1446            .execute(&self.pool)
1447            .await?;
1448
1449        Ok(result.rows_affected() > 0)
1450    }
1451
1452    // ---------------------------------------------------------------
1453    // Memory
1454    // ---------------------------------------------------------------
1455
1456    /// Append a memory entry to the task's memory JSON array.
1457    ///
1458    /// Uses a transaction to prevent lost-update races when concurrent
1459    /// callers append simultaneously.
1460    pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1461        let mut tx = self.pool.begin().await?;
1462
1463        let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1464            .bind(id)
1465            .fetch_one(&mut *tx)
1466            .await?;
1467
1468        // Propagate decode errors from the DB (e.g. invalid BLOB/UTF-8). We
1469        // intentionally do not treat decode/parse failures as an empty memory
1470        // because that would allow append_memory to overwrite a corrupt value
1471        // with a new array containing only the new entry (data loss).
1472        let memory_opt: Option<String> = row
1473            .try_get::<Option<String>, _>("memory")
1474            .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1475        let memory_str = memory_opt.unwrap_or_default();
1476
1477        let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1478            Vec::new()
1479        } else {
1480            serde_json::from_str(&memory_str)
1481                .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1482        };
1483        memory.push(entry.clone());
1484        let new_json = serde_json::to_string(&memory)?;
1485
1486        sqlx::query(
1487        "UPDATE tasks SET memory = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1488    )
1489    .bind(&new_json)
1490    .bind(id)
1491    .execute(&mut *tx)
1492    .await?;
1493
1494        tx.commit().await?;
1495        Ok(())
1496    }
1497
1498    /// Get the N most recent memory entries for a task.
1499    pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1500        let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1501            .bind(id)
1502            .fetch_one(&self.pool)
1503            .await?;
1504
1505        let memory_opt: Option<String> = row
1506            .try_get::<Option<String>, _>("memory")
1507            .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1508        let memory_str = memory_opt.unwrap_or_default();
1509
1510        let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1511            Vec::new()
1512        } else {
1513            serde_json::from_str(&memory_str)
1514                .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1515        };
1516
1517        memory.sort_by_key(|m| m.attempt);
1518        if memory.len() > max {
1519            memory = memory.split_off(memory.len() - max);
1520        }
1521        Ok(memory)
1522    }
1523
1524    // ---------------------------------------------------------------
1525    // Tokens
1526    // ---------------------------------------------------------------
1527
1528    /// Store token usage and cost for a task.
1529    pub async fn store_tokens(
1530        &self,
1531        id: i64,
1532        input: u64,
1533        output: u64,
1534        model: &str,
1535    ) -> anyhow::Result<()> {
1536        let pricing = pricing_for_model(model);
1537        let usage = TokenUsage {
1538            input_tokens: input,
1539            output_tokens: output,
1540        };
1541        let cost = pricing.estimate_cost_usd(usage);
1542
1543        sqlx::query(
1544            "UPDATE tasks SET
1545            input_tokens = COALESCE(input_tokens, 0) + ?,
1546            output_tokens = COALESCE(output_tokens, 0) + ?,
1547            input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1548            output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1549            total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1550            updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1551         WHERE id = ?",
1552        )
1553        .bind(i64::try_from(input).unwrap_or_else(|_| {
1554            tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1555            i64::MAX
1556        }))
1557        .bind(i64::try_from(output).unwrap_or_else(|_| {
1558            tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1559            i64::MAX
1560        }))
1561        .bind(cost.input_cost_usd)
1562        .bind(cost.output_cost_usd)
1563        .bind(cost.total_cost_usd)
1564        .bind(id)
1565        .execute(&self.pool)
1566        .await?;
1567
1568        Ok(())
1569    }
1570
1571    // ---------------------------------------------------------------
1572    // Cleanup
1573    // ---------------------------------------------------------------
1574
1575    /// Mark a task's worktree as cleaned.
1576    pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1577        sqlx::query(
1578        "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1579    )
1580    .bind(id)
1581    .execute(&self.pool)
1582    .await?;
1583        self.append_activity(
1584            id,
1585            "branch_delete",
1586            None,
1587            None,
1588            None,
1589            None,
1590            Some(&serde_json::json!({ "worktree_cleaned": true })),
1591        )
1592        .await?;
1593        Ok(())
1594    }
1595
1596    /// Batch-update fields for multiple tasks in a single transaction.
1597    ///
1598    /// Each entry is `(id, updates)` where `updates` is a slice of `(column, value)` pairs.
1599    /// Skips entries with empty update slices.
1600    /// Test-only helper / bulk operations used by admin paths and tests.
1601    #[cfg(test)]
1602    pub async fn batch_set_fields(
1603        &self,
1604        updates: &[(i64, &[(&str, serde_json::Value)])],
1605    ) -> anyhow::Result<()> {
1606        if updates.is_empty() {
1607            return Ok(());
1608        }
1609
1610        for (_, entry_updates) in updates {
1611            for (col, _) in *entry_updates {
1612                anyhow::ensure!(
1613                    ALLOWED_FIELDS.contains(col),
1614                    "column {col} is not in the update allowlist"
1615                );
1616            }
1617        }
1618
1619        let mut tx = self.pool.begin().await?;
1620        for (id, entry_updates) in updates {
1621            if entry_updates.is_empty() {
1622                continue;
1623            }
1624            let mut set_parts = Vec::new();
1625            let mut values: Vec<Option<String>> = Vec::new();
1626            for (col, val) in *entry_updates {
1627                set_parts.push(format!("{col} = ?"));
1628                values.push(sql_value_for_task_field(col, val)?);
1629            }
1630            set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1631            let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1632            let mut query = sqlx::query(&sql);
1633            for v in &values {
1634                query = query.bind(v.as_deref());
1635            }
1636            query = query.bind(*id);
1637            query.execute(&mut *tx).await?;
1638        }
1639        tx.commit().await?;
1640        Ok(())
1641    }
1642
1643    /// Batch-increment a field for multiple tasks in a single transaction.
1644    ///
1645    /// Each entry is `(id, field)`. All fields must be in the incrementable allowlist.
1646    /// Test-only helper for bulk increments used by tests.
1647    #[cfg(test)]
1648    pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1649        if entries.is_empty() {
1650            return Ok(());
1651        }
1652
1653        for (_, field) in entries {
1654            anyhow::ensure!(
1655                INCREMENTABLE_FIELDS.contains(field),
1656                "field {field} is not incrementable"
1657            );
1658        }
1659
1660        let mut tx = self.pool.begin().await?;
1661        for (id, field) in entries {
1662            let sql = format!(
1663                "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1664            );
1665            sqlx::query(&sql).bind(*id).execute(&mut *tx).await?;
1666        }
1667        tx.commit().await?;
1668        Ok(())
1669    }
1670
1671    /// Reset transient failure/retry counters for multiple tasks in a single transaction.
1672    ///
1673    /// Preserves `review_cycles`, `ci_merge_failures`, and
1674    /// `merge_conflict_retries` (same semantics as
1675    /// [`reset_failure_counters`]).
1676    /// Test-only helper for bulk counter resets used by tests.
1677    #[cfg(test)]
1678    pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1679        if ids.is_empty() {
1680            return Ok(());
1681        }
1682        // SQLite has a hard limit on host parameters (commonly 999). Chunk the
1683        // IN-list into smaller batches to avoid exceeding the limit.
1684        const CHUNK_SIZE: usize = 500;
1685
1686        let mut tx = self.pool.begin().await?;
1687
1688        for chunk in ids.chunks(CHUNK_SIZE) {
1689            // Build WHERE id IN (?, ?, ...) placeholders for this chunk
1690            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1691            let sql = format!(
1692                "UPDATE tasks SET
1693            route_attempts = 0,
1694            review_agent_failures = 0,
1695            review_invocations = 0,
1696            pr_create_failures = 0,
1697            push_failures = 0,
1698            network_retries = 0,
1699            review_session_expected = 0,
1700            auto_unblock_count = 0,
1701            auto_unblock_last_at = '',
1702            auto_unblock_last_reason = '',
1703            ci_recovery_count = 0,
1704            no_code_reroutes = 0,
1705            needs_review_refires = 0,
1706            updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1707         WHERE id IN ({placeholders})",
1708            );
1709            let mut query = sqlx::query(&sql);
1710            for id in chunk {
1711                query = query.bind(*id);
1712            }
1713            query.execute(&mut *tx).await?;
1714        }
1715
1716        tx.commit().await?;
1717        Ok(())
1718    }
1719
1720    /// Mark multiple tasks' worktrees as cleaned in a single transaction.
1721    ///
1722    /// Appends a `branch_delete` activity event for each task.
1723    /// Test-only helper for bulk cleaning used by tests.
1724    #[cfg(test)]
1725    pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1726        if ids.is_empty() {
1727            return Ok(());
1728        }
1729
1730        // Chunk updates to avoid exceeding SQLite's parameter limit.
1731        const CHUNK_SIZE: usize = 500;
1732
1733        let mut tx = self.pool.begin().await?;
1734
1735        for chunk in ids.chunks(CHUNK_SIZE) {
1736            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1737            let sql = format!(
1738                "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1739            );
1740            let mut query = sqlx::query(&sql);
1741            for id in chunk {
1742                query = query.bind(*id);
1743            }
1744            query.execute(&mut *tx).await?;
1745        }
1746
1747        // Append activity for each task (must be done individually — activity is per-task JSON)
1748        // Insert using the same transaction to avoid connection pool exhaustion
1749        // / deadlocks in tests.
1750        let details = serde_json::json!({ "worktree_cleaned": true });
1751        let details_json = serde_json::to_string(&details)?;
1752        for id in ids {
1753            sqlx::query(
1754                "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1755            )
1756            .bind(*id)
1757            .bind("branch_delete")
1758            .bind(None::<&str>)
1759            .bind(None::<&str>)
1760            .bind(None::<&str>)
1761            .bind(None::<&str>)
1762            .bind(details_json.as_str())
1763            .execute(&mut *tx)
1764            .await?;
1765        }
1766
1767        tx.commit().await?;
1768        Ok(())
1769    }
1770
1771    /// List tasks that are done/blocked with worktrees that haven't been cleaned.
1772    /// Test-only helper and used by cleanup tests.
1773    #[cfg(test)]
1774    pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1775        let rows = sqlx::query(&format!(
1776            "SELECT {TASK_COLS} FROM tasks
1777         WHERE repo = ?
1778           AND worktree != ''
1779           AND worktree_cleaned = 0
1780           AND status IN ('done', 'blocked')
1781         ORDER BY updated_at ASC"
1782        ))
1783        .bind(repo)
1784        .fetch_all(&self.pool)
1785        .await?;
1786
1787        rows.iter().map(Self::row_to_task).collect()
1788    }
1789
1790    /// Return external IDs of tasks whose worktrees have already been cleaned.
1791    ///
1792    /// Used by `cleanup_done_worktrees` to skip already-cleaned tasks without
1793    /// issuing one `resolve_task_id + get` per task (N+1 elimination).
1794    pub async fn cleaned_external_ids(
1795        &self,
1796        repo: &str,
1797    ) -> anyhow::Result<std::collections::HashSet<String>> {
1798        let rows: Vec<(String,)> =
1799            sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1800                .bind(repo)
1801                .fetch_all(&self.pool)
1802                .await?;
1803
1804        Ok(rows.into_iter().map(|(id,)| id).collect())
1805    }
1806
1807    // ---------------------------------------------------------------
1808    // Task Runs (audit trail)
1809    // ---------------------------------------------------------------
1810
1811    /// Start a new run, returning its ID.
1812    pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1813        // Self-heal: an earlier attempt of the same run_type may have been
1814        // abandoned by a crash or SIGKILL that never reached
1815        // complete_run/finalize_incomplete_runs. Close those out now so they
1816        // don't leak as permanently-open rows. Scoped to the same run_type so
1817        // a still-open run of a *different* run_type (e.g. an agent run left
1818        // open when a review run starts) is untouched here — that case is
1819        // handled by the explicit finalize_incomplete_runs calls in the
1820        // engine's recovery paths, not this opportunistic self-heal. The
1821        // current attempt is excluded so a legitimate retry of the same run
1822        // still goes through the ON CONFLICT upsert below instead of being
1823        // finalized out from under itself.
1824        let orphaned: Vec<(i64,)> = sqlx::query_as(
1825            "SELECT id FROM task_runs
1826             WHERE task_id = ? AND run_type = ? AND attempt != ?
1827               AND outcome IS NULL AND completed_at IS NULL",
1828        )
1829        .bind(run.task_id)
1830        .bind(run.run_type)
1831        .bind(run.attempt)
1832        .fetch_all(&self.pool)
1833        .await?;
1834
1835        for (run_id,) in orphaned {
1836            self.complete_run(&CompleteRun {
1837                run_id,
1838                exit_code: Some(-1),
1839                stdout: "",
1840                stderr: "",
1841                parsed: "",
1842                outcome: "aborted",
1843                error: "superseded by a new run start before this one completed",
1844                tokens: RunTokenUsage::default(),
1845            })
1846            .await?;
1847        }
1848
1849        let row = sqlx::query(
1850            "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
1851         VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
1852         ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
1853            agent = excluded.agent, model = excluded.model,
1854            command = excluded.command, prompt = excluded.prompt,
1855            outcome = NULL,
1856            started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
1857            completed_at = NULL, error = NULL, exit_code = NULL,
1858            stdout = NULL, stderr = NULL, parsed_response = NULL,
1859            duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
1860            total_cost_usd = NULL
1861         RETURNING id",
1862        )
1863        .bind(run.task_id)
1864        .bind(run.attempt)
1865        .bind(run.run_type)
1866        .bind(run.agent)
1867        .bind(run.model)
1868        .bind(run.command)
1869        .bind(run.prompt)
1870        .fetch_one(&self.pool)
1871        .await?;
1872
1873        let run_id: i64 = row
1874            .try_get("id")
1875            .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
1876        let event_type = match run.run_type {
1877            "review" => "review_start",
1878            _ => "dispatch",
1879        };
1880        let details = serde_json::json!({
1881            "run_type": run.run_type,
1882            "attempt": run.attempt,
1883            "command": run.command,
1884        });
1885        self.append_activity(
1886            run.task_id,
1887            event_type,
1888            None,
1889            None,
1890            Some(run.agent),
1891            Some(run.model),
1892            Some(&details),
1893        )
1894        .await?;
1895
1896        Ok(run_id)
1897    }
1898
1899    /// Complete a run with results.
1900    pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
1901        let run_ctx =
1902            sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
1903                .bind(run.run_id)
1904                .fetch_optional(&self.pool)
1905                .await?;
1906        sqlx::query(
1907            "UPDATE task_runs SET
1908            exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
1909            outcome = ?, error = NULLIF(?, ''),
1910            input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
1911            duration_secs = ?,
1912            completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1913         WHERE id = ?",
1914        )
1915        .bind(run.exit_code)
1916        .bind(run.stdout)
1917        .bind(security::redact(run.stderr))
1918        .bind(run.parsed)
1919        .bind(run.outcome)
1920        .bind(security::redact(run.error))
1921        .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1922            tracing::warn!(
1923                run.tokens.input_tokens,
1924                "input_tokens overflows i64, clamping to MAX"
1925            );
1926            i64::MAX
1927        }))
1928        .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1929            tracing::warn!(
1930                run.tokens.output_tokens,
1931                "output_tokens overflows i64, clamping to MAX"
1932            );
1933            i64::MAX
1934        }))
1935        .bind(run.tokens.total_cost_usd)
1936        .bind(run.tokens.duration_secs)
1937        .bind(run.run_id)
1938        .execute(&self.pool)
1939        .await?;
1940        if let Some(run_ctx) = run_ctx {
1941            let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1942                anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1943            })?;
1944            let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1945                anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1946            })?;
1947            let agent: String = run_ctx.try_get("agent").map_err(|e| {
1948                anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1949            })?;
1950            let model: String = run_ctx.try_get("model").map_err(|e| {
1951                anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1952            })?;
1953            if run.outcome == "timeout" {
1954                self.append_activity(
1955                    task_id,
1956                    "timeout",
1957                    None,
1958                    None,
1959                    Some(&agent),
1960                    Some(&model),
1961                    Some(&serde_json::json!({
1962                        "run_type": run_type,
1963                        "error": run.error,
1964                        "stderr": run.stderr,
1965                    })),
1966                )
1967                .await?;
1968            } else if run.outcome != "success" {
1969                self.append_activity(
1970                    task_id,
1971                    "error",
1972                    None,
1973                    None,
1974                    Some(&agent),
1975                    Some(&model),
1976                    Some(&serde_json::json!({
1977                        "run_type": run_type,
1978                        "outcome": run.outcome,
1979                        "error": run.error,
1980                    })),
1981                )
1982                .await?;
1983            }
1984        } else if run.outcome != "success" {
1985            tracing::warn!(
1986                run_id = run.run_id,
1987                outcome = run.outcome,
1988                "run_id not found in task_runs; skipping timeout/error activity log"
1989            );
1990        }
1991        Ok(())
1992    }
1993
1994    /// Finalize all incomplete runs for a task.
1995    ///
1996    /// This is used on runner early-return/error paths and engine shutdown
1997    /// paths to ensure every `start_run` has a terminal record.
1998    pub async fn finalize_incomplete_runs(
1999        &self,
2000        task_id: i64,
2001        outcome: &str,
2002        error: &str,
2003    ) -> anyhow::Result<u64> {
2004        let rows: Vec<(i64,)> = sqlx::query_as(
2005            "SELECT id FROM task_runs
2006             WHERE task_id = ?
2007               AND outcome IS NULL
2008               AND completed_at IS NULL
2009             ORDER BY id ASC",
2010        )
2011        .bind(task_id)
2012        .fetch_all(&self.pool)
2013        .await?;
2014
2015        for (run_id,) in &rows {
2016            self.complete_run(&CompleteRun {
2017                run_id: *run_id,
2018                exit_code: Some(-1),
2019                stdout: "",
2020                stderr: "",
2021                parsed: "",
2022                outcome,
2023                error,
2024                tokens: RunTokenUsage::default(),
2025            })
2026            .await?;
2027        }
2028
2029        Ok(rows.len() as u64)
2030    }
2031
2032    /// Get all runs for a task, ordered by attempt.
2033    pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
2034        let rows = sqlx::query(&format!(
2035            "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
2036        ))
2037        .bind(task_id)
2038        .fetch_all(&self.pool)
2039        .await?;
2040
2041        rows.iter().map(Self::row_to_run).collect()
2042    }
2043
2044    /// Get all runs for a batch of task IDs in a single query, grouped by task ID.
2045    /// Returns an empty map if `task_ids` is empty.
2046    pub async fn get_runs_batch(
2047        &self,
2048        task_ids: &[i64],
2049    ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
2050        if task_ids.is_empty() {
2051            return Ok(std::collections::HashMap::new());
2052        }
2053        // SQLite has a hard limit on host parameters (commonly 999). Chunk the
2054        // IN-list into smaller batches to avoid exceeding the limit.
2055        const CHUNK_SIZE: usize = 500;
2056
2057        let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
2058            std::collections::HashMap::new();
2059
2060        for chunk in task_ids.chunks(CHUNK_SIZE) {
2061            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2062            let sql = format!(
2063                "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
2064            );
2065            let mut query = sqlx::query(&sql);
2066            for id in chunk {
2067                query = query.bind(*id);
2068            }
2069            let rows = query.fetch_all(&self.pool).await?;
2070            for row in &rows {
2071                let run = Self::row_to_run(row)?;
2072                result.entry(run.task_id).or_default().push(run);
2073            }
2074        }
2075        Ok(result)
2076    }
2077
2078    /// Get multiple tasks by ID in a single query, returning a map from task ID to Task.
2079    /// Returns an empty map if `ids` is empty.
2080    pub async fn get_batch(
2081        &self,
2082        ids: &[i64],
2083    ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2084        if ids.is_empty() {
2085            return Ok(std::collections::HashMap::new());
2086        }
2087        // SQLite has a hard limit on host parameters (commonly 999). Chunk the
2088        // IN-list into smaller batches to avoid exceeding the limit.
2089        const CHUNK_SIZE: usize = 500;
2090
2091        let mut result = std::collections::HashMap::new();
2092
2093        for chunk in ids.chunks(CHUNK_SIZE) {
2094            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2095            let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2096            let mut query = sqlx::query(&sql);
2097            for id in chunk {
2098                query = query.bind(*id);
2099            }
2100            let rows = query.fetch_all(&self.pool).await?;
2101            for row in &rows {
2102                let task = Self::row_to_task(row)?;
2103                result.insert(task.id, task);
2104            }
2105        }
2106        Ok(result)
2107    }
2108
2109    /// Get statuses for a batch of external IDs in a single query.
2110    /// Returns a map from external_id -> TaskStatus for rows found in the DB.
2111    /// Missing external_ids are simply not present in the returned map.
2112    /// Uses chunking to avoid SQLite parameter limits.
2113    pub async fn get_statuses_by_external_ids(
2114        &self,
2115        repo: &str,
2116        ext_ids: &[&str],
2117    ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2118        let mut result = std::collections::HashMap::new();
2119        if ext_ids.is_empty() {
2120            return Ok(result);
2121        }
2122
2123        const CHUNK_SIZE: usize = 500;
2124        for chunk in ext_ids.chunks(CHUNK_SIZE) {
2125            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2126            let sql = format!(
2127                "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2128            );
2129            let mut query = sqlx::query(&sql);
2130            query = query.bind(repo);
2131            for id in chunk {
2132                query = query.bind(id);
2133            }
2134            let rows = query.fetch_all(&self.pool).await?;
2135            for row in &rows {
2136                let external_id: Result<String, _> = row.try_get("external_id");
2137                let status_str: Result<String, _> = row.try_get("status");
2138                match (external_id, status_str) {
2139                    (Ok(ext_id), Ok(sts)) => {
2140                        if ext_id.is_empty() {
2141                            tracing::warn!(status = %sts, "skipping task row with empty external_id");
2142                            continue;
2143                        }
2144                        if let Some(status) = TaskStatus::from_str(&sts) {
2145                            result.insert(ext_id, status);
2146                        } else {
2147                            tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2148                        }
2149                    }
2150                    (Err(e), _) => {
2151                        tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2152                    }
2153                    (_, Err(e)) => {
2154                        tracing::warn!(error = %e, "skipping task row with decode error for status");
2155                    }
2156                }
2157            }
2158        }
2159
2160        Ok(result)
2161    }
2162
2163    /// Get the last run of a specific type for a task.
2164    /// Used by tests to assert run ordering.
2165    #[cfg(test)]
2166    pub async fn get_last_run(
2167        &self,
2168        task_id: i64,
2169        run_type: &str,
2170    ) -> anyhow::Result<Option<TaskRun>> {
2171        let row = sqlx::query(&format!(
2172        "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2173    ))
2174    .bind(task_id)
2175    .bind(run_type)
2176    .fetch_optional(&self.pool)
2177    .await?;
2178
2179        match row {
2180            Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2181            None => Ok(None),
2182        }
2183    }
2184
2185    /// List distinct agents that have previously reviewed this task.
2186    /// Used to exclude them when picking the next review agent, so a different
2187    /// agent reviews each cycle (avoids the same agent hitting the same issues).
2188    pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2189        let rows: Vec<(String,)> = sqlx::query_as(
2190            "SELECT DISTINCT agent FROM task_runs \
2191             WHERE task_id = ? AND run_type = 'review' \
2192             ORDER BY agent",
2193        )
2194        .bind(task_id)
2195        .fetch_all(&self.pool)
2196        .await?;
2197        Ok(rows.into_iter().map(|(a,)| a).collect())
2198    }
2199
2200    /// Prune old runs for done/blocked tasks older than `days` days.
2201    /// Test-only maintenance helper.
2202    #[cfg(test)]
2203    pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2204        let result = sqlx::query(
2205            "DELETE FROM task_runs WHERE task_id IN (
2206            SELECT id FROM tasks
2207            WHERE status IN ('done', 'blocked')
2208              AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2209         )",
2210        )
2211        .bind(format!("-{days}"))
2212        .execute(&self.pool)
2213        .await?;
2214
2215        Ok(result.rows_affected())
2216    }
2217
2218    // ---------------------------------------------------------------
2219    // Row mapping helpers
2220    // ---------------------------------------------------------------
2221
2222    fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2223        // Diagnostic: verify column count matches expected to prevent OOB panics
2224        // when schema changes without updating TASK_COLS
2225        let col_count = row.len();
2226        if col_count != TASK_COLS_COUNT {
2227            tracing::warn!(
2228                expected = TASK_COLS_COUNT,
2229                actual = col_count,
2230                "TASK_COLS column count mismatch - schema may have changed"
2231            );
2232        }
2233
2234        let status_str: String = row
2235            .try_get("status")
2236            .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2237        let labels_str: String = row.try_get("labels").unwrap_or_default();
2238        let memory_str: String = row.try_get("memory").unwrap_or_default();
2239        let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2240
2241        Ok(Task {
2242            id: row
2243                .try_get("id")
2244                .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2245            external_id: row.try_get("external_id").unwrap_or(None),
2246            repo: row
2247                .try_get("repo")
2248                .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2249            origin: row
2250                .try_get("origin")
2251                .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2252            title: row
2253                .try_get("title")
2254                .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2255            body: row
2256                .try_get("body")
2257                .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2258            status: TaskStatus::from_str(&status_str)
2259                .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2260            source: row
2261                .try_get("source")
2262                .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2263            source_id: row
2264                .try_get("source_id")
2265                .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2266            author: row
2267                .try_get("author")
2268                .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2269            url: row
2270                .try_get("url")
2271                .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2272            labels: serde_json::from_str(&labels_str)
2273                .inspect_err(
2274                    |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2275                )
2276                .unwrap_or_default(),
2277            agent: row.try_get("agent").unwrap_or(None),
2278            model: row.try_get("model").unwrap_or(None),
2279            complexity: row.try_get("complexity").unwrap_or_default(),
2280            estimate: {
2281                let raw: i32 = row.try_get("estimate").unwrap_or(0);
2282                u8::try_from(raw).unwrap_or_else(|_| {
2283                    tracing::warn!(
2284                        raw_estimate = raw,
2285                        "estimate value out of u8 range; defaulting to 0"
2286                    );
2287                    0
2288                })
2289            },
2290            route_reason: row.try_get("route_reason").unwrap_or_default(),
2291            agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2292            selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2293            route_attempts: row.try_get("route_attempts").unwrap_or(0),
2294            attempts: row.try_get("attempts").unwrap_or(0),
2295            branch: row.try_get("branch").unwrap_or_default(),
2296            worktree: row.try_get("worktree").unwrap_or_default(),
2297            worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2298            summary: row.try_get("summary").unwrap_or_default(),
2299            last_error: row.try_get("last_error").unwrap_or_default(),
2300            parent_id: row.try_get("parent_id").unwrap_or(None),
2301            block_reason: row.try_get("block_reason").unwrap_or(None),
2302            pr_number: row.try_get("pr_number").unwrap_or(None),
2303            pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2304            last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2305            review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2306            last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2307            merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2308            ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2309            pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2310            push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2311            network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2312            review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2313            review_cycles: row.try_get("review_cycles").unwrap_or(0),
2314            review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2315            review_session_expected: row
2316                .try_get::<i32, _>("review_session_expected")
2317                .unwrap_or(0)
2318                != 0,
2319            needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2320            input_tokens: row.try_get("input_tokens").unwrap_or(0),
2321            output_tokens: row.try_get("output_tokens").unwrap_or(0),
2322            input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2323            output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2324            total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2325            model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2326            limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2327            memory: serde_json::from_str(&memory_str)
2328                .inspect_err(
2329                    |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2330                )
2331                .unwrap_or_default(),
2332            delegations: serde_json::from_str(&delegations_str)
2333                .inspect_err(
2334                    |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2335                )
2336                .unwrap_or_default(),
2337            // Some deployments may be running databases that haven't had
2338            // the most recent migrations applied. Use `try_get` with
2339            // sensible defaults for recently-added columns so a missing
2340            // column doesn't cause a panic in the sqlx row accessor.
2341            auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2342            auto_unblock_last_at: row
2343                .try_get::<String, _>("auto_unblock_last_at")
2344                .unwrap_or_default(),
2345            auto_unblock_last_reason: row
2346                .try_get::<String, _>("auto_unblock_last_reason")
2347                .unwrap_or_default(),
2348            ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2349            no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2350            no_code_last_agent: row
2351                .try_get::<String, _>("no_code_last_agent")
2352                .unwrap_or_default(),
2353            created_at: row
2354                .try_get::<String, _>("created_at")
2355                .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2356            updated_at: row
2357                .try_get::<String, _>("updated_at")
2358                .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2359        })
2360    }
2361    pub async fn ensure_external_task(
2362        &self,
2363        repo: &str,
2364        ext: &crate::backends::ExternalTask,
2365    ) -> anyhow::Result<i64> {
2366        self.upsert_external(&UpsertExternal {
2367            repo,
2368            ext_id: &ext.id.0,
2369            title: &ext.title,
2370            body: &ext.body,
2371            author: &ext.author,
2372            url: &ext.url,
2373            labels: &ext.labels,
2374            origin: "github",
2375        })
2376        .await
2377    }
2378
2379    /// Resolve a task_id (e.g. "42" or "internal:3") to a store internal ID.
2380    /// Returns None if the task is not in the store yet.
2381    pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2382        if let Some(suffix) = task_id.strip_prefix("internal:") {
2383            // Internal tasks: look up by external_id="internal:{n}" with origin='internal'
2384            if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2385                return Ok(Some(id));
2386            }
2387            // Fallback: try the numeric suffix as a direct store ID.
2388            // Must include the repo filter to avoid resolving a task that
2389            // belongs to a different repository in a multi-repo setup.
2390            if let Ok(id) = suffix.parse::<i64>() {
2391                let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2392                    .bind(id)
2393                    .bind(repo)
2394                    .fetch_optional(&self.pool)
2395                    .await?;
2396                if exists.is_some() {
2397                    return Ok(Some(id));
2398                }
2399            }
2400            return Ok(None);
2401        }
2402        // External tasks: look up by external_id using the lightweight id-only
2403        // lookup to avoid deserializing the full Task row.
2404        self.resolve_id_by_external(repo, task_id).await
2405    }
2406
2407    /// Resolve a task_id globally (across all repos) when a repo-scoped lookup fails.
2408    ///
2409    /// Returns `(internal_id, repo)` if found unambiguously, `None` if not found,
2410    /// or an error if the external_id matches tasks in multiple repos (GitHub issue
2411    /// numbers are not globally unique).
2412    pub async fn resolve_task_id_globally(
2413        &self,
2414        task_id: &str,
2415    ) -> anyhow::Result<Option<(i64, String)>> {
2416        if let Some(suffix) = task_id.strip_prefix("internal:") {
2417            // internal:N — external_id is globally unique; primary key is also unique.
2418            let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2419                .bind(task_id)
2420                .fetch_optional(&self.pool)
2421                .await?;
2422            if let Some(r) = row {
2423                return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2424            }
2425            if let Ok(id) = suffix.parse::<i64>() {
2426                let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2427                    .bind(id)
2428                    .fetch_optional(&self.pool)
2429                    .await?;
2430                if let Some(r) = row {
2431                    return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2432                }
2433            }
2434            return Ok(None);
2435        }
2436        // External IDs (e.g. GitHub issue numbers) may collide across repos.
2437        let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2438            .bind(task_id)
2439            .fetch_all(&self.pool)
2440            .await?;
2441        match rows.len() {
2442            0 => Ok(None),
2443            1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2444            _ => {
2445                let repos: Vec<String> = rows
2446                    .iter()
2447                    .filter_map(|r| r.try_get::<String, _>("repo").ok())
2448                    .collect();
2449                anyhow::bail!(
2450                    "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2451                     Run from the repo worktree so the current-repo lookup resolves it.",
2452                    repos.join(", ")
2453                )
2454            }
2455        }
2456    }
2457
2458    fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2459        Ok(TaskRun {
2460            id: row
2461                .try_get("id")
2462                .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2463            task_id: row
2464                .try_get("task_id")
2465                .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2466            attempt: row
2467                .try_get("attempt")
2468                .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2469            run_type: row
2470                .try_get("run_type")
2471                .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2472            agent: row
2473                .try_get("agent")
2474                .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2475            model: row
2476                .try_get("model")
2477                .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2478            command: row
2479                .try_get("command")
2480                .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2481            prompt: row
2482                .try_get("prompt")
2483                .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2484            env_vars: row
2485                .try_get("env_vars")
2486                .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2487            exit_code: row
2488                .try_get("exit_code")
2489                .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2490            stdout: row
2491                .try_get("stdout")
2492                .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2493            stderr: row
2494                .try_get("stderr")
2495                .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2496            parsed_response: row
2497                .try_get("parsed_response")
2498                .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2499            outcome: row
2500                .try_get("outcome")
2501                .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2502            error: row
2503                .try_get("error")
2504                .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2505            input_tokens: row
2506                .try_get("input_tokens")
2507                .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2508            output_tokens: row
2509                .try_get("output_tokens")
2510                .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2511            total_cost_usd: row
2512                .try_get("total_cost_usd")
2513                .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2514            duration_secs: row
2515                .try_get("duration_secs")
2516                .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2517            started_at: row
2518                .try_get("started_at")
2519                .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2520            completed_at: row.try_get("completed_at").unwrap_or(None),
2521        })
2522    }
2523
2524    fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2525        let details_str: String = row
2526            .try_get("details")
2527            .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2528        let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2529            |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2530        )?;
2531        Ok(TaskActivity {
2532            id: row
2533                .try_get("id")
2534                .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2535            task_id: row
2536                .try_get("task_id")
2537                .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2538            timestamp: row
2539                .try_get("timestamp")
2540                .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2541            event_type: row
2542                .try_get("event_type")
2543                .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2544            from_status: row
2545                .try_get("from_status")
2546                .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2547            to_status: row
2548                .try_get("to_status")
2549                .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2550            agent: row
2551                .try_get("agent")
2552                .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2553            model: row
2554                .try_get("model")
2555                .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2556            details,
2557        })
2558    }
2559}
2560
2561#[cfg(test)]
2562mod row_to_task_tests {
2563    use super::*;
2564
2565    async fn store_with_task() -> (crate::store::TaskStore, i64) {
2566        let store = crate::store::TaskStore::open_memory().await.unwrap();
2567        let id = store
2568            .create(&NewTask {
2569                external_id: None,
2570                repo: "owner/repo".to_string(),
2571                origin: "internal".to_string(),
2572                title: "Test task".to_string(),
2573                body: "Test body".to_string(),
2574                source: "cron".to_string(),
2575                source_id: "test-1".to_string(),
2576                author: "tester".to_string(),
2577                url: "https://example.com/1".to_string(),
2578                labels: vec![],
2579                parent_id: None,
2580            })
2581            .await
2582            .unwrap();
2583        (store, id)
2584    }
2585
2586    // Each test selects all previously-required columns so that exactly the
2587    // column under test is absent and triggers the Err path.
2588    macro_rules! missing_column_test {
2589        ($name:ident, $col:literal, $select:literal) => {
2590            #[tokio::test]
2591            async fn $name() {
2592                let (store, id) = store_with_task().await;
2593                let sql = format!("{} WHERE id = {id}", $select);
2594                let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2595                let err = TaskStore::row_to_task(&row).unwrap_err();
2596                assert!(
2597                    err.to_string().contains($col),
2598                    "expected '{}' in error message, got: {err}",
2599                    $col
2600                );
2601            }
2602        };
2603    }
2604
2605    missing_column_test!(
2606        row_to_task_fails_on_missing_repo,
2607        "repo",
2608        "SELECT id, status FROM tasks"
2609    );
2610    missing_column_test!(
2611        row_to_task_fails_on_missing_origin,
2612        "origin",
2613        "SELECT id, repo, status FROM tasks"
2614    );
2615    missing_column_test!(
2616        row_to_task_fails_on_missing_title,
2617        "title",
2618        "SELECT id, repo, origin, status FROM tasks"
2619    );
2620    missing_column_test!(
2621        row_to_task_fails_on_missing_body,
2622        "body",
2623        "SELECT id, repo, origin, title, status FROM tasks"
2624    );
2625    missing_column_test!(
2626        row_to_task_fails_on_missing_source,
2627        "source",
2628        "SELECT id, repo, origin, title, body, status FROM tasks"
2629    );
2630    missing_column_test!(
2631        row_to_task_fails_on_missing_source_id,
2632        "source_id",
2633        "SELECT id, repo, origin, title, body, source, status FROM tasks"
2634    );
2635    missing_column_test!(
2636        row_to_task_fails_on_missing_author,
2637        "author",
2638        "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2639    );
2640    missing_column_test!(
2641        row_to_task_fails_on_missing_url,
2642        "url",
2643        "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2644    );
2645
2646    // Decode-error test: inject a BLOB with invalid UTF-8 bytes for the `repo`
2647    // column. SQLite's type coercion converts NULL and integers to strings, so
2648    // they don't produce a decode error. A raw BLOB that is not valid UTF-8
2649    // (X'DEADBEEF') does cause `try_get::<String>` to fail with a ColumnDecode
2650    // error, exercising the decode-error-propagation path that is distinct from
2651    // the missing-column (ColumnNotFound) path tested above.
2652    #[tokio::test]
2653    async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2654        let (store, id) = store_with_task().await;
2655        // X'DEADBEEF' is a 4-byte BLOB; bytes 0xDE 0xAD 0xBE 0xEF are not
2656        // valid UTF-8, so try_get::<String>("repo") will fail with a decode error.
2657        let sql = format!(
2658            "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2659             source, source_id, author, url FROM tasks WHERE id = {id}"
2660        );
2661        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2662        let err = TaskStore::row_to_task(&row).unwrap_err();
2663        assert!(
2664            err.to_string().contains("repo"),
2665            "expected 'repo' in error message, got: {err}"
2666        );
2667    }
2668
2669    // ── distinct_repos decode-error tests ─────────────────────────────────
2670
2671    #[tokio::test]
2672    async fn distinct_repos_fails_on_decode_error() {
2673        let store = crate::store::TaskStore::open_memory().await.unwrap();
2674        let id = store
2675            .create(&NewTask {
2676                external_id: None,
2677                repo: "owner/repo".to_string(),
2678                origin: "internal".to_string(),
2679                title: "Test task".to_string(),
2680                body: "Test body".to_string(),
2681                source: "cron".to_string(),
2682                source_id: "test-1".to_string(),
2683                author: "tester".to_string(),
2684                url: "https://example.com/1".to_string(),
2685                labels: vec![],
2686                parent_id: None,
2687            })
2688            .await
2689            .unwrap();
2690
2691        // Inject an invalid UTF-8 BLOB as repo — try_get::<String> will fail.
2692        let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2693        sqlx::query(&sql).execute(store.pool()).await.unwrap();
2694
2695        let err = store.distinct_repos().await.unwrap_err();
2696        assert!(
2697            err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2698            "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2699        );
2700    }
2701
2702    // ── row_to_run decode-error tests ────────────────────────────────────────
2703
2704    async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
2705        let store = crate::store::TaskStore::open_memory().await.unwrap();
2706        let task_id = store
2707            .create(&NewTask {
2708                external_id: None,
2709                repo: "owner/repo".to_string(),
2710                origin: "internal".to_string(),
2711                title: "Test task".to_string(),
2712                body: "Test body".to_string(),
2713                source: "cron".to_string(),
2714                source_id: "test-1".to_string(),
2715                author: "tester".to_string(),
2716                url: "https://example.com/1".to_string(),
2717                labels: vec![],
2718                parent_id: None,
2719            })
2720            .await
2721            .unwrap();
2722        let run_id = store
2723            .start_run(&crate::store::StartRun {
2724                task_id,
2725                attempt: 1,
2726                run_type: "agent",
2727                agent: "claude",
2728                model: "sonnet",
2729                command: "claude -p ...",
2730                prompt: "system prompt",
2731            })
2732            .await
2733            .unwrap();
2734        (store, task_id, run_id)
2735    }
2736
2737    #[tokio::test]
2738    async fn start_run_upsert_clears_stale_completion_fields() {
2739        let (store, task_id, run_id) = store_with_run().await;
2740        store
2741            .complete_run(&CompleteRun {
2742                run_id,
2743                exit_code: Some(0),
2744                stdout: "",
2745                stderr: "",
2746                parsed: "",
2747                outcome: "success",
2748                error: "boom",
2749                tokens: RunTokenUsage::default(),
2750            })
2751            .await
2752            .unwrap();
2753
2754        // Re-dispatch the same (task_id, attempt, run_type) key.
2755        let same_run_id = store
2756            .start_run(&crate::store::StartRun {
2757                task_id,
2758                attempt: 1,
2759                run_type: "agent",
2760                agent: "claude",
2761                model: "sonnet",
2762                command: "claude -p ...",
2763                prompt: "system prompt",
2764            })
2765            .await
2766            .unwrap();
2767        assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
2768
2769        let row = sqlx::query(
2770            "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
2771        )
2772        .bind(run_id)
2773        .fetch_one(store.pool())
2774        .await
2775        .unwrap();
2776        let outcome: Option<String> = row.try_get("outcome").unwrap();
2777        let completed_at: Option<String> = row.try_get("completed_at").unwrap();
2778        let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
2779        let error: Option<String> = row.try_get("error").unwrap();
2780        assert_eq!(outcome, None);
2781        assert_eq!(
2782            completed_at, None,
2783            "stale completion timestamp must be cleared"
2784        );
2785        assert_eq!(exit_code, None);
2786        assert_eq!(error, None);
2787    }
2788
2789    // NOTE: Unlike the task table (where all required columns are TEXT and NULL
2790    // propagates a ColumnNotFound error), the task_runs table has INTEGER and REAL
2791    // columns (id, task_id, attempt, started_at). SQLite coerces NULL/INTEGER to
2792    // String when try_get::<String> is called, so we cannot use the missing-column
2793    // pattern here. Instead, we use BLOB injection to trigger actual decode errors
2794    // that propagate through map_err.
2795    macro_rules! run_blob_decode_test {
2796        ($name:ident, $col:literal, $select:literal) => {
2797            #[tokio::test]
2798            async fn $name() {
2799                let (store, _task_id, run_id) = store_with_run().await;
2800                let sql = format!($select, run_id = run_id);
2801                let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2802                let err = TaskStore::row_to_run(&row).unwrap_err();
2803                assert!(
2804                    err.to_string().contains($col),
2805                    "expected '{0}' in error message, got: {err}",
2806                    $col
2807                );
2808            }
2809        };
2810    }
2811
2812    run_blob_decode_test!(
2813        row_to_run_fails_on_decode_error_invalid_utf8_id,
2814        "id",
2815        "SELECT X'DEADBEEF' as id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2816    );
2817    run_blob_decode_test!(
2818        row_to_run_fails_on_decode_error_invalid_utf8_task_id,
2819        "task_id",
2820        "SELECT id, X'DEADBEEF' as task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2821    );
2822    run_blob_decode_test!(
2823        row_to_run_fails_on_decode_error_invalid_utf8_started_at,
2824        "started_at",
2825        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, X'DEADBEEF' as started_at, completed_at FROM task_runs WHERE id = {run_id}"
2826    );
2827
2828    // ── row_to_activity decode-error tests ───────────────────────────────────
2829
2830    async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
2831        let store = crate::store::TaskStore::open_memory().await.unwrap();
2832        let task_id = store
2833            .create(&NewTask {
2834                external_id: None,
2835                repo: "owner/repo".to_string(),
2836                origin: "internal".to_string(),
2837                title: "Test task".to_string(),
2838                body: "Test body".to_string(),
2839                source: "cron".to_string(),
2840                source_id: "test-1".to_string(),
2841                author: "tester".to_string(),
2842                url: "https://example.com/1".to_string(),
2843                labels: vec![],
2844                parent_id: None,
2845            })
2846            .await
2847            .unwrap();
2848        store
2849            .append_activity(task_id, "created", None, Some("new"), None, None, None)
2850            .await
2851            .unwrap();
2852        (store, task_id, 1)
2853    }
2854
2855    // Each test drops exactly one column from the full TASK_ACTIVITY_COLS so that
2856    // the first missing-column try_get (id) propagates the error.
2857    #[tokio::test]
2858    async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
2859        let (store, _task_id, activity_id) = store_with_activity().await;
2860        let sql = format!(
2861            "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2862        );
2863        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2864        let err = TaskStore::row_to_activity(&row).unwrap_err();
2865        assert!(
2866            err.to_string().contains("id"),
2867            "expected 'id' in error message, got: {err}"
2868        );
2869    }
2870
2871    #[tokio::test]
2872    async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
2873        let (store, _task_id, activity_id) = store_with_activity().await;
2874        let sql = format!(
2875            "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2876        );
2877        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2878        let err = TaskStore::row_to_activity(&row).unwrap_err();
2879        assert!(
2880            err.to_string().contains("task_id"),
2881            "expected 'task_id' in error message, got: {err}"
2882        );
2883    }
2884
2885    #[tokio::test]
2886    async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
2887        let (store, _task_id, activity_id) = store_with_activity().await;
2888        let sql = format!(
2889            "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2890        );
2891        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2892        let err = TaskStore::row_to_activity(&row).unwrap_err();
2893        assert!(
2894            err.to_string().contains("timestamp"),
2895            "expected 'timestamp' in error message, got: {err}"
2896        );
2897    }
2898
2899    #[tokio::test]
2900    async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
2901        let (store, _task_id, activity_id) = store_with_activity().await;
2902        let sql = format!(
2903            "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2904        );
2905        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2906        let err = TaskStore::row_to_activity(&row).unwrap_err();
2907        assert!(
2908            err.to_string().contains("event_type"),
2909            "expected 'event_type' in error message, got: {err}"
2910        );
2911    }
2912
2913    #[tokio::test]
2914    async fn row_to_activity_fails_on_corrupt_details_json() {
2915        let (store, task_id, _activity_id) = store_with_activity().await;
2916        // Insert a row with invalid JSON in details
2917        sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
2918            .bind(task_id)
2919            .bind("test")
2920            .bind("not valid json {{{")
2921            .execute(store.pool())
2922            .await
2923            .unwrap();
2924        let row = sqlx::query(&format!(
2925            "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
2926        ))
2927        .fetch_one(store.pool())
2928        .await
2929        .unwrap();
2930        let err = TaskStore::row_to_activity(&row).unwrap_err();
2931        assert!(
2932            err.to_string().contains("details")
2933                || err.to_string().contains("json")
2934                || err.to_string().contains("expected"),
2935            "expected 'details', 'json', or 'expected' in error message, got: {err}"
2936        );
2937    }
2938
2939    // ── row_to_run: remaining fields with map_err ──────────────────────────────
2940
2941    run_blob_decode_test!(
2942        row_to_run_fails_on_decode_error_invalid_utf8_model,
2943        "model",
2944        "SELECT id, task_id, attempt, run_type, agent, X'DeadbEEF' as model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2945    );
2946    run_blob_decode_test!(
2947        row_to_run_fails_on_decode_error_invalid_utf8_command,
2948        "command",
2949        "SELECT id, task_id, attempt, run_type, agent, model, X'DeadbEEF' as command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2950    );
2951    run_blob_decode_test!(
2952        row_to_run_fails_on_decode_error_invalid_utf8_prompt,
2953        "prompt",
2954        "SELECT id, task_id, attempt, run_type, agent, model, command, X'DeadbEEF' as prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2955    );
2956    run_blob_decode_test!(
2957        row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
2958        "env_vars",
2959        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, X'DeadbEEF' as env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2960    );
2961    run_blob_decode_test!(
2962        row_to_run_fails_on_decode_error_invalid_utf8_stdout,
2963        "stdout",
2964        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, X'DeadbEEF' as stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2965    );
2966    run_blob_decode_test!(
2967        row_to_run_fails_on_decode_error_invalid_utf8_stderr,
2968        "stderr",
2969        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, X'DeadbEEF' as stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2970    );
2971    run_blob_decode_test!(
2972        row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
2973        "parsed_response",
2974        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, X'DeadbEEF' as parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2975    );
2976    run_blob_decode_test!(
2977        row_to_run_fails_on_decode_error_invalid_utf8_outcome,
2978        "outcome",
2979        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, X'DeadbEEF' as outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2980    );
2981    run_blob_decode_test!(
2982        row_to_run_fails_on_decode_error_invalid_utf8_error,
2983        "error",
2984        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, X'DeadbEEF' as error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2985    );
2986    run_blob_decode_test!(
2987        row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
2988        "input_tokens",
2989        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, X'DeadbEEF' as input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2990    );
2991    run_blob_decode_test!(
2992        row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
2993        "output_tokens",
2994        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, X'DeadbEEF' as output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2995    );
2996    run_blob_decode_test!(
2997        row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
2998        "total_cost_usd",
2999        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, X'DeadbEEF' as total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
3000    );
3001    run_blob_decode_test!(
3002        row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3003        "duration_secs",
3004        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, X'DeadbEEF' as duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
3005    );
3006    run_blob_decode_test!(
3007        row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3008        "exit_code",
3009        "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, X'DeadbEEF' as exit_code, completed_at FROM task_runs WHERE id = {run_id}"
3010    );
3011
3012    // ── row_to_activity: nullable columns with map_err ────────────────────────
3013
3014    // from_status, to_status, agent, model are nullable (Option<String>) but their
3015    // decode errors should still propagate via map_err, not be masked as None.
3016    // Each test injects BLOB into exactly one column and NULL into the rest so
3017    // the error always references the intended column (the first failing map_err).
3018    #[tokio::test]
3019    async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3020        let (store, _task_id, activity_id) = store_with_activity().await;
3021        let sql = format!(
3022            "SELECT id, task_id, timestamp, event_type, \
3023             X'DeadbEEF' as from_status, NULL as to_status, \
3024             NULL as agent, NULL as model, details \
3025             FROM task_activity WHERE id = {activity_id}"
3026        );
3027        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3028        let err = TaskStore::row_to_activity(&row).unwrap_err();
3029        assert!(
3030            err.to_string().contains("from_status"),
3031            "expected 'from_status' in error message, got: {err}"
3032        );
3033    }
3034
3035    #[tokio::test]
3036    async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3037        let (store, _task_id, activity_id) = store_with_activity().await;
3038        let sql = format!(
3039            "SELECT id, task_id, timestamp, event_type, \
3040             NULL as from_status, X'DeadbEEF' as to_status, \
3041             NULL as agent, NULL as model, details \
3042             FROM task_activity WHERE id = {activity_id}"
3043        );
3044        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3045        let err = TaskStore::row_to_activity(&row).unwrap_err();
3046        assert!(
3047            err.to_string().contains("to_status"),
3048            "expected 'to_status' in error message, got: {err}"
3049        );
3050    }
3051
3052    #[tokio::test]
3053    async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3054        let (store, _task_id, activity_id) = store_with_activity().await;
3055        let sql = format!(
3056            "SELECT id, task_id, timestamp, event_type, \
3057             NULL as from_status, NULL as to_status, \
3058             X'DeadbEEF' as agent, NULL as model, details \
3059             FROM task_activity WHERE id = {activity_id}"
3060        );
3061        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3062        let err = TaskStore::row_to_activity(&row).unwrap_err();
3063        assert!(
3064            err.to_string().contains("agent"),
3065            "expected 'agent' in error message, got: {err}"
3066        );
3067    }
3068
3069    #[tokio::test]
3070    async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3071        let (store, _task_id, activity_id) = store_with_activity().await;
3072        let sql = format!(
3073            "SELECT id, task_id, timestamp, event_type, \
3074             NULL as from_status, NULL as to_status, \
3075             NULL as agent, X'DeadbEEF' as model, details \
3076             FROM task_activity WHERE id = {activity_id}"
3077        );
3078        let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3079        let err = TaskStore::row_to_activity(&row).unwrap_err();
3080        assert!(
3081            err.to_string().contains("model"),
3082            "expected 'model' in error message, got: {err}"
3083        );
3084    }
3085
3086    // ── get_statuses_by_external_ids: decode-failure rows are skipped ───────────
3087
3088    #[tokio::test]
3089    async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3090        // Insert a task row with a BLOB value in the external_id column (valid
3091        // SQLite but not valid UTF-8 TEXT), so row.try_get("external_id") fails
3092        // with a decode error. The method should skip this row (not panic) and
3093        // continue processing the remaining rows.
3094        let store = crate::store::TaskStore::open_memory().await.unwrap();
3095        sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3096            .execute(store.pool())
3097            .await
3098            .unwrap();
3099        // A second row with a valid external_id should be returned.
3100        sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3101            .execute(store.pool())
3102            .await
3103            .unwrap();
3104
3105        let result = store
3106            .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3107            .await
3108            .unwrap();
3109        assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3110        assert_eq!(
3111            result.get("valid-id"),
3112            Some(&crate::store::TaskStatus::Done)
3113        );
3114    }
3115}