Skip to main content

orch/store/
tasks.rs

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