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