Skip to main content

orch/store/
tasks.rs

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