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