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