Skip to main content

orch/store/
tasks.rs

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