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