Skip to main content

orch/store/
tasks.rs

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