Skip to main content

orch/store/
tasks.rs

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