Skip to main content

orch/store/
tasks.rs

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