Skip to main content

orch/store/
tasks.rs

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