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