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