Skip to main content

orch/store/
tasks.rs

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