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