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 = CASE WHEN ? > 0 THEN ?
1836                ELSE MAX(0, (julianday('now') - julianday(started_at)) * 86400.0) END,
1837            completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1838         WHERE id = ?",
1839        )
1840        .bind(run.exit_code)
1841        .bind(run.stdout)
1842        .bind(security::redact(run.stderr))
1843        .bind(run.parsed)
1844        .bind(run.outcome)
1845        .bind(security::redact(run.error))
1846        .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1847            tracing::warn!(
1848                run.tokens.input_tokens,
1849                "input_tokens overflows i64, clamping to MAX"
1850            );
1851            i64::MAX
1852        }))
1853        .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1854            tracing::warn!(
1855                run.tokens.output_tokens,
1856                "output_tokens overflows i64, clamping to MAX"
1857            );
1858            i64::MAX
1859        }))
1860        .bind(run.tokens.total_cost_usd)
1861        .bind(run.tokens.duration_secs)
1862        .bind(run.tokens.duration_secs)
1863        .bind(run.run_id)
1864        .execute(&self.pool)
1865        .await?;
1866        if let Some(run_ctx) = run_ctx {
1867            let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1868                anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1869            })?;
1870            let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1871                anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1872            })?;
1873            let agent: String = run_ctx.try_get("agent").map_err(|e| {
1874                anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1875            })?;
1876            let model: String = run_ctx.try_get("model").map_err(|e| {
1877                anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1878            })?;
1879            if run.outcome == "timeout" {
1880                self.append_activity(
1881                    task_id,
1882                    "timeout",
1883                    None,
1884                    None,
1885                    Some(&agent),
1886                    Some(&model),
1887                    Some(&serde_json::json!({
1888                        "run_type": run_type,
1889                        "error": run.error,
1890                        "stderr": run.stderr,
1891                    })),
1892                )
1893                .await?;
1894            } else if run.outcome != "success" {
1895                self.append_activity(
1896                    task_id,
1897                    "error",
1898                    None,
1899                    None,
1900                    Some(&agent),
1901                    Some(&model),
1902                    Some(&serde_json::json!({
1903                        "run_type": run_type,
1904                        "outcome": run.outcome,
1905                        "error": run.error,
1906                    })),
1907                )
1908                .await?;
1909            }
1910        } else if run.outcome != "success" {
1911            tracing::warn!(
1912                run_id = run.run_id,
1913                outcome = run.outcome,
1914                "run_id not found in task_runs; skipping timeout/error activity log"
1915            );
1916        }
1917        Ok(())
1918    }
1919
1920    /// Finalize all incomplete runs for a task.
1921    ///
1922    /// This is used on runner early-return/error paths and engine shutdown
1923    /// paths to ensure every `start_run` has a terminal record.
1924    pub async fn finalize_incomplete_runs(
1925        &self,
1926        task_id: i64,
1927        outcome: &str,
1928        error: &str,
1929    ) -> anyhow::Result<u64> {
1930        let rows: Vec<(i64,)> = sqlx::query_as(
1931            "SELECT id FROM task_runs
1932             WHERE task_id = ?
1933               AND outcome IS NULL
1934               AND completed_at IS NULL
1935             ORDER BY id ASC",
1936        )
1937        .bind(task_id)
1938        .fetch_all(&self.pool)
1939        .await?;
1940
1941        for (run_id,) in &rows {
1942            self.complete_run(&CompleteRun {
1943                run_id: *run_id,
1944                exit_code: Some(-1),
1945                stdout: "",
1946                stderr: "",
1947                parsed: "",
1948                outcome,
1949                error,
1950                tokens: RunTokenUsage::default(),
1951            })
1952            .await?;
1953        }
1954
1955        Ok(rows.len() as u64)
1956    }
1957
1958    /// Get all runs for a task, ordered by attempt.
1959    pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
1960        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1961            "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
1962        )))
1963        .bind(task_id)
1964        .fetch_all(&self.pool)
1965        .await?;
1966
1967        rows.iter().map(Self::row_to_run).collect()
1968    }
1969
1970    /// Get all runs for a batch of task IDs in a single query, grouped by task ID.
1971    /// Returns an empty map if `task_ids` is empty.
1972    pub async fn get_runs_batch(
1973        &self,
1974        task_ids: &[i64],
1975    ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
1976        if task_ids.is_empty() {
1977            return Ok(std::collections::HashMap::new());
1978        }
1979        // SQLite has a hard limit on host parameters (commonly 999). Chunk the
1980        // IN-list into smaller batches to avoid exceeding the limit.
1981        const CHUNK_SIZE: usize = 500;
1982
1983        let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
1984            std::collections::HashMap::new();
1985
1986        for chunk in task_ids.chunks(CHUNK_SIZE) {
1987            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1988            let sql = format!(
1989                "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
1990            );
1991            let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1992            for id in chunk {
1993                query = query.bind(*id);
1994            }
1995            let rows = query.fetch_all(&self.pool).await?;
1996            for row in &rows {
1997                let run = Self::row_to_run(row)?;
1998                result.entry(run.task_id).or_default().push(run);
1999            }
2000        }
2001        Ok(result)
2002    }
2003
2004    /// Get multiple tasks by ID in a single query, returning a map from task ID to Task.
2005    /// Returns an empty map if `ids` is empty.
2006    pub async fn get_batch(
2007        &self,
2008        ids: &[i64],
2009    ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2010        if ids.is_empty() {
2011            return Ok(std::collections::HashMap::new());
2012        }
2013        // SQLite has a hard limit on host parameters (commonly 999). Chunk the
2014        // IN-list into smaller batches to avoid exceeding the limit.
2015        const CHUNK_SIZE: usize = 500;
2016
2017        let mut result = std::collections::HashMap::new();
2018
2019        for chunk in ids.chunks(CHUNK_SIZE) {
2020            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2021            let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2022            let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2023            for id in chunk {
2024                query = query.bind(*id);
2025            }
2026            let rows = query.fetch_all(&self.pool).await?;
2027            for row in &rows {
2028                let task = Self::row_to_task(row)?;
2029                result.insert(task.id, task);
2030            }
2031        }
2032        Ok(result)
2033    }
2034
2035    /// Get statuses for a batch of external IDs in a single query.
2036    /// Returns a map from external_id -> TaskStatus for rows found in the DB.
2037    /// Missing external_ids are simply not present in the returned map.
2038    /// Uses chunking to avoid SQLite parameter limits.
2039    pub async fn get_statuses_by_external_ids(
2040        &self,
2041        repo: &str,
2042        ext_ids: &[&str],
2043    ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2044        let mut result = std::collections::HashMap::new();
2045        if ext_ids.is_empty() {
2046            return Ok(result);
2047        }
2048
2049        const CHUNK_SIZE: usize = 500;
2050        for chunk in ext_ids.chunks(CHUNK_SIZE) {
2051            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2052            let sql = format!(
2053                "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2054            );
2055            let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2056            query = query.bind(repo);
2057            for id in chunk {
2058                query = query.bind(id);
2059            }
2060            let rows = query.fetch_all(&self.pool).await?;
2061            for row in &rows {
2062                let external_id: Result<String, _> = row.try_get("external_id");
2063                let status_str: Result<String, _> = row.try_get("status");
2064                match (external_id, status_str) {
2065                    (Ok(ext_id), Ok(sts)) => {
2066                        if ext_id.is_empty() {
2067                            tracing::warn!(status = %sts, "skipping task row with empty external_id");
2068                            continue;
2069                        }
2070                        if let Some(status) = TaskStatus::from_str(&sts) {
2071                            result.insert(ext_id, status);
2072                        } else {
2073                            tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2074                        }
2075                    }
2076                    (Err(e), _) => {
2077                        tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2078                    }
2079                    (_, Err(e)) => {
2080                        tracing::warn!(error = %e, "skipping task row with decode error for status");
2081                    }
2082                }
2083            }
2084        }
2085
2086        Ok(result)
2087    }
2088
2089    /// Get the last run of a specific type for a task.
2090    /// Used by tests to assert run ordering.
2091    #[cfg(test)]
2092    pub async fn get_last_run(
2093        &self,
2094        task_id: i64,
2095        run_type: &str,
2096    ) -> anyhow::Result<Option<TaskRun>> {
2097        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
2098        "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2099    )))
2100    .bind(task_id)
2101    .bind(run_type)
2102    .fetch_optional(&self.pool)
2103    .await?;
2104
2105        match row {
2106            Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2107            None => Ok(None),
2108        }
2109    }
2110
2111    /// List distinct agents that have previously reviewed this task.
2112    /// Used to exclude them when picking the next review agent, so a different
2113    /// agent reviews each cycle (avoids the same agent hitting the same issues).
2114    pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2115        let rows: Vec<(String,)> = sqlx::query_as(
2116            "SELECT DISTINCT agent FROM task_runs \
2117             WHERE task_id = ? AND run_type = 'review' \
2118             ORDER BY agent",
2119        )
2120        .bind(task_id)
2121        .fetch_all(&self.pool)
2122        .await?;
2123        Ok(rows.into_iter().map(|(a,)| a).collect())
2124    }
2125
2126    /// Prune old runs for done/blocked tasks older than `days` days.
2127    /// Test-only maintenance helper.
2128    #[cfg(test)]
2129    pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2130        let result = sqlx::query(
2131            "DELETE FROM task_runs WHERE task_id IN (
2132            SELECT id FROM tasks
2133            WHERE status IN ('done', 'blocked')
2134              AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2135         )",
2136        )
2137        .bind(format!("-{days}"))
2138        .execute(&self.pool)
2139        .await?;
2140
2141        Ok(result.rows_affected())
2142    }
2143
2144    // ---------------------------------------------------------------
2145    // Row mapping helpers
2146    // ---------------------------------------------------------------
2147
2148    fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2149        // Diagnostic: verify column count matches expected to prevent OOB panics
2150        // when schema changes without updating TASK_COLS
2151        let col_count = row.len();
2152        if col_count != TASK_COLS_COUNT {
2153            tracing::warn!(
2154                expected = TASK_COLS_COUNT,
2155                actual = col_count,
2156                "TASK_COLS column count mismatch - schema may have changed"
2157            );
2158        }
2159
2160        let status_str: String = row
2161            .try_get("status")
2162            .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2163        let labels_str: String = row.try_get("labels").unwrap_or_default();
2164        let memory_str: String = row.try_get("memory").unwrap_or_default();
2165        let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2166
2167        Ok(Task {
2168            id: row
2169                .try_get("id")
2170                .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2171            external_id: row.try_get("external_id").unwrap_or(None),
2172            repo: row
2173                .try_get("repo")
2174                .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2175            origin: row
2176                .try_get("origin")
2177                .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2178            title: row
2179                .try_get("title")
2180                .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2181            body: row
2182                .try_get("body")
2183                .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2184            status: TaskStatus::from_str(&status_str)
2185                .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2186            source: row
2187                .try_get("source")
2188                .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2189            source_id: row
2190                .try_get("source_id")
2191                .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2192            author: row
2193                .try_get("author")
2194                .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2195            url: row
2196                .try_get("url")
2197                .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2198            labels: serde_json::from_str(&labels_str)
2199                .inspect_err(
2200                    |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2201                )
2202                .unwrap_or_default(),
2203            agent: row.try_get("agent").unwrap_or(None),
2204            model: row.try_get("model").unwrap_or(None),
2205            complexity: row.try_get("complexity").unwrap_or_default(),
2206            estimate: {
2207                let raw: i32 = row.try_get("estimate").unwrap_or(0);
2208                u8::try_from(raw).unwrap_or_else(|_| {
2209                    tracing::warn!(
2210                        raw_estimate = raw,
2211                        "estimate value out of u8 range; defaulting to 0"
2212                    );
2213                    0
2214                })
2215            },
2216            route_reason: row.try_get("route_reason").unwrap_or_default(),
2217            agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2218            selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2219            route_attempts: row.try_get("route_attempts").unwrap_or(0),
2220            attempts: row.try_get("attempts").unwrap_or(0),
2221            branch: row.try_get("branch").unwrap_or_default(),
2222            worktree: row.try_get("worktree").unwrap_or_default(),
2223            worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2224            owner_node: row.try_get("owner_node").unwrap_or(None),
2225            summary: row.try_get("summary").unwrap_or_default(),
2226            last_error: row.try_get("last_error").unwrap_or_default(),
2227            parent_id: row.try_get("parent_id").unwrap_or(None),
2228            block_reason: row.try_get("block_reason").unwrap_or(None),
2229            pr_number: row.try_get("pr_number").unwrap_or(None),
2230            pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2231            last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2232            review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2233            last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2234            merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2235            ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2236            pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2237            push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2238            network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2239            review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2240            review_resets: row.try_get::<i32, _>("review_resets").unwrap_or(0),
2241            review_cycles: row.try_get("review_cycles").unwrap_or(0),
2242            review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2243            needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2244            input_tokens: row.try_get("input_tokens").unwrap_or(0),
2245            output_tokens: row.try_get("output_tokens").unwrap_or(0),
2246            input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2247            output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2248            total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2249            model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2250            limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2251            memory: serde_json::from_str(&memory_str)
2252                .inspect_err(
2253                    |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2254                )
2255                .unwrap_or_default(),
2256            delegations: serde_json::from_str(&delegations_str)
2257                .inspect_err(
2258                    |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2259                )
2260                .unwrap_or_default(),
2261            // Some deployments may be running databases that haven't had
2262            // the most recent migrations applied. Use `try_get` with
2263            // sensible defaults for recently-added columns so a missing
2264            // column doesn't cause a panic in the sqlx row accessor.
2265            auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2266            auto_unblock_last_at: row
2267                .try_get::<String, _>("auto_unblock_last_at")
2268                .unwrap_or_default(),
2269            auto_unblock_last_reason: row
2270                .try_get::<String, _>("auto_unblock_last_reason")
2271                .unwrap_or_default(),
2272            ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2273            no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2274            no_code_last_agent: row
2275                .try_get::<String, _>("no_code_last_agent")
2276                .unwrap_or_default(),
2277            created_at: row
2278                .try_get::<String, _>("created_at")
2279                .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2280            updated_at: row
2281                .try_get::<String, _>("updated_at")
2282                .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2283        })
2284    }
2285    pub async fn ensure_external_task(
2286        &self,
2287        repo: &str,
2288        ext: &crate::backends::ExternalTask,
2289    ) -> anyhow::Result<i64> {
2290        self.upsert_external(&UpsertExternal {
2291            repo,
2292            ext_id: &ext.id.0,
2293            title: &ext.title,
2294            body: &ext.body,
2295            author: &ext.author,
2296            url: &ext.url,
2297            labels: &ext.labels,
2298            origin: "github",
2299        })
2300        .await
2301    }
2302
2303    /// Resolve a task_id (e.g. "42" or "internal:3") to a store internal ID.
2304    /// Returns None if the task is not in the store yet.
2305    pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2306        if let Some(suffix) = task_id.strip_prefix("internal:") {
2307            // Internal tasks: look up by external_id="internal:{n}" with origin='internal'
2308            if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2309                return Ok(Some(id));
2310            }
2311            // Fallback: try the numeric suffix as a direct store ID.
2312            // Must include the repo filter to avoid resolving a task that
2313            // belongs to a different repository in a multi-repo setup.
2314            if let Ok(id) = suffix.parse::<i64>() {
2315                let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2316                    .bind(id)
2317                    .bind(repo)
2318                    .fetch_optional(&self.pool)
2319                    .await?;
2320                if exists.is_some() {
2321                    return Ok(Some(id));
2322                }
2323            }
2324            return Ok(None);
2325        }
2326        // External tasks: look up by external_id using the lightweight id-only
2327        // lookup to avoid deserializing the full Task row.
2328        self.resolve_id_by_external(repo, task_id).await
2329    }
2330
2331    /// Resolve a task_id globally (across all repos) when a repo-scoped lookup fails.
2332    ///
2333    /// Returns `(internal_id, repo)` if found unambiguously, `None` if not found,
2334    /// or an error if the external_id matches tasks in multiple repos (GitHub issue
2335    /// numbers are not globally unique).
2336    pub async fn resolve_task_id_globally(
2337        &self,
2338        task_id: &str,
2339    ) -> anyhow::Result<Option<(i64, String)>> {
2340        if let Some(suffix) = task_id.strip_prefix("internal:") {
2341            // internal:N — external_id is globally unique; primary key is also unique.
2342            let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2343                .bind(task_id)
2344                .fetch_optional(&self.pool)
2345                .await?;
2346            if let Some(r) = row {
2347                return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2348            }
2349            if let Ok(id) = suffix.parse::<i64>() {
2350                let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2351                    .bind(id)
2352                    .fetch_optional(&self.pool)
2353                    .await?;
2354                if let Some(r) = row {
2355                    return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2356                }
2357            }
2358            return Ok(None);
2359        }
2360        // External IDs (e.g. GitHub issue numbers) may collide across repos.
2361        let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2362            .bind(task_id)
2363            .fetch_all(&self.pool)
2364            .await?;
2365        match rows.len() {
2366            0 => Ok(None),
2367            1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2368            _ => {
2369                let repos: Vec<String> = rows
2370                    .iter()
2371                    .filter_map(|r| r.try_get::<String, _>("repo").ok())
2372                    .collect();
2373                anyhow::bail!(
2374                    "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2375                     Run from the repo worktree so the current-repo lookup resolves it.",
2376                    repos.join(", ")
2377                )
2378            }
2379        }
2380    }
2381
2382    /// Resolve a CLI task id. A bare number in a project is that project's issue first
2383    pub async fn resolve_cli_id(
2384        &self,
2385        spec: &str,
2386        current_repo: Option<&str>,
2387    ) -> anyhow::Result<Option<CliTaskRef>> {
2388        let current_repo = current_repo.filter(|repo| !repo.is_empty());
2389        match parse_cli_task_spec(spec)? {
2390            CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2391            CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2392            CliTaskSpec::Bare(number) => {
2393                if let Some(repo) = current_repo {
2394                    if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2395                        return Ok(Some(task));
2396                    }
2397                    if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2398                        return Ok(Some(task));
2399                    }
2400                    return Ok(None);
2401                }
2402                let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2403                match matches.len() {
2404                    0 => Ok(None),
2405                    1 => Ok(matches.pop()),
2406                    _ => Err(ambiguous_cli_id_error(spec, &matches)),
2407                }
2408            }
2409        }
2410    }
2411
2412    async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2413        let ext = format!("internal:{number}");
2414        let rows = sqlx::query(
2415            "SELECT id, repo, origin, title, external_id, status FROM tasks \
2416             WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2417             ORDER BY id",
2418        )
2419        .bind(number)
2420        .bind(&ext)
2421        .fetch_all(&self.pool)
2422        .await?;
2423        let mut matches = dedup_cli_refs(rows)?;
2424        match matches.len() {
2425            0 => Ok(None),
2426            1 => Ok(matches.pop()),
2427            _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2428        }
2429    }
2430
2431    async fn lookup_internal_in_repo(
2432        &self,
2433        number: i64,
2434        repo: &str,
2435    ) -> anyhow::Result<Option<CliTaskRef>> {
2436        let row = sqlx::query(
2437            "SELECT id, repo, origin, title, external_id, status FROM tasks \
2438             WHERE origin = 'internal' AND id = ? AND repo = ?",
2439        )
2440        .bind(number)
2441        .bind(repo)
2442        .fetch_optional(&self.pool)
2443        .await?;
2444        match row {
2445            Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2446            None => Ok(None),
2447        }
2448    }
2449
2450    async fn lookup_issue_cli_id(
2451        &self,
2452        repo: &str,
2453        number: i64,
2454    ) -> anyhow::Result<Option<CliTaskRef>> {
2455        let ext = number.to_string();
2456        let rows = sqlx::query(
2457            "SELECT id, repo, origin, title, external_id, status FROM tasks \
2458             WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2459             ORDER BY id",
2460        )
2461        .bind(repo)
2462        .bind(&ext)
2463        .fetch_all(&self.pool)
2464        .await?;
2465        let mut matches = dedup_cli_refs(rows)?;
2466        match matches.len() {
2467            0 => Ok(None),
2468            1 => Ok(matches.pop()),
2469            _ => Err(ambiguous_cli_id_error(
2470                &format!("{repo}#{number}"),
2471                &matches,
2472            )),
2473        }
2474    }
2475
2476    async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2477        let ext = number.to_string();
2478        let rows = sqlx::query(
2479            "SELECT id, repo, origin, title, external_id, status FROM tasks \
2480             WHERE (origin = 'internal' AND id = ?) \
2481                OR (origin != 'internal' AND external_id = ?) \
2482             ORDER BY repo, id",
2483        )
2484        .bind(number)
2485        .bind(&ext)
2486        .fetch_all(&self.pool)
2487        .await?;
2488        dedup_cli_refs(rows)
2489    }
2490
2491    fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2492        Ok(TaskRun {
2493            id: row
2494                .try_get("id")
2495                .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2496            task_id: row
2497                .try_get("task_id")
2498                .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2499            attempt: row
2500                .try_get("attempt")
2501                .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2502            run_type: row
2503                .try_get("run_type")
2504                .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2505            agent: row
2506                .try_get("agent")
2507                .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2508            model: row
2509                .try_get("model")
2510                .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2511            command: row
2512                .try_get("command")
2513                .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2514            prompt: row
2515                .try_get("prompt")
2516                .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2517            env_vars: row
2518                .try_get("env_vars")
2519                .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2520            exit_code: row
2521                .try_get("exit_code")
2522                .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2523            stdout: row
2524                .try_get("stdout")
2525                .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2526            stderr: row
2527                .try_get("stderr")
2528                .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2529            parsed_response: row
2530                .try_get("parsed_response")
2531                .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2532            outcome: row
2533                .try_get("outcome")
2534                .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2535            error: row
2536                .try_get("error")
2537                .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2538            input_tokens: row
2539                .try_get("input_tokens")
2540                .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2541            output_tokens: row
2542                .try_get("output_tokens")
2543                .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2544            total_cost_usd: row
2545                .try_get("total_cost_usd")
2546                .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2547            duration_secs: row
2548                .try_get("duration_secs")
2549                .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2550            started_at: row
2551                .try_get("started_at")
2552                .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2553            completed_at: row.try_get("completed_at").unwrap_or(None),
2554        })
2555    }
2556
2557    fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2558        let details_str: String = row
2559            .try_get("details")
2560            .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2561        let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2562            |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2563        )?;
2564        Ok(TaskActivity {
2565            id: row
2566                .try_get("id")
2567                .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2568            task_id: row
2569                .try_get("task_id")
2570                .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2571            timestamp: row
2572                .try_get("timestamp")
2573                .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2574            event_type: row
2575                .try_get("event_type")
2576                .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2577            from_status: row
2578                .try_get("from_status")
2579                .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2580            to_status: row
2581                .try_get("to_status")
2582                .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2583            agent: row
2584                .try_get("agent")
2585                .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2586            model: row
2587                .try_get("model")
2588                .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2589            details,
2590        })
2591    }
2592}
2593
2594/// Columns that describe this node's own copy of a task, never sent through the log
2595const LOCAL_TASK_COLUMNS: &[&str] = &[
2596    "worktree",
2597    "input_tokens",
2598    "output_tokens",
2599    "input_cost_usd",
2600    "output_cost_usd",
2601    "total_cost_usd",
2602];
2603
2604pub(crate) fn is_local_column(col: &str) -> bool {
2605    LOCAL_TASK_COLUMNS.contains(&col)
2606}
2607
2608/// Owned copy of [`UpsertExternal`], so it can travel in a Raft entry
2609#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2610pub struct UpsertExternalCmd {
2611    pub repo: String,
2612    pub ext_id: String,
2613    pub title: String,
2614    pub body: String,
2615    pub author: String,
2616    pub url: String,
2617    pub labels: Vec<String>,
2618    pub origin: String,
2619    pub parent_id: Option<i64>,
2620}
2621
2622/// Owned copy of [`StoreRoute`], so it can travel in a Raft entry
2623#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2624pub struct StoreRouteCmd {
2625    pub id: i64,
2626    pub agent: String,
2627    pub model: Option<String>,
2628    pub complexity: String,
2629    pub estimate: u8,
2630    pub reason: String,
2631    pub profile: String,
2632    pub skills: String,
2633}
2634
2635/// The columns a done task's tombstone keeps. Nodes that had the task keep the rest locally
2636const TOMBSTONE_COLUMNS: &[&str] = &[
2637    "id",
2638    "external_id",
2639    "repo",
2640    "origin",
2641    "title",
2642    "status",
2643    "pr_number",
2644    "parent_id",
2645    "created_at",
2646    "updated_at",
2647];
2648
2649async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2650    let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2651        .fetch_all(conn)
2652        .await?;
2653    Ok(cols
2654        .into_iter()
2655        .map(|(c,)| c)
2656        .filter(|c| !is_local_column(c))
2657        .collect())
2658}
2659
2660/// Every task row as JSON with its replicated columns, done tasks as tombstones
2661pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2662    let mut conn = pool.acquire().await?;
2663    let cols = replicated_columns(&mut conn).await?;
2664    let full: Vec<&String> = cols.iter().collect();
2665    let tomb: Vec<&String> = cols
2666        .iter()
2667        .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2668        .collect();
2669    let sql = format!(
2670        "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2671        super::replica::json_row_sql(&tomb),
2672        super::replica::json_row_sql(&full)
2673    );
2674    let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
2675        .fetch_all(&mut *conn)
2676        .await?;
2677    rows.into_iter()
2678        .map(|(r,)| Ok(serde_json::from_str(&r)?))
2679        .collect()
2680}
2681
2682/// Make the task rows match `rows`. Local columns, and a tombstone's missing columns, keep the local
2683/// value. Tasks missing from `rows` go, with this node's runs and activity for them
2684pub(crate) async fn replace_replicated(
2685    conn: &mut sqlx::SqliteConnection,
2686    rows: &[serde_json::Value],
2687) -> anyhow::Result<()> {
2688    let allowed = replicated_columns(conn).await?;
2689    sqlx::query("PRAGMA defer_foreign_keys = ON")
2690        .execute(&mut *conn)
2691        .await?;
2692    sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2693        .execute(&mut *conn)
2694        .await?;
2695    sqlx::query("DELETE FROM snapshot_ids")
2696        .execute(&mut *conn)
2697        .await?;
2698    for row in rows {
2699        let id = row["id"]
2700            .as_i64()
2701            .context("snapshot task row without an id")?;
2702        sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2703            .bind(id)
2704            .execute(&mut *conn)
2705            .await?;
2706    }
2707
2708    for table in ["task_runs", "task_activity"] {
2709        sqlx::query(sqlx::AssertSqlSafe(format!(
2710            "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2711        )))
2712        .execute(&mut *conn)
2713        .await?;
2714    }
2715
2716    sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2717        .execute(&mut *conn)
2718        .await?;
2719
2720    for row in rows {
2721        let obj = row
2722            .as_object()
2723            .context("snapshot task row is not an object")?;
2724        let cols: Vec<&String> = obj.keys().collect();
2725        for c in &cols {
2726            anyhow::ensure!(
2727                allowed.contains(c),
2728                "snapshot names unknown task column {c}"
2729            );
2730        }
2731
2732        let values: Vec<String> = cols
2733            .iter()
2734            .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2735            .collect();
2736        let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2737        let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2738        let sql = format!(
2739            "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2740            names.join(", "),
2741            values.join(", "),
2742            updates.join(", ")
2743        );
2744        sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2745            .bind(serde_json::to_string(row)?)
2746            .execute(&mut *conn)
2747            .await?;
2748    }
2749
2750    sqlx::query("DROP TABLE snapshot_ids")
2751        .execute(&mut *conn)
2752        .await?;
2753    Ok(())
2754}
2755
2756/// The SQL behind each replicated task write, run with the proposer's time
2757pub(crate) mod apply {
2758    use super::*;
2759    use sqlx::SqliteConnection;
2760
2761    async fn previous(
2762        conn: &mut SqliteConnection,
2763        id: i64,
2764    ) -> anyhow::Result<(String, Option<String>, Option<String>)> {
2765        let row = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
2766            .bind(id)
2767            .fetch_one(&mut *conn)
2768            .await?;
2769        let status: String = row
2770            .try_get("status")
2771            .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
2772        let agent: Option<String> = row
2773            .try_get("agent")
2774            .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
2775        let model: Option<String> = row
2776            .try_get("model")
2777            .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
2778        Ok((status, agent, model))
2779    }
2780
2781    #[allow(clippy::too_many_arguments)]
2782    pub(crate) async fn activity(
2783        conn: &mut SqliteConnection,
2784        task_id: i64,
2785        event_type: &str,
2786        from_status: Option<&str>,
2787        to_status: Option<&str>,
2788        agent: Option<&str>,
2789        model: Option<&str>,
2790        details: Option<&serde_json::Value>,
2791    ) -> anyhow::Result<()> {
2792        let details_json = match details {
2793            Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
2794            None => "{}".to_string(),
2795        };
2796        sqlx::query(
2797            "INSERT INTO task_activity
2798             (task_id, event_type, from_status, to_status, agent, model, details)
2799             VALUES (?, ?, ?, ?, ?, ?, ?)",
2800        )
2801        .bind(task_id)
2802        .bind(event_type)
2803        .bind(from_status)
2804        .bind(to_status)
2805        .bind(agent)
2806        .bind(model)
2807        .bind(details_json)
2808        .execute(conn)
2809        .await?;
2810        Ok(())
2811    }
2812
2813    async fn status_change(
2814        conn: &mut SqliteConnection,
2815        id: i64,
2816        previous: &(String, Option<String>, Option<String>),
2817        to: TaskStatus,
2818        details: Option<&serde_json::Value>,
2819    ) -> anyhow::Result<()> {
2820        activity(
2821            conn,
2822            id,
2823            "status_change",
2824            Some(previous.0.as_str()),
2825            Some(to.as_str()),
2826            previous.1.as_deref(),
2827            previous.2.as_deref(),
2828            details,
2829        )
2830        .await
2831    }
2832
2833    /// Through Raft, ids come from the replicated rows so every node assigns the same one.
2834    /// Otherwise NULL lets SQLite's AUTOINCREMENT pick it, as before
2835    pub(crate) async fn create(
2836        conn: &mut SqliteConnection,
2837        now: &str,
2838        new: &NewTask,
2839        internal: bool,
2840        replicated: bool,
2841    ) -> anyhow::Result<i64> {
2842        let labels_json = serde_json::to_string(&new.labels)?;
2843        let row = sqlx::query(
2844            "INSERT INTO tasks (id, external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id, created_at, updated_at)
2845             VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2846             RETURNING id",
2847        )
2848        .bind(replicated)
2849        .bind(&new.external_id)
2850        .bind(&new.repo)
2851        .bind(&new.origin)
2852        .bind(&new.title)
2853        .bind(&new.body)
2854        .bind(&new.source)
2855        .bind(&new.source_id)
2856        .bind(&new.author)
2857        .bind(&new.url)
2858        .bind(&labels_json)
2859        .bind(new.parent_id)
2860        .bind(now)
2861        .bind(now)
2862        .fetch_one(&mut *conn)
2863        .await?;
2864        let id: i64 = row
2865            .try_get("id")
2866            .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
2867        if internal {
2868            // resolve_task_id finds internal tasks by this external id
2869            sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
2870                .bind(format!("internal:{id}"))
2871                .bind(id)
2872                .execute(conn)
2873                .await?;
2874        }
2875
2876        Ok(id)
2877    }
2878
2879    pub(crate) async fn upsert_external(
2880        conn: &mut SqliteConnection,
2881        now: &str,
2882        ext: &UpsertExternalCmd,
2883        replicated: bool,
2884    ) -> anyhow::Result<i64> {
2885        let labels_json = serde_json::to_string(&ext.labels)?;
2886        let row = sqlx::query(
2887            "INSERT INTO tasks (id, external_id, repo, origin, title, body, author, url, labels, parent_id, created_at, updated_at)
2888             VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2889             ON CONFLICT(repo, external_id) DO UPDATE SET
2890                title = excluded.title,
2891                body = excluded.body,
2892                labels = excluded.labels,
2893                url = excluded.url,
2894                parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
2895             RETURNING id",
2896        )
2897        .bind(replicated)
2898        .bind(&ext.ext_id)
2899        .bind(&ext.repo)
2900        .bind(&ext.origin)
2901        .bind(&ext.title)
2902        .bind(&ext.body)
2903        .bind(&ext.author)
2904        .bind(&ext.url)
2905        .bind(&labels_json)
2906        .bind(ext.parent_id)
2907        .bind(now)
2908        .bind(now)
2909        .fetch_one(conn)
2910        .await?;
2911        row.try_get("id")
2912            .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
2913    }
2914
2915    fn ensure_allowed(updates: &[(String, serde_json::Value)]) -> anyhow::Result<()> {
2916        for (col, _) in updates {
2917            anyhow::ensure!(
2918                ALLOWED_FIELDS.contains(&col.as_str()),
2919                "column {col} is not in the update allowlist"
2920            );
2921        }
2922
2923        Ok(())
2924    }
2925
2926    pub(crate) async fn update_status_and_fields(
2927        conn: &mut SqliteConnection,
2928        now: &str,
2929        id: i64,
2930        status: TaskStatus,
2931        updates: &[(String, serde_json::Value)],
2932    ) -> anyhow::Result<()> {
2933        ensure_allowed(updates)?;
2934        let prev = previous(conn, id).await?;
2935
2936        let mut set_parts = vec!["status = ?".to_string()];
2937        let mut values: Vec<Option<String>> = vec![Some(status.as_str().to_string())];
2938        let mut block_reason_in_updates = false;
2939        for (col, val) in updates {
2940            if col == "block_reason" {
2941                block_reason_in_updates = true;
2942            }
2943
2944            set_parts.push(format!("{col} = ?"));
2945            match val {
2946                serde_json::Value::String(s) => values.push(Some(s.clone())),
2947                serde_json::Value::Number(n) => values.push(Some(n.to_string())),
2948                serde_json::Value::Bool(b) => {
2949                    values.push(Some(if *b { "1" } else { "0" }.to_string()));
2950                }
2951                serde_json::Value::Null => values.push(None),
2952                other => values.push(Some(serde_json::to_string(other)?)),
2953            }
2954        }
2955
2956        if !block_reason_in_updates && status != TaskStatus::Blocked {
2957            set_parts.push("block_reason = NULL".to_string());
2958        }
2959
2960        set_parts.push("updated_at = ?".to_string());
2961        values.push(Some(now.to_string()));
2962        let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
2963        let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2964        for v in &values {
2965            query = query.bind(v.as_deref());
2966        }
2967        query.bind(id).execute(&mut *conn).await?;
2968        status_change(conn, id, &prev, status, None).await
2969    }
2970
2971    /// Moves the task to `status`, and when `expected` is set only if it still holds that status
2972    pub(crate) async fn update_status(
2973        conn: &mut SqliteConnection,
2974        now: &str,
2975        id: i64,
2976        status: TaskStatus,
2977        expected: Option<TaskStatus>,
2978    ) -> anyhow::Result<bool> {
2979        let prev = previous(conn, id).await?;
2980        let clear_block = if status == TaskStatus::Blocked {
2981            ""
2982        } else {
2983            ", block_reason = NULL"
2984        };
2985        let guard = if expected.is_some() {
2986            " AND status = ?"
2987        } else {
2988            ""
2989        };
2990        let sql =
2991            format!("UPDATE tasks SET status = ?{clear_block}, updated_at = ? WHERE id = ?{guard}");
2992        let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2993            .bind(status.as_str())
2994            .bind(now)
2995            .bind(id);
2996        if let Some(expected) = expected {
2997            query = query.bind(expected.as_str());
2998        }
2999
3000        if query.execute(&mut *conn).await?.rows_affected() == 0 {
3001            return Ok(false);
3002        }
3003
3004        status_change(conn, id, &prev, status, None).await?;
3005        Ok(true)
3006    }
3007
3008    pub(crate) async fn update_external_id(
3009        conn: &mut SqliteConnection,
3010        now: &str,
3011        id: i64,
3012        external_id: &str,
3013    ) -> anyhow::Result<()> {
3014        sqlx::query("UPDATE tasks SET external_id = ?, updated_at = ? WHERE id = ?")
3015            .bind(external_id)
3016            .bind(now)
3017            .bind(id)
3018            .execute(conn)
3019            .await?;
3020        Ok(())
3021    }
3022
3023    pub(crate) async fn touch(
3024        conn: &mut SqliteConnection,
3025        now: &str,
3026        id: i64,
3027    ) -> anyhow::Result<()> {
3028        sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
3029            .bind(now)
3030            .bind(id)
3031            .execute(conn)
3032            .await?;
3033        Ok(())
3034    }
3035
3036    pub(crate) async fn reset_to_new(
3037        conn: &mut SqliteConnection,
3038        now: &str,
3039        id: i64,
3040    ) -> anyhow::Result<()> {
3041        let prev = previous(conn, id).await?;
3042        sqlx::query(
3043            "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = ? WHERE id = ?",
3044        )
3045        .bind(now)
3046        .bind(id)
3047        .execute(&mut *conn)
3048        .await?;
3049        let details = serde_json::json!({ "op": "reset_to_new" });
3050        status_change(conn, id, &prev, TaskStatus::New, Some(&details)).await
3051    }
3052
3053    /// `silent` leaves `updated_at` alone, for bookkeeping that must not look like activity
3054    pub(crate) async fn set_fields(
3055        conn: &mut SqliteConnection,
3056        now: &str,
3057        id: i64,
3058        updates: &[(String, serde_json::Value)],
3059        silent: bool,
3060    ) -> anyhow::Result<()> {
3061        if updates.is_empty() {
3062            return Ok(());
3063        }
3064
3065        ensure_allowed(updates)?;
3066        let mut set_parts = Vec::new();
3067        let mut values: Vec<Option<String>> = Vec::new();
3068        for (col, val) in updates {
3069            set_parts.push(format!("{col} = ?"));
3070            values.push(sql_value_for_task_field(col, val)?);
3071        }
3072
3073        if !silent {
3074            set_parts.push("updated_at = ?".to_string());
3075            values.push(Some(now.to_string()));
3076        }
3077
3078        let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
3079        let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
3080        for v in &values {
3081            query = query.bind(v.as_deref());
3082        }
3083        query.bind(id).execute(conn).await?;
3084        Ok(())
3085    }
3086
3087    pub(crate) async fn increment(
3088        conn: &mut SqliteConnection,
3089        now: &str,
3090        id: i64,
3091        field: &str,
3092    ) -> anyhow::Result<i64> {
3093        anyhow::ensure!(
3094            INCREMENTABLE_FIELDS.contains(&field),
3095            "field {field} is not incrementable"
3096        );
3097        let sql = format!(
3098            "UPDATE tasks SET {field} = {field} + 1, updated_at = ? WHERE id = ? RETURNING {field} AS val"
3099        );
3100        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3101            .bind(now)
3102            .bind(id)
3103            .fetch_one(conn)
3104            .await?;
3105        let val: i32 = row.try_get("val")?;
3106        Ok(val.into())
3107    }
3108
3109    /// `failure_only` keeps `attempts`, `review_invocations`, `review_cycles`,
3110    /// `ci_merge_failures` and `merge_conflict_retries`, see `reset_failure_counters`
3111    pub(crate) async fn reset_counters(
3112        conn: &mut SqliteConnection,
3113        now: &str,
3114        id: i64,
3115        failure_only: bool,
3116    ) -> anyhow::Result<()> {
3117        let all = if failure_only {
3118            ""
3119        } else {
3120            "attempts = 0, merge_conflict_retries = 0, ci_merge_failures = 0, review_cycles = 0, review_invocations = 0,"
3121        };
3122        let sql = format!(
3123            "UPDATE tasks SET {all}
3124            route_attempts = 0,
3125            review_agent_failures = 0,
3126            review_resets = 0,
3127            pr_create_failures = 0,
3128            push_failures = 0,
3129            network_retries = 0,
3130            auto_unblock_count = 0,
3131            auto_unblock_last_at = '',
3132            auto_unblock_last_reason = '',
3133            ci_recovery_count = 0,
3134            no_code_reroutes = 0,
3135            no_code_last_agent = '',
3136            needs_review_refires = 0,
3137            updated_at = ?
3138         WHERE id = ?"
3139        );
3140        sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3141            .bind(now)
3142            .bind(id)
3143            .execute(conn)
3144            .await?;
3145        Ok(())
3146    }
3147
3148    pub(crate) async fn store_route(
3149        conn: &mut SqliteConnection,
3150        now: &str,
3151        route: &StoreRouteCmd,
3152    ) -> anyhow::Result<()> {
3153        sqlx::query(
3154            "UPDATE tasks SET
3155            agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
3156            agent_profile = ?, selected_skills = ?,
3157            updated_at = ?
3158         WHERE id = ?",
3159        )
3160        .bind(&route.agent)
3161        .bind(&route.model)
3162        .bind(&route.complexity)
3163        .bind(route.estimate as i64)
3164        .bind(&route.reason)
3165        .bind(&route.profile)
3166        .bind(&route.skills)
3167        .bind(now)
3168        .bind(route.id)
3169        .execute(&mut *conn)
3170        .await?;
3171        let details = serde_json::json!({
3172            "complexity": route.complexity,
3173            "estimate": route.estimate,
3174            "reason": route.reason,
3175            "skills": route.skills,
3176        });
3177        activity(
3178            conn,
3179            route.id,
3180            "dispatch",
3181            None,
3182            None,
3183            Some(&route.agent),
3184            route.model.as_deref(),
3185            Some(&details),
3186        )
3187        .await
3188    }
3189
3190    pub(crate) async fn set_estimate_if_zero(
3191        conn: &mut SqliteConnection,
3192        now: &str,
3193        id: i64,
3194        estimate: u8,
3195    ) -> anyhow::Result<bool> {
3196        let result = sqlx::query(
3197            "UPDATE tasks SET estimate = ?, updated_at = ? WHERE id = ? AND estimate = 0",
3198        )
3199        .bind(estimate as i64)
3200        .bind(now)
3201        .bind(id)
3202        .execute(conn)
3203        .await?;
3204        Ok(result.rows_affected() > 0)
3205    }
3206
3207    pub(crate) async fn append_memory(
3208        conn: &mut SqliteConnection,
3209        now: &str,
3210        id: i64,
3211        entry: &MemoryEntry,
3212    ) -> anyhow::Result<()> {
3213        let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
3214            .bind(id)
3215            .fetch_one(&mut *conn)
3216            .await?;
3217        // A corrupt value must fail, an empty array in its place would lose the old entries
3218        let memory_opt: Option<String> = row
3219            .try_get::<Option<String>, _>("memory")
3220            .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
3221        let memory_str = memory_opt.unwrap_or_default();
3222        let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
3223            Vec::new()
3224        } else {
3225            serde_json::from_str(&memory_str)
3226                .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
3227        };
3228        memory.push(entry.clone());
3229        sqlx::query("UPDATE tasks SET memory = ?, updated_at = ? WHERE id = ?")
3230            .bind(serde_json::to_string(&memory)?)
3231            .bind(now)
3232            .bind(id)
3233            .execute(conn)
3234            .await?;
3235        Ok(())
3236    }
3237
3238    pub(crate) async fn mark_cleaned(
3239        conn: &mut SqliteConnection,
3240        now: &str,
3241        id: i64,
3242    ) -> anyhow::Result<()> {
3243        sqlx::query("UPDATE tasks SET worktree_cleaned = 1, updated_at = ? WHERE id = ?")
3244            .bind(now)
3245            .bind(id)
3246            .execute(&mut *conn)
3247            .await?;
3248        activity(
3249            conn,
3250            id,
3251            "branch_delete",
3252            None,
3253            None,
3254            None,
3255            None,
3256            Some(&serde_json::json!({ "worktree_cleaned": true })),
3257        )
3258        .await
3259    }
3260}
3261
3262#[cfg(test)]
3263mod row_to_task_tests {
3264    use super::*;
3265
3266    async fn store_with_task() -> (crate::store::TaskStore, i64) {
3267        let store = crate::store::TaskStore::open_memory().await.unwrap();
3268        let id = store
3269            .create(&NewTask {
3270                external_id: None,
3271                repo: "owner/repo".to_string(),
3272                origin: "internal".to_string(),
3273                title: "Test task".to_string(),
3274                body: "Test body".to_string(),
3275                source: "cron".to_string(),
3276                source_id: "test-1".to_string(),
3277                author: "tester".to_string(),
3278                url: "https://example.com/1".to_string(),
3279                labels: vec![],
3280                parent_id: None,
3281            })
3282            .await
3283            .unwrap();
3284        (store, id)
3285    }
3286
3287    // Each test selects all previously-required columns so that exactly the
3288    // column under test is absent and triggers the Err path.
3289    macro_rules! missing_column_test {
3290        ($name:ident, $col:literal, $select:literal) => {
3291            #[tokio::test]
3292            async fn $name() {
3293                let (store, id) = store_with_task().await;
3294                let sql = format!("{} WHERE id = {id}", $select);
3295                let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3296                    .fetch_one(store.pool())
3297                    .await
3298                    .unwrap();
3299                let err = TaskStore::row_to_task(&row).unwrap_err();
3300                assert!(
3301                    err.to_string().contains($col),
3302                    "expected '{}' in error message, got: {err}",
3303                    $col
3304                );
3305            }
3306        };
3307    }
3308
3309    missing_column_test!(
3310        row_to_task_fails_on_missing_repo,
3311        "repo",
3312        "SELECT id, status FROM tasks"
3313    );
3314    missing_column_test!(
3315        row_to_task_fails_on_missing_origin,
3316        "origin",
3317        "SELECT id, repo, status FROM tasks"
3318    );
3319    missing_column_test!(
3320        row_to_task_fails_on_missing_title,
3321        "title",
3322        "SELECT id, repo, origin, status FROM tasks"
3323    );
3324    missing_column_test!(
3325        row_to_task_fails_on_missing_body,
3326        "body",
3327        "SELECT id, repo, origin, title, status FROM tasks"
3328    );
3329    missing_column_test!(
3330        row_to_task_fails_on_missing_source,
3331        "source",
3332        "SELECT id, repo, origin, title, body, status FROM tasks"
3333    );
3334    missing_column_test!(
3335        row_to_task_fails_on_missing_source_id,
3336        "source_id",
3337        "SELECT id, repo, origin, title, body, source, status FROM tasks"
3338    );
3339    missing_column_test!(
3340        row_to_task_fails_on_missing_author,
3341        "author",
3342        "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
3343    );
3344    missing_column_test!(
3345        row_to_task_fails_on_missing_url,
3346        "url",
3347        "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
3348    );
3349
3350    // Decode-error test: inject a BLOB with invalid UTF-8 bytes for the `repo`
3351    // column. SQLite's type coercion converts NULL and integers to strings, so
3352    // they don't produce a decode error. A raw BLOB that is not valid UTF-8
3353    // (X'DEADBEEF') does cause `try_get::<String>` to fail with a ColumnDecode
3354    // error, exercising the decode-error-propagation path that is distinct from
3355    // the missing-column (ColumnNotFound) path tested above.
3356    #[tokio::test]
3357    async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
3358        let (store, id) = store_with_task().await;
3359        // X'DEADBEEF' is a 4-byte BLOB; bytes 0xDE 0xAD 0xBE 0xEF are not
3360        // valid UTF-8, so try_get::<String>("repo") will fail with a decode error.
3361        let sql = format!(
3362            "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
3363             source, source_id, author, url FROM tasks WHERE id = {id}"
3364        );
3365        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3366            .fetch_one(store.pool())
3367            .await
3368            .unwrap();
3369        let err = TaskStore::row_to_task(&row).unwrap_err();
3370        assert!(
3371            err.to_string().contains("repo"),
3372            "expected 'repo' in error message, got: {err}"
3373        );
3374    }
3375
3376    // ── distinct_repos decode-error tests ─────────────────────────────────
3377
3378    #[tokio::test]
3379    async fn distinct_repos_fails_on_decode_error() {
3380        let store = crate::store::TaskStore::open_memory().await.unwrap();
3381        let id = store
3382            .create(&NewTask {
3383                external_id: None,
3384                repo: "owner/repo".to_string(),
3385                origin: "internal".to_string(),
3386                title: "Test task".to_string(),
3387                body: "Test body".to_string(),
3388                source: "cron".to_string(),
3389                source_id: "test-1".to_string(),
3390                author: "tester".to_string(),
3391                url: "https://example.com/1".to_string(),
3392                labels: vec![],
3393                parent_id: None,
3394            })
3395            .await
3396            .unwrap();
3397
3398        // Inject an invalid UTF-8 BLOB as repo — try_get::<String> will fail.
3399        let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
3400        sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3401            .execute(store.pool())
3402            .await
3403            .unwrap();
3404
3405        let err = store.distinct_repos().await.unwrap_err();
3406        assert!(
3407            err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
3408            "expected 'distinct_repos' and 'repo' in error message, got: {err}"
3409        );
3410    }
3411
3412    // ── row_to_run decode-error tests ────────────────────────────────────────
3413
3414    async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
3415        let store = crate::store::TaskStore::open_memory().await.unwrap();
3416        let task_id = store
3417            .create(&NewTask {
3418                external_id: None,
3419                repo: "owner/repo".to_string(),
3420                origin: "internal".to_string(),
3421                title: "Test task".to_string(),
3422                body: "Test body".to_string(),
3423                source: "cron".to_string(),
3424                source_id: "test-1".to_string(),
3425                author: "tester".to_string(),
3426                url: "https://example.com/1".to_string(),
3427                labels: vec![],
3428                parent_id: None,
3429            })
3430            .await
3431            .unwrap();
3432        let run_id = store
3433            .start_run(&crate::store::StartRun {
3434                task_id,
3435                attempt: 1,
3436                run_type: "agent",
3437                agent: "claude",
3438                model: "sonnet",
3439                command: "claude -p ...",
3440                prompt: "system prompt",
3441            })
3442            .await
3443            .unwrap();
3444        (store, task_id, run_id)
3445    }
3446
3447    #[tokio::test]
3448    async fn start_run_upsert_clears_stale_completion_fields() {
3449        let (store, task_id, run_id) = store_with_run().await;
3450        store
3451            .complete_run(&CompleteRun {
3452                run_id,
3453                exit_code: Some(0),
3454                stdout: "",
3455                stderr: "",
3456                parsed: "",
3457                outcome: "success",
3458                error: "boom",
3459                tokens: RunTokenUsage::default(),
3460            })
3461            .await
3462            .unwrap();
3463
3464        // Re-dispatch the same (task_id, attempt, run_type) key.
3465        let same_run_id = store
3466            .start_run(&crate::store::StartRun {
3467                task_id,
3468                attempt: 1,
3469                run_type: "agent",
3470                agent: "claude",
3471                model: "sonnet",
3472                command: "claude -p ...",
3473                prompt: "system prompt",
3474            })
3475            .await
3476            .unwrap();
3477        assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
3478
3479        let row = sqlx::query(
3480            "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
3481        )
3482        .bind(run_id)
3483        .fetch_one(store.pool())
3484        .await
3485        .unwrap();
3486        let outcome: Option<String> = row.try_get("outcome").unwrap();
3487        let completed_at: Option<String> = row.try_get("completed_at").unwrap();
3488        let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
3489        let error: Option<String> = row.try_get("error").unwrap();
3490        assert_eq!(outcome, None);
3491        assert_eq!(
3492            completed_at, None,
3493            "stale completion timestamp must be cleared"
3494        );
3495        assert_eq!(exit_code, None);
3496        assert_eq!(error, None);
3497    }
3498
3499    // NOTE: Unlike the task table (where all required columns are TEXT and NULL
3500    // propagates a ColumnNotFound error), the task_runs table has INTEGER and REAL
3501    // columns (id, task_id, attempt, started_at). SQLite coerces NULL/INTEGER to
3502    // String when try_get::<String> is called, so we cannot use the missing-column
3503    // pattern here. Instead, we use BLOB injection to trigger actual decode errors
3504    // that propagate through map_err.
3505    macro_rules! run_blob_decode_test {
3506        ($name:ident, $col:literal, $select:literal) => {
3507            #[tokio::test]
3508            async fn $name() {
3509                let (store, _task_id, run_id) = store_with_run().await;
3510                let sql = format!($select, run_id = run_id);
3511                let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3512                    .fetch_one(store.pool())
3513                    .await
3514                    .unwrap();
3515                let err = TaskStore::row_to_run(&row).unwrap_err();
3516                assert!(
3517                    err.to_string().contains($col),
3518                    "expected '{0}' in error message, got: {err}",
3519                    $col
3520                );
3521            }
3522        };
3523    }
3524
3525    run_blob_decode_test!(
3526        row_to_run_fails_on_decode_error_invalid_utf8_id,
3527        "id",
3528        "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}"
3529    );
3530    run_blob_decode_test!(
3531        row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3532        "task_id",
3533        "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}"
3534    );
3535    run_blob_decode_test!(
3536        row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3537        "started_at",
3538        "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}"
3539    );
3540
3541    // ── row_to_activity decode-error tests ───────────────────────────────────
3542
3543    async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
3544        let store = crate::store::TaskStore::open_memory().await.unwrap();
3545        let task_id = store
3546            .create(&NewTask {
3547                external_id: None,
3548                repo: "owner/repo".to_string(),
3549                origin: "internal".to_string(),
3550                title: "Test task".to_string(),
3551                body: "Test body".to_string(),
3552                source: "cron".to_string(),
3553                source_id: "test-1".to_string(),
3554                author: "tester".to_string(),
3555                url: "https://example.com/1".to_string(),
3556                labels: vec![],
3557                parent_id: None,
3558            })
3559            .await
3560            .unwrap();
3561        store
3562            .append_activity(task_id, "created", None, Some("new"), None, None, None)
3563            .await
3564            .unwrap();
3565        (store, task_id, 1)
3566    }
3567
3568    // Each test drops exactly one column from the full TASK_ACTIVITY_COLS so that
3569    // the first missing-column try_get (id) propagates the error.
3570    #[tokio::test]
3571    async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3572        let (store, _task_id, activity_id) = store_with_activity().await;
3573        let sql = format!(
3574            "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3575        );
3576        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3577            .fetch_one(store.pool())
3578            .await
3579            .unwrap();
3580        let err = TaskStore::row_to_activity(&row).unwrap_err();
3581        assert!(
3582            err.to_string().contains("id"),
3583            "expected 'id' in error message, got: {err}"
3584        );
3585    }
3586
3587    #[tokio::test]
3588    async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3589        let (store, _task_id, activity_id) = store_with_activity().await;
3590        let sql = format!(
3591            "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3592        );
3593        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3594            .fetch_one(store.pool())
3595            .await
3596            .unwrap();
3597        let err = TaskStore::row_to_activity(&row).unwrap_err();
3598        assert!(
3599            err.to_string().contains("task_id"),
3600            "expected 'task_id' in error message, got: {err}"
3601        );
3602    }
3603
3604    #[tokio::test]
3605    async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3606        let (store, _task_id, activity_id) = store_with_activity().await;
3607        let sql = format!(
3608            "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3609        );
3610        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3611            .fetch_one(store.pool())
3612            .await
3613            .unwrap();
3614        let err = TaskStore::row_to_activity(&row).unwrap_err();
3615        assert!(
3616            err.to_string().contains("timestamp"),
3617            "expected 'timestamp' in error message, got: {err}"
3618        );
3619    }
3620
3621    #[tokio::test]
3622    async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3623        let (store, _task_id, activity_id) = store_with_activity().await;
3624        let sql = format!(
3625            "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3626        );
3627        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3628            .fetch_one(store.pool())
3629            .await
3630            .unwrap();
3631        let err = TaskStore::row_to_activity(&row).unwrap_err();
3632        assert!(
3633            err.to_string().contains("event_type"),
3634            "expected 'event_type' in error message, got: {err}"
3635        );
3636    }
3637
3638    #[tokio::test]
3639    async fn row_to_activity_fails_on_corrupt_details_json() {
3640        let (store, task_id, _activity_id) = store_with_activity().await;
3641        // Insert a row with invalid JSON in details
3642        sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3643            .bind(task_id)
3644            .bind("test")
3645            .bind("not valid json {{{")
3646            .execute(store.pool())
3647            .await
3648            .unwrap();
3649        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
3650            "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3651        )))
3652        .fetch_one(store.pool())
3653        .await
3654        .unwrap();
3655        let err = TaskStore::row_to_activity(&row).unwrap_err();
3656        assert!(
3657            err.to_string().contains("details")
3658                || err.to_string().contains("json")
3659                || err.to_string().contains("expected"),
3660            "expected 'details', 'json', or 'expected' in error message, got: {err}"
3661        );
3662    }
3663
3664    // ── row_to_run: remaining fields with map_err ──────────────────────────────
3665
3666    run_blob_decode_test!(
3667        row_to_run_fails_on_decode_error_invalid_utf8_model,
3668        "model",
3669        "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}"
3670    );
3671    run_blob_decode_test!(
3672        row_to_run_fails_on_decode_error_invalid_utf8_command,
3673        "command",
3674        "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}"
3675    );
3676    run_blob_decode_test!(
3677        row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3678        "prompt",
3679        "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}"
3680    );
3681    run_blob_decode_test!(
3682        row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3683        "env_vars",
3684        "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}"
3685    );
3686    run_blob_decode_test!(
3687        row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3688        "stdout",
3689        "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}"
3690    );
3691    run_blob_decode_test!(
3692        row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3693        "stderr",
3694        "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}"
3695    );
3696    run_blob_decode_test!(
3697        row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3698        "parsed_response",
3699        "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}"
3700    );
3701    run_blob_decode_test!(
3702        row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3703        "outcome",
3704        "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}"
3705    );
3706    run_blob_decode_test!(
3707        row_to_run_fails_on_decode_error_invalid_utf8_error,
3708        "error",
3709        "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}"
3710    );
3711    run_blob_decode_test!(
3712        row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3713        "input_tokens",
3714        "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}"
3715    );
3716    run_blob_decode_test!(
3717        row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3718        "output_tokens",
3719        "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}"
3720    );
3721    run_blob_decode_test!(
3722        row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3723        "total_cost_usd",
3724        "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}"
3725    );
3726    run_blob_decode_test!(
3727        row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3728        "duration_secs",
3729        "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}"
3730    );
3731    run_blob_decode_test!(
3732        row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3733        "exit_code",
3734        "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}"
3735    );
3736
3737    // ── row_to_activity: nullable columns with map_err ────────────────────────
3738
3739    // from_status, to_status, agent, model are nullable (Option<String>) but their
3740    // decode errors should still propagate via map_err, not be masked as None.
3741    // Each test injects BLOB into exactly one column and NULL into the rest so
3742    // the error always references the intended column (the first failing map_err).
3743    #[tokio::test]
3744    async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3745        let (store, _task_id, activity_id) = store_with_activity().await;
3746        let sql = format!(
3747            "SELECT id, task_id, timestamp, event_type, \
3748             X'DeadbEEF' as from_status, NULL as to_status, \
3749             NULL as agent, NULL as model, details \
3750             FROM task_activity WHERE id = {activity_id}"
3751        );
3752        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3753            .fetch_one(store.pool())
3754            .await
3755            .unwrap();
3756        let err = TaskStore::row_to_activity(&row).unwrap_err();
3757        assert!(
3758            err.to_string().contains("from_status"),
3759            "expected 'from_status' in error message, got: {err}"
3760        );
3761    }
3762
3763    #[tokio::test]
3764    async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3765        let (store, _task_id, activity_id) = store_with_activity().await;
3766        let sql = format!(
3767            "SELECT id, task_id, timestamp, event_type, \
3768             NULL as from_status, X'DeadbEEF' as to_status, \
3769             NULL as agent, NULL as model, details \
3770             FROM task_activity WHERE id = {activity_id}"
3771        );
3772        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3773            .fetch_one(store.pool())
3774            .await
3775            .unwrap();
3776        let err = TaskStore::row_to_activity(&row).unwrap_err();
3777        assert!(
3778            err.to_string().contains("to_status"),
3779            "expected 'to_status' in error message, got: {err}"
3780        );
3781    }
3782
3783    #[tokio::test]
3784    async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3785        let (store, _task_id, activity_id) = store_with_activity().await;
3786        let sql = format!(
3787            "SELECT id, task_id, timestamp, event_type, \
3788             NULL as from_status, NULL as to_status, \
3789             X'DeadbEEF' as agent, NULL as model, details \
3790             FROM task_activity WHERE id = {activity_id}"
3791        );
3792        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3793            .fetch_one(store.pool())
3794            .await
3795            .unwrap();
3796        let err = TaskStore::row_to_activity(&row).unwrap_err();
3797        assert!(
3798            err.to_string().contains("agent"),
3799            "expected 'agent' in error message, got: {err}"
3800        );
3801    }
3802
3803    #[tokio::test]
3804    async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3805        let (store, _task_id, activity_id) = store_with_activity().await;
3806        let sql = format!(
3807            "SELECT id, task_id, timestamp, event_type, \
3808             NULL as from_status, NULL as to_status, \
3809             NULL as agent, X'DeadbEEF' as model, details \
3810             FROM task_activity WHERE id = {activity_id}"
3811        );
3812        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3813            .fetch_one(store.pool())
3814            .await
3815            .unwrap();
3816        let err = TaskStore::row_to_activity(&row).unwrap_err();
3817        assert!(
3818            err.to_string().contains("model"),
3819            "expected 'model' in error message, got: {err}"
3820        );
3821    }
3822
3823    // ── get_statuses_by_external_ids: decode-failure rows are skipped ───────────
3824
3825    #[tokio::test]
3826    async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3827        // Insert a task row with a BLOB value in the external_id column (valid
3828        // SQLite but not valid UTF-8 TEXT), so row.try_get("external_id") fails
3829        // with a decode error. The method should skip this row (not panic) and
3830        // continue processing the remaining rows.
3831        let store = crate::store::TaskStore::open_memory().await.unwrap();
3832        sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3833            .execute(store.pool())
3834            .await
3835            .unwrap();
3836        // A second row with a valid external_id should be returned.
3837        sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3838            .execute(store.pool())
3839            .await
3840            .unwrap();
3841
3842        let result = store
3843            .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3844            .await
3845            .unwrap();
3846        assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3847        assert_eq!(
3848            result.get("valid-id"),
3849            Some(&crate::store::TaskStatus::Done)
3850        );
3851    }
3852}