1use super::*;
2use crate::cluster::Command;
3use crate::security;
4use serde::{Deserialize, Serialize};
5use sqlx::Row;
6
7pub(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
28pub(crate) const TASK_COLS_COUNT: usize = 61;
30
31const 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
37const TASK_ACTIVITY_COLS: &str =
39 "id, task_id, timestamp, event_type, from_status, to_status, agent, model, details";
40
41const 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
94const 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#[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 pub agent: Option<String>,
214 pub model: Option<String>,
215 pub complexity: String,
216 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 pub attempts: i32,
225 pub branch: String,
226 pub worktree: String,
227 pub worktree_cleaned: bool,
228 #[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 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 pub needs_review_refires: i32,
253
254 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 pub model_reroute_chain: String,
263 pub limit_reroute_chain: String,
264
265 pub memory: Vec<MemoryEntry>,
267 pub delegations: Vec<serde_json::Value>,
268
269 pub auto_unblock_count: i32,
271 pub auto_unblock_last_at: String,
272 pub auto_unblock_last_reason: String,
273
274 pub ci_recovery_count: i32,
276
277 pub no_code_reroutes: i32,
279 pub no_code_last_agent: String,
281
282 pub created_at: String,
284 pub updated_at: String,
285}
286
287#[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 pub parent_id: Option<i64>,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum CliTaskSpec {
307 Internal(i64),
309 Issue { repo: String, number: i64 },
311 Bare(i64),
313}
314
315#[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
392pub fn parse_cli_task_spec(raw: &str) -> anyhow::Result<CliTaskSpec> {
394 let spec = raw.trim();
395 if spec.is_empty() {
396 anyhow::bail!("empty task id");
397 }
398 if let Some(suffix) = spec.strip_prefix("internal:") {
399 let number: i64 = suffix
400 .parse()
401 .map_err(|_| anyhow::anyhow!("internal task id '{suffix}' is not numeric"))?;
402 if number <= 0 {
403 anyhow::bail!("internal task id must be a positive number");
404 }
405 return Ok(CliTaskSpec::Internal(number));
406 }
407 if let Some((repo, number)) = parse_repo_issue(spec) {
408 return Ok(CliTaskSpec::Issue { repo, number });
409 }
410 let bare = spec.strip_prefix('#').unwrap_or(spec);
411 if let Ok(number) = bare.parse::<i64>() {
412 if number > 0 {
413 return Ok(CliTaskSpec::Bare(number));
414 }
415 }
416 anyhow::bail!("invalid task id '{spec}': use a number, owner/repo#N, or internal:N")
417}
418
419fn parse_repo_issue(spec: &str) -> Option<(String, i64)> {
420 let (repo, number) = spec.rsplit_once('#')?;
421 if repo.is_empty() {
422 return None;
423 }
424 let number: i64 = number.parse().ok()?;
425 if number <= 0 {
426 return None;
427 }
428 let mut parts = repo.split('/');
429 let owner = parts.next()?;
430 let name = parts.next()?;
431 if parts.next().is_some() || owner.is_empty() || name.is_empty() {
432 return None;
433 }
434 let ok = |part: &str| {
435 part.chars()
436 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
437 };
438 if !ok(owner) || !ok(name) {
439 return None;
440 }
441 Some((repo.to_string(), number))
442}
443
444fn ambiguous_cli_id_error(spec: &str, matches: &[CliTaskRef]) -> anyhow::Error {
445 let mut lines = vec![format!("task ID {spec} matches more than one task:")];
446 for task in matches {
447 lines.push(task.format_match());
448 }
449 lines.push("Use owner/repo#N for an issue, or internal:N for an internal task.".to_string());
450 anyhow::anyhow!(lines.join("\n"))
451}
452
453fn dedup_cli_refs(rows: Vec<sqlx::sqlite::SqliteRow>) -> anyhow::Result<Vec<CliTaskRef>> {
454 let mut seen = Vec::new();
455 let mut out = Vec::new();
456 for row in rows {
457 let task = CliTaskRef::from_row(&row)?;
458 if let Some(id) = task.store_id {
459 if seen.contains(&id) {
460 continue;
461 }
462 seen.push(id);
463 }
464 out.push(task);
465 }
466 Ok(out)
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct TaskRun {
472 pub id: i64,
473 pub task_id: i64,
474 pub attempt: i32,
475 pub run_type: String,
476 pub agent: String,
477 pub model: String,
478 pub command: String,
479 pub prompt: String,
480 pub env_vars: String,
481 pub exit_code: Option<i32>,
482 pub stdout: String,
483 pub stderr: String,
484 pub parsed_response: String,
485 pub outcome: String,
486 pub error: String,
487 pub input_tokens: i64,
488 pub output_tokens: i64,
489 pub total_cost_usd: f64,
490 pub duration_secs: f64,
491 pub started_at: String,
492 pub completed_at: Option<String>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct TaskActivity {
498 pub id: i64,
499 pub task_id: i64,
500 pub timestamp: String,
501 pub event_type: String,
502 pub from_status: Option<String>,
503 pub to_status: Option<String>,
504 pub agent: Option<String>,
505 pub model: Option<String>,
506 pub details: serde_json::Value,
507}
508
509#[derive(Debug, Clone, Copy, Default)]
511pub struct RunTokenUsage {
512 pub input_tokens: u64,
513 pub output_tokens: u64,
514 pub total_cost_usd: f64,
515 pub duration_secs: f64,
516}
517
518#[derive(Debug, Clone)]
520pub struct StartRun<'a> {
521 pub task_id: i64,
522 pub attempt: i32,
523 pub run_type: &'a str,
524 pub agent: &'a str,
525 pub model: &'a str,
526 pub command: &'a str,
527 pub prompt: &'a str,
528}
529
530#[derive(Debug, Clone)]
532pub struct CompleteRun<'a> {
533 pub run_id: i64,
534 pub exit_code: Option<i32>,
535 pub stdout: &'a str,
536 pub stderr: &'a str,
537 pub parsed: &'a str,
538 pub outcome: &'a str,
539 pub error: &'a str,
540 pub tokens: RunTokenUsage,
541}
542
543#[derive(Debug, Clone)]
545pub struct StoreRoute<'a> {
546 pub id: i64,
547 pub agent: &'a str,
548 pub model: Option<&'a str>,
549 pub complexity: &'a str,
550 pub estimate: u8,
552 pub reason: &'a str,
553 pub profile: &'a str,
554 pub skills: &'a str,
555}
556
557#[derive(Debug, Clone)]
559pub struct UpsertExternal<'a> {
560 pub repo: &'a str,
561 pub ext_id: &'a str,
562 pub title: &'a str,
563 pub body: &'a str,
564 pub author: &'a str,
565 pub url: &'a str,
566 pub labels: &'a [String],
567 pub origin: &'a str,
568}
569
570fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
571 labels
572 .iter()
573 .find_map(|label| label.strip_prefix("parent:"))
574 .filter(|parent| !parent.is_empty())
575}
576
577fn owned_updates(
578 updates: &[(&str, serde_json::Value)],
579) -> anyhow::Result<Vec<(String, serde_json::Value)>> {
580 updates
581 .iter()
582 .map(|(col, val)| {
583 anyhow::ensure!(
584 ALLOWED_FIELDS.contains(col),
585 "column {col} is not in the update allowlist"
586 );
587 Ok((col.to_string(), val.clone()))
588 })
589 .collect()
590}
591
592impl TaskStore {
593 #[allow(clippy::too_many_arguments)]
595 pub async fn append_activity(
596 &self,
597 task_id: i64,
598 event_type: &str,
599 from_status: Option<&str>,
600 to_status: Option<&str>,
601 agent: Option<&str>,
602 model: Option<&str>,
603 details: Option<&serde_json::Value>,
604 ) -> anyhow::Result<()> {
605 let details_json = match details {
606 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
607 None => "{}".to_string(),
608 };
609 sqlx::query(
610 "INSERT INTO task_activity
611 (task_id, event_type, from_status, to_status, agent, model, details)
612 VALUES (?, ?, ?, ?, ?, ?, ?)",
613 )
614 .bind(task_id)
615 .bind(event_type)
616 .bind(from_status)
617 .bind(to_status)
618 .bind(agent)
619 .bind(model)
620 .bind(details_json)
621 .execute(&self.pool)
622 .await?;
623 Ok(())
624 }
625
626 #[doc(hidden)]
628 #[allow(clippy::too_many_arguments)]
629 pub async fn append_activity_tx(
630 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
631 task_id: i64,
632 event_type: &str,
633 from_status: Option<&str>,
634 to_status: Option<&str>,
635 agent: Option<&str>,
636 model: Option<&str>,
637 details: Option<&serde_json::Value>,
638 ) -> anyhow::Result<()> {
639 let details_json = match details {
640 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
641 None => "{}".to_string(),
642 };
643 sqlx::query(
644 "INSERT INTO task_activity
645 (task_id, event_type, from_status, to_status, agent, model, details)
646 VALUES (?, ?, ?, ?, ?, ?, ?)",
647 )
648 .bind(task_id)
649 .bind(event_type)
650 .bind(from_status)
651 .bind(to_status)
652 .bind(agent)
653 .bind(model)
654 .bind(details_json)
655 .execute(&mut **tx)
656 .await?;
657 Ok(())
658 }
659
660 pub async fn in_review_entry(&self, task_id: i64) -> anyhow::Result<Option<(String, bool)>> {
662 let row: Option<(String, bool)> = sqlx::query_as(
663 "SELECT a.timestamp, EXISTS (
664 SELECT 1 FROM task_activity d
665 WHERE d.task_id = a.task_id AND d.event_type = 'review_decision' AND d.id > a.id
666 )
667 FROM task_activity a
668 WHERE a.task_id = ? AND a.event_type = 'status_change' AND a.to_status = 'in_review'
669 ORDER BY a.id DESC LIMIT 1",
670 )
671 .bind(task_id)
672 .fetch_optional(&self.pool)
673 .await?;
674 Ok(row)
675 }
676
677 pub async fn get_activity(
679 &self,
680 task_id: i64,
681 limit: Option<usize>,
682 ) -> anyhow::Result<Vec<TaskActivity>> {
683 let rows = if let Some(limit) = limit {
684 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
685 sqlx::query(&format!(
686 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
687 ))
688 .bind(task_id)
689 .bind(limit_i64)
690 .fetch_all(&self.pool)
691 .await?
692 } else {
693 sqlx::query(&format!(
694 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
695 ))
696 .bind(task_id)
697 .fetch_all(&self.pool)
698 .await?
699 };
700
701 rows.iter().map(Self::row_to_activity).collect()
702 }
703
704 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
705 self.write_int(Command::TaskCreate {
706 new: new.clone(),
707 internal: false,
708 })
709 .await
710 }
711
712 pub async fn create_internal(
717 &self,
718 repo: &str,
719 title: &str,
720 body: &str,
721 source: &str,
722 source_id: &str,
723 parent_id: Option<i64>,
724 ) -> anyhow::Result<i64> {
725 let new = NewTask {
726 external_id: None,
727 repo: repo.to_string(),
728 origin: "internal".to_string(),
729 title: title.to_string(),
730 body: body.to_string(),
731 source: source.to_string(),
732 source_id: source_id.to_string(),
733 author: String::new(),
734 url: String::new(),
735 labels: vec![],
736 parent_id,
737 };
738 self.write_int(Command::TaskCreate {
739 new,
740 internal: true,
741 })
742 .await
743 }
744
745 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
747 let row = sqlx::query(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?"))
748 .bind(id)
749 .fetch_optional(&self.pool)
750 .await?
751 .with_context(|| format!("task {id} not found"))?;
752
753 Self::row_to_task(&row)
754 }
755
756 pub async fn get_by_external_id(
758 &self,
759 repo: &str,
760 ext_id: &str,
761 ) -> anyhow::Result<Option<Task>> {
762 let row = sqlx::query(&format!(
763 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
764 ))
765 .bind(repo)
766 .bind(ext_id)
767 .fetch_optional(&self.pool)
768 .await?;
769
770 match row {
771 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
772 None => Ok(None),
773 }
774 }
775
776 pub async fn resolve_id_by_external(
782 &self,
783 repo: &str,
784 ext_id: &str,
785 ) -> anyhow::Result<Option<i64>> {
786 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
787 .bind(repo)
788 .bind(ext_id)
789 .fetch_optional(&self.pool)
790 .await?;
791 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
792 Ok(id)
793 }
794
795 pub async fn resolve_id_by_worktree(&self, path: &str) -> anyhow::Result<Option<i64>> {
797 if path.is_empty() {
798 return Ok(None);
799 }
800
801 let row = sqlx::query("SELECT id FROM tasks WHERE worktree = ? LIMIT 1")
802 .bind(path)
803 .fetch_optional(&self.pool)
804 .await?;
805 Ok(row.map(|r| r.try_get::<i64, _>("id")).transpose()?)
806 }
807
808 pub async fn resolve_id_by_pr_number(
814 &self,
815 repo: &str,
816 pr_number: i32,
817 ) -> anyhow::Result<Option<i64>> {
818 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
819 .bind(repo)
820 .bind(pr_number)
821 .fetch_optional(&self.pool)
822 .await?;
823 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
824 Ok(id)
825 }
826
827 pub async fn get_agent_by_external_id(
830 &self,
831 repo: &str,
832 ext_id: &str,
833 ) -> anyhow::Result<Option<String>> {
834 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
835 .bind(repo)
836 .bind(ext_id)
837 .fetch_optional(&self.pool)
838 .await?;
839 let agent = row
840 .map(|r| r.try_get::<Option<String>, _>("agent"))
841 .transpose()?
842 .flatten();
843 Ok(agent)
844 }
845
846 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
848 let parent_id = match parent_external_id_from_labels(ext.labels) {
849 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
850 None => None,
851 };
852 self.write_int(Command::TaskUpsertExternal(UpsertExternalCmd {
853 repo: ext.repo.to_string(),
854 ext_id: ext.ext_id.to_string(),
855 title: ext.title.to_string(),
856 body: ext.body.to_string(),
857 author: ext.author.to_string(),
858 url: ext.url.to_string(),
859 labels: ext.labels.to_vec(),
860 origin: ext.origin.to_string(),
861 parent_id,
862 }))
863 .await
864 }
865
866 pub async fn update_status_and_fields(
871 &self,
872 id: i64,
873 status: TaskStatus,
874 updates: &[(&str, serde_json::Value)],
875 ) -> anyhow::Result<()> {
876 let updates = owned_updates(updates)?;
877 self.write(Command::TaskUpdateStatusAndFields {
878 id,
879 status,
880 updates,
881 })
882 .await
883 .map(|_| ())
884 }
885
886 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
888 self.write_bool(Command::TaskUpdateStatus {
889 id,
890 status,
891 expected: None,
892 })
893 .await
894 .map(|_| ())
895 }
896
897 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
899 self.write(Command::TaskUpdateExternalId {
900 id,
901 external_id: external_id.to_string(),
902 })
903 .await
904 .map(|_| ())
905 }
906
907 pub async fn update_status_if(
912 &self,
913 id: i64,
914 status: TaskStatus,
915 expected: TaskStatus,
916 ) -> anyhow::Result<bool> {
917 self.write_bool(Command::TaskUpdateStatus {
918 id,
919 status,
920 expected: Some(expected),
921 })
922 .await
923 }
924
925 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
933 self.write(Command::TaskTouch { id }).await.map(|_| ())
934 }
935
936 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
938 self.write(Command::TaskResetToNew { id }).await.map(|_| ())
939 }
940
941 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
943 let value = reason
944 .map(|r| serde_json::Value::String(r.to_string()))
945 .unwrap_or(serde_json::Value::Null);
946 self.set_fields(id, &[("block_reason", value)]).await
947 }
948
949 pub async fn list_by_status(
951 &self,
952 repo: &str,
953 status: TaskStatus,
954 ) -> anyhow::Result<Vec<Task>> {
955 let rows = sqlx::query(&format!(
956 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
957 ))
958 .bind(repo)
959 .bind(status.as_str())
960 .fetch_all(&self.pool)
961 .await?;
962
963 rows.iter().map(Self::row_to_task).collect()
964 }
965
966 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
968 let rows = sqlx::query(&format!(
969 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
970 ))
971 .bind(repo)
972 .bind(parent_id)
973 .fetch_all(&self.pool)
974 .await?;
975
976 rows.iter().map(Self::row_to_task).collect()
977 }
978
979 pub async fn list_external_by_status(
981 &self,
982 repo: &str,
983 status: TaskStatus,
984 ) -> anyhow::Result<Vec<Task>> {
985 let rows = sqlx::query(
986 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC"),
987 )
988 .bind(repo)
989 .bind(status.as_str())
990 .fetch_all(&self.pool)
991 .await?;
992
993 rows.iter().map(Self::row_to_task).collect()
994 }
995
996 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
998 self.list_by_status(repo, TaskStatus::New).await
999 }
1000
1001 pub async fn list_internal_by_status(
1003 &self,
1004 repo: &str,
1005 status: TaskStatus,
1006 ) -> anyhow::Result<Vec<Task>> {
1007 let rows = sqlx::query(
1008 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC"),
1009 )
1010 .bind(repo)
1011 .bind(status.as_str())
1012 .fetch_all(&self.pool)
1013 .await?;
1014
1015 rows.iter().map(Self::row_to_task).collect()
1016 }
1017
1018 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1020 let rows = sqlx::query(
1021 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC"),
1022 )
1023 .bind(repo)
1024 .fetch_all(&self.pool)
1025 .await?;
1026
1027 rows.iter().map(Self::row_to_task).collect()
1028 }
1029
1030 pub async fn list_internal_by_source(
1033 &self,
1034 repo: &str,
1035 source: &str,
1036 ) -> anyhow::Result<Vec<Task>> {
1037 let rows = sqlx::query(
1038 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC"),
1039 )
1040 .bind(repo)
1041 .bind(source)
1042 .fetch_all(&self.pool)
1043 .await?;
1044
1045 rows.iter().map(Self::row_to_task).collect()
1046 }
1047
1048 pub async fn list_source_ids_by_source(
1053 &self,
1054 repo: &str,
1055 source: &str,
1056 since_days: i64,
1057 ) -> anyhow::Result<Vec<String>> {
1058 let cutoff = format!("-{since_days} days");
1059 let rows = sqlx::query(
1060 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1061 )
1062 .bind(repo)
1063 .bind(source)
1064 .bind(&cutoff)
1065 .fetch_all(&self.pool)
1066 .await?;
1067
1068 let mut ids = Vec::with_capacity(rows.len());
1069 for row in &rows {
1070 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1071 anyhow::anyhow!(
1072 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1073 )
1074 })?);
1075 }
1076 Ok(ids)
1077 }
1078
1079 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1081 let rows = sqlx::query(
1082 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC"),
1083 )
1084 .bind(repo)
1085 .fetch_all(&self.pool)
1086 .await?;
1087
1088 rows.iter().map(Self::row_to_task).collect()
1089 }
1090
1091 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1093 let rows = sqlx::query(
1094 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC"),
1095 )
1096 .bind(repo)
1097 .fetch_all(&self.pool)
1098 .await?;
1099
1100 rows.iter().map(Self::row_to_task).collect()
1101 }
1102
1103 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1105 let has_external = sqlx::query_scalar::<_, i32>(
1106 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1107 )
1108 .bind(repo)
1109 .fetch_optional(&self.pool)
1110 .await?
1111 .is_some();
1112 Ok(has_external)
1113 }
1114
1115 pub async fn existing_external_ids(
1119 &self,
1120 repo: &str,
1121 ) -> anyhow::Result<std::collections::HashSet<String>> {
1122 let rows: Vec<(String,)> = sqlx::query_as(
1123 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1124 )
1125 .bind(repo)
1126 .fetch_all(&self.pool)
1127 .await?;
1128
1129 Ok(rows
1130 .into_iter()
1131 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1132 .collect())
1133 }
1134
1135 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1138 let rows = sqlx::query(
1139 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC"),
1140 )
1141 .bind(repo)
1142 .bind(cutoff_str)
1143 .fetch_all(&self.pool)
1144 .await?;
1145 rows.iter().map(Self::row_to_task).collect()
1146 }
1147
1148 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1150 let rows = sqlx::query(&format!(
1151 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1152 ))
1153 .bind(repo)
1154 .fetch_all(&self.pool)
1155 .await?;
1156 rows.iter().map(Self::row_to_task).collect()
1157 }
1158
1159 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1161 let rows =
1162 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1163 .fetch_all(&self.pool)
1164 .await?;
1165 let mut repos = Vec::new();
1166 for row in &rows {
1167 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1168 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1169 })?);
1170 }
1171 Ok(repos)
1172 }
1173
1174 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1179 let rows = sqlx::query(&format!(
1180 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1181 ))
1182 .fetch_all(&self.pool)
1183 .await?;
1184 rows.iter().map(Self::row_to_task).collect()
1185 }
1186
1187 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1191 let rows = sqlx::query(&format!(
1192 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1193 ))
1194 .fetch_all(&self.pool)
1195 .await?;
1196 rows.iter().map(Self::row_to_task).collect()
1197 }
1198
1199 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1203 let rows = sqlx::query(&format!(
1204 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1205 ))
1206 .bind(status.as_str())
1207 .fetch_all(&self.pool)
1208 .await?;
1209 rows.iter().map(Self::row_to_task).collect()
1210 }
1211
1212 pub async fn set_fields(
1215 &self,
1216 id: i64,
1217 updates: &[(&str, serde_json::Value)],
1218 ) -> anyhow::Result<()> {
1219 self.set_fields_split(id, updates, false).await
1220 }
1221
1222 pub async fn set_fields_silent(
1227 &self,
1228 id: i64,
1229 updates: &[(&str, serde_json::Value)],
1230 ) -> anyhow::Result<()> {
1231 self.set_fields_split(id, updates, true).await
1232 }
1233
1234 async fn set_fields_split(
1236 &self,
1237 id: i64,
1238 updates: &[(&str, serde_json::Value)],
1239 silent: bool,
1240 ) -> anyhow::Result<()> {
1241 let all = owned_updates(updates)?;
1242 if !self.replicates() {
1244 let mut tx = self.pool.begin().await?;
1245 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &all, silent).await?;
1246 tx.commit().await?;
1247 return Ok(());
1248 }
1249
1250 let (local, replicated): (Vec<_>, Vec<_>) =
1251 all.into_iter().partition(|(col, _)| is_local_column(col));
1252 if !local.is_empty() {
1253 let mut tx = self.pool.begin().await?;
1254 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &local, silent).await?;
1255 tx.commit().await?;
1256 }
1257
1258 if replicated.is_empty() {
1259 return Ok(());
1260 }
1261
1262 self.write(Command::TaskSetFields {
1263 id,
1264 updates: replicated,
1265 silent,
1266 })
1267 .await
1268 .map(|_| ())
1269 }
1270
1271 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1273 anyhow::ensure!(
1274 INCREMENTABLE_FIELDS.contains(&field),
1275 "field {field} is not incrementable"
1276 );
1277 let n = self
1278 .write_int(Command::TaskIncrement {
1279 id,
1280 field: field.to_string(),
1281 })
1282 .await?;
1283 Ok(i32::try_from(n)?)
1284 }
1285
1286 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1288 self.write(Command::TaskResetCounters {
1289 id,
1290 failure_only: false,
1291 })
1292 .await
1293 .map(|_| ())
1294 }
1295
1296 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1311 self.write(Command::TaskResetCounters {
1312 id,
1313 failure_only: true,
1314 })
1315 .await
1316 .map(|_| ())
1317 }
1318
1319 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1325 self.write(Command::TaskStoreRoute(StoreRouteCmd {
1326 id: route.id,
1327 agent: route.agent.to_string(),
1328 model: route.model.map(str::to_string),
1329 complexity: route.complexity.to_string(),
1330 estimate: route.estimate,
1331 reason: route.reason.to_string(),
1332 profile: route.profile.to_string(),
1333 skills: route.skills.to_string(),
1334 }))
1335 .await
1336 .map(|_| ())
1337 }
1338
1339 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1347 let rows = sqlx::query(
1348 "SELECT external_id, estimate FROM tasks \
1349 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1350 AND status NOT IN ('done', 'blocked')",
1351 )
1352 .fetch_all(&self.pool)
1353 .await?;
1354
1355 let mut result = Vec::new();
1356 for row in &rows {
1357 let external_id: String = row.try_get("external_id")?;
1358 let estimate_raw: i64 = row.try_get("estimate")?;
1359 if (1..=255).contains(&estimate_raw) {
1360 result.push((external_id, estimate_raw as u8));
1361 }
1362 }
1363 Ok(result)
1364 }
1365
1366 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1372 self.write_bool(Command::TaskSetEstimateIfZero { id, estimate })
1373 .await
1374 }
1375
1376 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1385 self.write(Command::TaskAppendMemory {
1386 id,
1387 entry: entry.clone(),
1388 })
1389 .await
1390 .map(|_| ())
1391 }
1392
1393 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1395 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1396 .bind(id)
1397 .fetch_one(&self.pool)
1398 .await?;
1399
1400 let memory_opt: Option<String> = row
1401 .try_get::<Option<String>, _>("memory")
1402 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1403 let memory_str = memory_opt.unwrap_or_default();
1404
1405 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1406 Vec::new()
1407 } else {
1408 serde_json::from_str(&memory_str)
1409 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1410 };
1411
1412 memory.sort_by_key(|m| m.attempt);
1413 if memory.len() > max {
1414 memory = memory.split_off(memory.len() - max);
1415 }
1416 Ok(memory)
1417 }
1418
1419 pub async fn store_tokens(
1425 &self,
1426 id: i64,
1427 input: u64,
1428 output: u64,
1429 model: &str,
1430 ) -> anyhow::Result<()> {
1431 let pricing = pricing_for_model(model);
1432 let usage = TokenUsage {
1433 input_tokens: input,
1434 output_tokens: output,
1435 };
1436 let cost = pricing.estimate_cost_usd(usage);
1437
1438 sqlx::query(
1439 "UPDATE tasks SET
1440 input_tokens = COALESCE(input_tokens, 0) + ?,
1441 output_tokens = COALESCE(output_tokens, 0) + ?,
1442 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1443 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1444 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1445 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1446 WHERE id = ?",
1447 )
1448 .bind(i64::try_from(input).unwrap_or_else(|_| {
1449 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1450 i64::MAX
1451 }))
1452 .bind(i64::try_from(output).unwrap_or_else(|_| {
1453 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1454 i64::MAX
1455 }))
1456 .bind(cost.input_cost_usd)
1457 .bind(cost.output_cost_usd)
1458 .bind(cost.total_cost_usd)
1459 .bind(id)
1460 .execute(&self.pool)
1461 .await?;
1462
1463 Ok(())
1464 }
1465
1466 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1472 self.write(Command::TaskMarkCleaned { id })
1473 .await
1474 .map(|_| ())
1475 }
1476
1477 #[cfg(test)]
1483 pub async fn batch_set_fields(
1484 &self,
1485 updates: &[(i64, &[(&str, serde_json::Value)])],
1486 ) -> anyhow::Result<()> {
1487 if updates.is_empty() {
1488 return Ok(());
1489 }
1490
1491 for (_, entry_updates) in updates {
1492 for (col, _) in *entry_updates {
1493 anyhow::ensure!(
1494 ALLOWED_FIELDS.contains(col),
1495 "column {col} is not in the update allowlist"
1496 );
1497 }
1498 }
1499
1500 let mut tx = self.pool.begin().await?;
1501 for (id, entry_updates) in updates {
1502 if entry_updates.is_empty() {
1503 continue;
1504 }
1505 let mut set_parts = Vec::new();
1506 let mut values: Vec<Option<String>> = Vec::new();
1507 for (col, val) in *entry_updates {
1508 set_parts.push(format!("{col} = ?"));
1509 values.push(sql_value_for_task_field(col, val)?);
1510 }
1511 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1512 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1513 let mut query = sqlx::query(&sql);
1514 for v in &values {
1515 query = query.bind(v.as_deref());
1516 }
1517 query = query.bind(*id);
1518 query.execute(&mut *tx).await?;
1519 }
1520 tx.commit().await?;
1521 Ok(())
1522 }
1523
1524 #[cfg(test)]
1529 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1530 if entries.is_empty() {
1531 return Ok(());
1532 }
1533
1534 for (_, field) in entries {
1535 anyhow::ensure!(
1536 INCREMENTABLE_FIELDS.contains(field),
1537 "field {field} is not incrementable"
1538 );
1539 }
1540
1541 let mut tx = self.pool.begin().await?;
1542 for (id, field) in entries {
1543 let sql = format!(
1544 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1545 );
1546 sqlx::query(&sql).bind(*id).execute(&mut *tx).await?;
1547 }
1548 tx.commit().await?;
1549 Ok(())
1550 }
1551
1552 #[cfg(test)]
1559 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1560 if ids.is_empty() {
1561 return Ok(());
1562 }
1563 const CHUNK_SIZE: usize = 500;
1566
1567 let mut tx = self.pool.begin().await?;
1568
1569 for chunk in ids.chunks(CHUNK_SIZE) {
1570 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1572 let sql = format!(
1573 "UPDATE tasks SET
1574 route_attempts = 0,
1575 review_agent_failures = 0,
1576 review_invocations = 0,
1577 pr_create_failures = 0,
1578 push_failures = 0,
1579 network_retries = 0,
1580 review_session_expected = 0,
1581 auto_unblock_count = 0,
1582 auto_unblock_last_at = '',
1583 auto_unblock_last_reason = '',
1584 ci_recovery_count = 0,
1585 no_code_reroutes = 0,
1586 needs_review_refires = 0,
1587 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1588 WHERE id IN ({placeholders})",
1589 );
1590 let mut query = sqlx::query(&sql);
1591 for id in chunk {
1592 query = query.bind(*id);
1593 }
1594 query.execute(&mut *tx).await?;
1595 }
1596
1597 tx.commit().await?;
1598 Ok(())
1599 }
1600
1601 #[cfg(test)]
1606 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1607 if ids.is_empty() {
1608 return Ok(());
1609 }
1610
1611 const CHUNK_SIZE: usize = 500;
1613
1614 let mut tx = self.pool.begin().await?;
1615
1616 for chunk in ids.chunks(CHUNK_SIZE) {
1617 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1618 let sql = format!(
1619 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1620 );
1621 let mut query = sqlx::query(&sql);
1622 for id in chunk {
1623 query = query.bind(*id);
1624 }
1625 query.execute(&mut *tx).await?;
1626 }
1627
1628 let details = serde_json::json!({ "worktree_cleaned": true });
1632 let details_json = serde_json::to_string(&details)?;
1633 for id in ids {
1634 sqlx::query(
1635 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1636 )
1637 .bind(*id)
1638 .bind("branch_delete")
1639 .bind(None::<&str>)
1640 .bind(None::<&str>)
1641 .bind(None::<&str>)
1642 .bind(None::<&str>)
1643 .bind(details_json.as_str())
1644 .execute(&mut *tx)
1645 .await?;
1646 }
1647
1648 tx.commit().await?;
1649 Ok(())
1650 }
1651
1652 #[cfg(test)]
1655 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1656 let rows = sqlx::query(&format!(
1657 "SELECT {TASK_COLS} FROM tasks
1658 WHERE repo = ?
1659 AND worktree != ''
1660 AND worktree_cleaned = 0
1661 AND status IN ('done', 'blocked')
1662 ORDER BY updated_at ASC"
1663 ))
1664 .bind(repo)
1665 .fetch_all(&self.pool)
1666 .await?;
1667
1668 rows.iter().map(Self::row_to_task).collect()
1669 }
1670
1671 pub async fn cleaned_external_ids(
1676 &self,
1677 repo: &str,
1678 ) -> anyhow::Result<std::collections::HashSet<String>> {
1679 let rows: Vec<(String,)> =
1680 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1681 .bind(repo)
1682 .fetch_all(&self.pool)
1683 .await?;
1684
1685 Ok(rows.into_iter().map(|(id,)| id).collect())
1686 }
1687
1688 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1694 let orphaned: Vec<(i64,)> = sqlx::query_as(
1706 "SELECT id FROM task_runs
1707 WHERE task_id = ? AND run_type = ? AND attempt != ?
1708 AND outcome IS NULL AND completed_at IS NULL",
1709 )
1710 .bind(run.task_id)
1711 .bind(run.run_type)
1712 .bind(run.attempt)
1713 .fetch_all(&self.pool)
1714 .await?;
1715
1716 for (run_id,) in orphaned {
1717 self.complete_run(&CompleteRun {
1718 run_id,
1719 exit_code: Some(-1),
1720 stdout: "",
1721 stderr: "",
1722 parsed: "",
1723 outcome: "aborted",
1724 error: "superseded by a new run start before this one completed",
1725 tokens: RunTokenUsage::default(),
1726 })
1727 .await?;
1728 }
1729
1730 let row = sqlx::query(
1731 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
1732 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
1733 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
1734 agent = excluded.agent, model = excluded.model,
1735 command = excluded.command, prompt = excluded.prompt,
1736 outcome = NULL,
1737 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
1738 completed_at = NULL, error = NULL, exit_code = NULL,
1739 stdout = NULL, stderr = NULL, parsed_response = NULL,
1740 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
1741 total_cost_usd = NULL
1742 RETURNING id",
1743 )
1744 .bind(run.task_id)
1745 .bind(run.attempt)
1746 .bind(run.run_type)
1747 .bind(run.agent)
1748 .bind(run.model)
1749 .bind(run.command)
1750 .bind(run.prompt)
1751 .fetch_one(&self.pool)
1752 .await?;
1753
1754 let run_id: i64 = row
1755 .try_get("id")
1756 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
1757 let event_type = match run.run_type {
1758 "review" => "review_start",
1759 _ => "dispatch",
1760 };
1761 let details = serde_json::json!({
1762 "run_type": run.run_type,
1763 "attempt": run.attempt,
1764 "command": run.command,
1765 });
1766 self.append_activity(
1767 run.task_id,
1768 event_type,
1769 None,
1770 None,
1771 Some(run.agent),
1772 Some(run.model),
1773 Some(&details),
1774 )
1775 .await?;
1776
1777 Ok(run_id)
1778 }
1779
1780 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
1782 let run_ctx =
1783 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
1784 .bind(run.run_id)
1785 .fetch_optional(&self.pool)
1786 .await?;
1787 sqlx::query(
1788 "UPDATE task_runs SET
1789 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
1790 outcome = ?, error = NULLIF(?, ''),
1791 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
1792 duration_secs = ?,
1793 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1794 WHERE id = ?",
1795 )
1796 .bind(run.exit_code)
1797 .bind(run.stdout)
1798 .bind(security::redact(run.stderr))
1799 .bind(run.parsed)
1800 .bind(run.outcome)
1801 .bind(security::redact(run.error))
1802 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1803 tracing::warn!(
1804 run.tokens.input_tokens,
1805 "input_tokens overflows i64, clamping to MAX"
1806 );
1807 i64::MAX
1808 }))
1809 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1810 tracing::warn!(
1811 run.tokens.output_tokens,
1812 "output_tokens overflows i64, clamping to MAX"
1813 );
1814 i64::MAX
1815 }))
1816 .bind(run.tokens.total_cost_usd)
1817 .bind(run.tokens.duration_secs)
1818 .bind(run.run_id)
1819 .execute(&self.pool)
1820 .await?;
1821 if let Some(run_ctx) = run_ctx {
1822 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1823 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1824 })?;
1825 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1826 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1827 })?;
1828 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1829 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1830 })?;
1831 let model: String = run_ctx.try_get("model").map_err(|e| {
1832 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1833 })?;
1834 if run.outcome == "timeout" {
1835 self.append_activity(
1836 task_id,
1837 "timeout",
1838 None,
1839 None,
1840 Some(&agent),
1841 Some(&model),
1842 Some(&serde_json::json!({
1843 "run_type": run_type,
1844 "error": run.error,
1845 "stderr": run.stderr,
1846 })),
1847 )
1848 .await?;
1849 } else if run.outcome != "success" {
1850 self.append_activity(
1851 task_id,
1852 "error",
1853 None,
1854 None,
1855 Some(&agent),
1856 Some(&model),
1857 Some(&serde_json::json!({
1858 "run_type": run_type,
1859 "outcome": run.outcome,
1860 "error": run.error,
1861 })),
1862 )
1863 .await?;
1864 }
1865 } else if run.outcome != "success" {
1866 tracing::warn!(
1867 run_id = run.run_id,
1868 outcome = run.outcome,
1869 "run_id not found in task_runs; skipping timeout/error activity log"
1870 );
1871 }
1872 Ok(())
1873 }
1874
1875 pub async fn finalize_incomplete_runs(
1880 &self,
1881 task_id: i64,
1882 outcome: &str,
1883 error: &str,
1884 ) -> anyhow::Result<u64> {
1885 let rows: Vec<(i64,)> = sqlx::query_as(
1886 "SELECT id FROM task_runs
1887 WHERE task_id = ?
1888 AND outcome IS NULL
1889 AND completed_at IS NULL
1890 ORDER BY id ASC",
1891 )
1892 .bind(task_id)
1893 .fetch_all(&self.pool)
1894 .await?;
1895
1896 for (run_id,) in &rows {
1897 self.complete_run(&CompleteRun {
1898 run_id: *run_id,
1899 exit_code: Some(-1),
1900 stdout: "",
1901 stderr: "",
1902 parsed: "",
1903 outcome,
1904 error,
1905 tokens: RunTokenUsage::default(),
1906 })
1907 .await?;
1908 }
1909
1910 Ok(rows.len() as u64)
1911 }
1912
1913 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
1915 let rows = sqlx::query(&format!(
1916 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
1917 ))
1918 .bind(task_id)
1919 .fetch_all(&self.pool)
1920 .await?;
1921
1922 rows.iter().map(Self::row_to_run).collect()
1923 }
1924
1925 pub async fn get_runs_batch(
1928 &self,
1929 task_ids: &[i64],
1930 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
1931 if task_ids.is_empty() {
1932 return Ok(std::collections::HashMap::new());
1933 }
1934 const CHUNK_SIZE: usize = 500;
1937
1938 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
1939 std::collections::HashMap::new();
1940
1941 for chunk in task_ids.chunks(CHUNK_SIZE) {
1942 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1943 let sql = format!(
1944 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
1945 );
1946 let mut query = sqlx::query(&sql);
1947 for id in chunk {
1948 query = query.bind(*id);
1949 }
1950 let rows = query.fetch_all(&self.pool).await?;
1951 for row in &rows {
1952 let run = Self::row_to_run(row)?;
1953 result.entry(run.task_id).or_default().push(run);
1954 }
1955 }
1956 Ok(result)
1957 }
1958
1959 pub async fn get_batch(
1962 &self,
1963 ids: &[i64],
1964 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
1965 if ids.is_empty() {
1966 return Ok(std::collections::HashMap::new());
1967 }
1968 const CHUNK_SIZE: usize = 500;
1971
1972 let mut result = std::collections::HashMap::new();
1973
1974 for chunk in ids.chunks(CHUNK_SIZE) {
1975 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1976 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
1977 let mut query = sqlx::query(&sql);
1978 for id in chunk {
1979 query = query.bind(*id);
1980 }
1981 let rows = query.fetch_all(&self.pool).await?;
1982 for row in &rows {
1983 let task = Self::row_to_task(row)?;
1984 result.insert(task.id, task);
1985 }
1986 }
1987 Ok(result)
1988 }
1989
1990 pub async fn get_statuses_by_external_ids(
1995 &self,
1996 repo: &str,
1997 ext_ids: &[&str],
1998 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
1999 let mut result = std::collections::HashMap::new();
2000 if ext_ids.is_empty() {
2001 return Ok(result);
2002 }
2003
2004 const CHUNK_SIZE: usize = 500;
2005 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2006 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2007 let sql = format!(
2008 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2009 );
2010 let mut query = sqlx::query(&sql);
2011 query = query.bind(repo);
2012 for id in chunk {
2013 query = query.bind(id);
2014 }
2015 let rows = query.fetch_all(&self.pool).await?;
2016 for row in &rows {
2017 let external_id: Result<String, _> = row.try_get("external_id");
2018 let status_str: Result<String, _> = row.try_get("status");
2019 match (external_id, status_str) {
2020 (Ok(ext_id), Ok(sts)) => {
2021 if ext_id.is_empty() {
2022 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2023 continue;
2024 }
2025 if let Some(status) = TaskStatus::from_str(&sts) {
2026 result.insert(ext_id, status);
2027 } else {
2028 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2029 }
2030 }
2031 (Err(e), _) => {
2032 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2033 }
2034 (_, Err(e)) => {
2035 tracing::warn!(error = %e, "skipping task row with decode error for status");
2036 }
2037 }
2038 }
2039 }
2040
2041 Ok(result)
2042 }
2043
2044 #[cfg(test)]
2047 pub async fn get_last_run(
2048 &self,
2049 task_id: i64,
2050 run_type: &str,
2051 ) -> anyhow::Result<Option<TaskRun>> {
2052 let row = sqlx::query(&format!(
2053 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2054 ))
2055 .bind(task_id)
2056 .bind(run_type)
2057 .fetch_optional(&self.pool)
2058 .await?;
2059
2060 match row {
2061 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2062 None => Ok(None),
2063 }
2064 }
2065
2066 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2070 let rows: Vec<(String,)> = sqlx::query_as(
2071 "SELECT DISTINCT agent FROM task_runs \
2072 WHERE task_id = ? AND run_type = 'review' \
2073 ORDER BY agent",
2074 )
2075 .bind(task_id)
2076 .fetch_all(&self.pool)
2077 .await?;
2078 Ok(rows.into_iter().map(|(a,)| a).collect())
2079 }
2080
2081 #[cfg(test)]
2084 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2085 let result = sqlx::query(
2086 "DELETE FROM task_runs WHERE task_id IN (
2087 SELECT id FROM tasks
2088 WHERE status IN ('done', 'blocked')
2089 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2090 )",
2091 )
2092 .bind(format!("-{days}"))
2093 .execute(&self.pool)
2094 .await?;
2095
2096 Ok(result.rows_affected())
2097 }
2098
2099 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2104 let col_count = row.len();
2107 if col_count != TASK_COLS_COUNT {
2108 tracing::warn!(
2109 expected = TASK_COLS_COUNT,
2110 actual = col_count,
2111 "TASK_COLS column count mismatch - schema may have changed"
2112 );
2113 }
2114
2115 let status_str: String = row
2116 .try_get("status")
2117 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2118 let labels_str: String = row.try_get("labels").unwrap_or_default();
2119 let memory_str: String = row.try_get("memory").unwrap_or_default();
2120 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2121
2122 Ok(Task {
2123 id: row
2124 .try_get("id")
2125 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2126 external_id: row.try_get("external_id").unwrap_or(None),
2127 repo: row
2128 .try_get("repo")
2129 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2130 origin: row
2131 .try_get("origin")
2132 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2133 title: row
2134 .try_get("title")
2135 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2136 body: row
2137 .try_get("body")
2138 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2139 status: TaskStatus::from_str(&status_str)
2140 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2141 source: row
2142 .try_get("source")
2143 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2144 source_id: row
2145 .try_get("source_id")
2146 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2147 author: row
2148 .try_get("author")
2149 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2150 url: row
2151 .try_get("url")
2152 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2153 labels: serde_json::from_str(&labels_str)
2154 .inspect_err(
2155 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2156 )
2157 .unwrap_or_default(),
2158 agent: row.try_get("agent").unwrap_or(None),
2159 model: row.try_get("model").unwrap_or(None),
2160 complexity: row.try_get("complexity").unwrap_or_default(),
2161 estimate: {
2162 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2163 u8::try_from(raw).unwrap_or_else(|_| {
2164 tracing::warn!(
2165 raw_estimate = raw,
2166 "estimate value out of u8 range; defaulting to 0"
2167 );
2168 0
2169 })
2170 },
2171 route_reason: row.try_get("route_reason").unwrap_or_default(),
2172 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2173 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2174 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2175 attempts: row.try_get("attempts").unwrap_or(0),
2176 branch: row.try_get("branch").unwrap_or_default(),
2177 worktree: row.try_get("worktree").unwrap_or_default(),
2178 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2179 owner_node: row.try_get("owner_node").unwrap_or(None),
2180 summary: row.try_get("summary").unwrap_or_default(),
2181 last_error: row.try_get("last_error").unwrap_or_default(),
2182 parent_id: row.try_get("parent_id").unwrap_or(None),
2183 block_reason: row.try_get("block_reason").unwrap_or(None),
2184 pr_number: row.try_get("pr_number").unwrap_or(None),
2185 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2186 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2187 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2188 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2189 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2190 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2191 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2192 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2193 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2194 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2195 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2196 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2197 review_session_expected: row
2198 .try_get::<i32, _>("review_session_expected")
2199 .unwrap_or(0)
2200 != 0,
2201 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2202 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2203 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2204 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2205 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2206 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2207 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2208 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2209 memory: serde_json::from_str(&memory_str)
2210 .inspect_err(
2211 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2212 )
2213 .unwrap_or_default(),
2214 delegations: serde_json::from_str(&delegations_str)
2215 .inspect_err(
2216 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2217 )
2218 .unwrap_or_default(),
2219 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2224 auto_unblock_last_at: row
2225 .try_get::<String, _>("auto_unblock_last_at")
2226 .unwrap_or_default(),
2227 auto_unblock_last_reason: row
2228 .try_get::<String, _>("auto_unblock_last_reason")
2229 .unwrap_or_default(),
2230 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2231 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2232 no_code_last_agent: row
2233 .try_get::<String, _>("no_code_last_agent")
2234 .unwrap_or_default(),
2235 created_at: row
2236 .try_get::<String, _>("created_at")
2237 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2238 updated_at: row
2239 .try_get::<String, _>("updated_at")
2240 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2241 })
2242 }
2243 pub async fn ensure_external_task(
2244 &self,
2245 repo: &str,
2246 ext: &crate::backends::ExternalTask,
2247 ) -> anyhow::Result<i64> {
2248 self.upsert_external(&UpsertExternal {
2249 repo,
2250 ext_id: &ext.id.0,
2251 title: &ext.title,
2252 body: &ext.body,
2253 author: &ext.author,
2254 url: &ext.url,
2255 labels: &ext.labels,
2256 origin: "github",
2257 })
2258 .await
2259 }
2260
2261 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2264 if let Some(suffix) = task_id.strip_prefix("internal:") {
2265 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2267 return Ok(Some(id));
2268 }
2269 if let Ok(id) = suffix.parse::<i64>() {
2273 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2274 .bind(id)
2275 .bind(repo)
2276 .fetch_optional(&self.pool)
2277 .await?;
2278 if exists.is_some() {
2279 return Ok(Some(id));
2280 }
2281 }
2282 return Ok(None);
2283 }
2284 self.resolve_id_by_external(repo, task_id).await
2287 }
2288
2289 pub async fn resolve_task_id_globally(
2295 &self,
2296 task_id: &str,
2297 ) -> anyhow::Result<Option<(i64, String)>> {
2298 if let Some(suffix) = task_id.strip_prefix("internal:") {
2299 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2301 .bind(task_id)
2302 .fetch_optional(&self.pool)
2303 .await?;
2304 if let Some(r) = row {
2305 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2306 }
2307 if let Ok(id) = suffix.parse::<i64>() {
2308 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2309 .bind(id)
2310 .fetch_optional(&self.pool)
2311 .await?;
2312 if let Some(r) = row {
2313 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2314 }
2315 }
2316 return Ok(None);
2317 }
2318 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2320 .bind(task_id)
2321 .fetch_all(&self.pool)
2322 .await?;
2323 match rows.len() {
2324 0 => Ok(None),
2325 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2326 _ => {
2327 let repos: Vec<String> = rows
2328 .iter()
2329 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2330 .collect();
2331 anyhow::bail!(
2332 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2333 Run from the repo worktree so the current-repo lookup resolves it.",
2334 repos.join(", ")
2335 )
2336 }
2337 }
2338 }
2339
2340 pub async fn resolve_cli_id(
2342 &self,
2343 spec: &str,
2344 current_repo: Option<&str>,
2345 ) -> anyhow::Result<Option<CliTaskRef>> {
2346 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2347 match parse_cli_task_spec(spec)? {
2348 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2349 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2350 CliTaskSpec::Bare(number) => {
2351 if let Some(repo) = current_repo {
2352 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2353 return Ok(Some(task));
2354 }
2355 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2356 return Ok(Some(task));
2357 }
2358 return Ok(None);
2359 }
2360 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2361 match matches.len() {
2362 0 => Ok(None),
2363 1 => Ok(matches.pop()),
2364 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2365 }
2366 }
2367 }
2368 }
2369
2370 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2371 let ext = format!("internal:{number}");
2372 let rows = sqlx::query(
2373 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2374 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2375 ORDER BY id",
2376 )
2377 .bind(number)
2378 .bind(&ext)
2379 .fetch_all(&self.pool)
2380 .await?;
2381 let mut matches = dedup_cli_refs(rows)?;
2382 match matches.len() {
2383 0 => Ok(None),
2384 1 => Ok(matches.pop()),
2385 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2386 }
2387 }
2388
2389 async fn lookup_internal_in_repo(
2390 &self,
2391 number: i64,
2392 repo: &str,
2393 ) -> anyhow::Result<Option<CliTaskRef>> {
2394 let row = sqlx::query(
2395 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2396 WHERE origin = 'internal' AND id = ? AND repo = ?",
2397 )
2398 .bind(number)
2399 .bind(repo)
2400 .fetch_optional(&self.pool)
2401 .await?;
2402 match row {
2403 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2404 None => Ok(None),
2405 }
2406 }
2407
2408 async fn lookup_issue_cli_id(
2409 &self,
2410 repo: &str,
2411 number: i64,
2412 ) -> anyhow::Result<Option<CliTaskRef>> {
2413 let ext = number.to_string();
2414 let rows = sqlx::query(
2415 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2416 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2417 ORDER BY id",
2418 )
2419 .bind(repo)
2420 .bind(&ext)
2421 .fetch_all(&self.pool)
2422 .await?;
2423 let mut matches = dedup_cli_refs(rows)?;
2424 match matches.len() {
2425 0 => Ok(None),
2426 1 => Ok(matches.pop()),
2427 _ => Err(ambiguous_cli_id_error(
2428 &format!("{repo}#{number}"),
2429 &matches,
2430 )),
2431 }
2432 }
2433
2434 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2435 let ext = number.to_string();
2436 let rows = sqlx::query(
2437 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2438 WHERE (origin = 'internal' AND id = ?) \
2439 OR (origin != 'internal' AND external_id = ?) \
2440 ORDER BY repo, id",
2441 )
2442 .bind(number)
2443 .bind(&ext)
2444 .fetch_all(&self.pool)
2445 .await?;
2446 dedup_cli_refs(rows)
2447 }
2448
2449 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2450 Ok(TaskRun {
2451 id: row
2452 .try_get("id")
2453 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2454 task_id: row
2455 .try_get("task_id")
2456 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2457 attempt: row
2458 .try_get("attempt")
2459 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2460 run_type: row
2461 .try_get("run_type")
2462 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2463 agent: row
2464 .try_get("agent")
2465 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2466 model: row
2467 .try_get("model")
2468 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2469 command: row
2470 .try_get("command")
2471 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2472 prompt: row
2473 .try_get("prompt")
2474 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2475 env_vars: row
2476 .try_get("env_vars")
2477 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2478 exit_code: row
2479 .try_get("exit_code")
2480 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2481 stdout: row
2482 .try_get("stdout")
2483 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2484 stderr: row
2485 .try_get("stderr")
2486 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2487 parsed_response: row
2488 .try_get("parsed_response")
2489 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2490 outcome: row
2491 .try_get("outcome")
2492 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2493 error: row
2494 .try_get("error")
2495 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2496 input_tokens: row
2497 .try_get("input_tokens")
2498 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2499 output_tokens: row
2500 .try_get("output_tokens")
2501 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2502 total_cost_usd: row
2503 .try_get("total_cost_usd")
2504 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2505 duration_secs: row
2506 .try_get("duration_secs")
2507 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2508 started_at: row
2509 .try_get("started_at")
2510 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2511 completed_at: row.try_get("completed_at").unwrap_or(None),
2512 })
2513 }
2514
2515 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2516 let details_str: String = row
2517 .try_get("details")
2518 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2519 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2520 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2521 )?;
2522 Ok(TaskActivity {
2523 id: row
2524 .try_get("id")
2525 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2526 task_id: row
2527 .try_get("task_id")
2528 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2529 timestamp: row
2530 .try_get("timestamp")
2531 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2532 event_type: row
2533 .try_get("event_type")
2534 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2535 from_status: row
2536 .try_get("from_status")
2537 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2538 to_status: row
2539 .try_get("to_status")
2540 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2541 agent: row
2542 .try_get("agent")
2543 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2544 model: row
2545 .try_get("model")
2546 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2547 details,
2548 })
2549 }
2550}
2551
2552const LOCAL_TASK_COLUMNS: &[&str] = &[
2554 "worktree",
2555 "review_session_expected",
2556 "input_tokens",
2557 "output_tokens",
2558 "input_cost_usd",
2559 "output_cost_usd",
2560 "total_cost_usd",
2561];
2562
2563pub(crate) fn is_local_column(col: &str) -> bool {
2564 LOCAL_TASK_COLUMNS.contains(&col)
2565}
2566
2567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2569pub struct UpsertExternalCmd {
2570 pub repo: String,
2571 pub ext_id: String,
2572 pub title: String,
2573 pub body: String,
2574 pub author: String,
2575 pub url: String,
2576 pub labels: Vec<String>,
2577 pub origin: String,
2578 pub parent_id: Option<i64>,
2579}
2580
2581#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2583pub struct StoreRouteCmd {
2584 pub id: i64,
2585 pub agent: String,
2586 pub model: Option<String>,
2587 pub complexity: String,
2588 pub estimate: u8,
2589 pub reason: String,
2590 pub profile: String,
2591 pub skills: String,
2592}
2593
2594const TOMBSTONE_COLUMNS: &[&str] = &[
2596 "id",
2597 "external_id",
2598 "repo",
2599 "origin",
2600 "title",
2601 "status",
2602 "pr_number",
2603 "parent_id",
2604 "created_at",
2605 "updated_at",
2606];
2607
2608async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2609 let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2610 .fetch_all(conn)
2611 .await?;
2612 Ok(cols
2613 .into_iter()
2614 .map(|(c,)| c)
2615 .filter(|c| !is_local_column(c))
2616 .collect())
2617}
2618
2619pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2621 let mut conn = pool.acquire().await?;
2622 let cols = replicated_columns(&mut conn).await?;
2623 let full: Vec<&String> = cols.iter().collect();
2624 let tomb: Vec<&String> = cols
2625 .iter()
2626 .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2627 .collect();
2628 let sql = format!(
2629 "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2630 super::replica::json_row_sql(&tomb),
2631 super::replica::json_row_sql(&full)
2632 );
2633 let rows: Vec<(String,)> = sqlx::query_as(&sql).fetch_all(&mut *conn).await?;
2634 rows.into_iter()
2635 .map(|(r,)| Ok(serde_json::from_str(&r)?))
2636 .collect()
2637}
2638
2639pub(crate) async fn replace_replicated(
2642 conn: &mut sqlx::SqliteConnection,
2643 rows: &[serde_json::Value],
2644) -> anyhow::Result<()> {
2645 let allowed = replicated_columns(conn).await?;
2646 sqlx::query("PRAGMA defer_foreign_keys = ON")
2647 .execute(&mut *conn)
2648 .await?;
2649 sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2650 .execute(&mut *conn)
2651 .await?;
2652 sqlx::query("DELETE FROM snapshot_ids")
2653 .execute(&mut *conn)
2654 .await?;
2655 for row in rows {
2656 let id = row["id"]
2657 .as_i64()
2658 .context("snapshot task row without an id")?;
2659 sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2660 .bind(id)
2661 .execute(&mut *conn)
2662 .await?;
2663 }
2664
2665 for table in ["task_runs", "task_activity"] {
2666 sqlx::query(&format!(
2667 "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2668 ))
2669 .execute(&mut *conn)
2670 .await?;
2671 }
2672
2673 sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2674 .execute(&mut *conn)
2675 .await?;
2676
2677 for row in rows {
2678 let obj = row
2679 .as_object()
2680 .context("snapshot task row is not an object")?;
2681 let cols: Vec<&String> = obj.keys().collect();
2682 for c in &cols {
2683 anyhow::ensure!(
2684 allowed.contains(c),
2685 "snapshot names unknown task column {c}"
2686 );
2687 }
2688
2689 let values: Vec<String> = cols
2690 .iter()
2691 .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2692 .collect();
2693 let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2694 let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2695 let sql = format!(
2696 "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2697 names.join(", "),
2698 values.join(", "),
2699 updates.join(", ")
2700 );
2701 sqlx::query(&sql)
2702 .bind(serde_json::to_string(row)?)
2703 .execute(&mut *conn)
2704 .await?;
2705 }
2706
2707 sqlx::query("DROP TABLE snapshot_ids")
2708 .execute(&mut *conn)
2709 .await?;
2710 Ok(())
2711}
2712
2713pub(crate) mod apply {
2715 use super::*;
2716 use sqlx::SqliteConnection;
2717
2718 async fn previous(
2719 conn: &mut SqliteConnection,
2720 id: i64,
2721 ) -> anyhow::Result<(String, Option<String>, Option<String>)> {
2722 let row = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
2723 .bind(id)
2724 .fetch_one(&mut *conn)
2725 .await?;
2726 let status: String = row
2727 .try_get("status")
2728 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
2729 let agent: Option<String> = row
2730 .try_get("agent")
2731 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
2732 let model: Option<String> = row
2733 .try_get("model")
2734 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
2735 Ok((status, agent, model))
2736 }
2737
2738 #[allow(clippy::too_many_arguments)]
2739 pub(crate) async fn activity(
2740 conn: &mut SqliteConnection,
2741 task_id: i64,
2742 event_type: &str,
2743 from_status: Option<&str>,
2744 to_status: Option<&str>,
2745 agent: Option<&str>,
2746 model: Option<&str>,
2747 details: Option<&serde_json::Value>,
2748 ) -> anyhow::Result<()> {
2749 let details_json = match details {
2750 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
2751 None => "{}".to_string(),
2752 };
2753 sqlx::query(
2754 "INSERT INTO task_activity
2755 (task_id, event_type, from_status, to_status, agent, model, details)
2756 VALUES (?, ?, ?, ?, ?, ?, ?)",
2757 )
2758 .bind(task_id)
2759 .bind(event_type)
2760 .bind(from_status)
2761 .bind(to_status)
2762 .bind(agent)
2763 .bind(model)
2764 .bind(details_json)
2765 .execute(conn)
2766 .await?;
2767 Ok(())
2768 }
2769
2770 async fn status_change(
2771 conn: &mut SqliteConnection,
2772 id: i64,
2773 previous: &(String, Option<String>, Option<String>),
2774 to: TaskStatus,
2775 details: Option<&serde_json::Value>,
2776 ) -> anyhow::Result<()> {
2777 activity(
2778 conn,
2779 id,
2780 "status_change",
2781 Some(previous.0.as_str()),
2782 Some(to.as_str()),
2783 previous.1.as_deref(),
2784 previous.2.as_deref(),
2785 details,
2786 )
2787 .await
2788 }
2789
2790 pub(crate) async fn create(
2793 conn: &mut SqliteConnection,
2794 now: &str,
2795 new: &NewTask,
2796 internal: bool,
2797 replicated: bool,
2798 ) -> anyhow::Result<i64> {
2799 let labels_json = serde_json::to_string(&new.labels)?;
2800 let row = sqlx::query(
2801 "INSERT INTO tasks (id, external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id, created_at, updated_at)
2802 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2803 RETURNING id",
2804 )
2805 .bind(replicated)
2806 .bind(&new.external_id)
2807 .bind(&new.repo)
2808 .bind(&new.origin)
2809 .bind(&new.title)
2810 .bind(&new.body)
2811 .bind(&new.source)
2812 .bind(&new.source_id)
2813 .bind(&new.author)
2814 .bind(&new.url)
2815 .bind(&labels_json)
2816 .bind(new.parent_id)
2817 .bind(now)
2818 .bind(now)
2819 .fetch_one(&mut *conn)
2820 .await?;
2821 let id: i64 = row
2822 .try_get("id")
2823 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
2824 if internal {
2825 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
2827 .bind(format!("internal:{id}"))
2828 .bind(id)
2829 .execute(conn)
2830 .await?;
2831 }
2832
2833 Ok(id)
2834 }
2835
2836 pub(crate) async fn upsert_external(
2837 conn: &mut SqliteConnection,
2838 now: &str,
2839 ext: &UpsertExternalCmd,
2840 replicated: bool,
2841 ) -> anyhow::Result<i64> {
2842 let labels_json = serde_json::to_string(&ext.labels)?;
2843 let row = sqlx::query(
2844 "INSERT INTO tasks (id, external_id, repo, origin, title, body, author, url, labels, parent_id, created_at, updated_at)
2845 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2846 ON CONFLICT(repo, external_id) DO UPDATE SET
2847 title = excluded.title,
2848 body = excluded.body,
2849 labels = excluded.labels,
2850 url = excluded.url,
2851 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
2852 RETURNING id",
2853 )
2854 .bind(replicated)
2855 .bind(&ext.ext_id)
2856 .bind(&ext.repo)
2857 .bind(&ext.origin)
2858 .bind(&ext.title)
2859 .bind(&ext.body)
2860 .bind(&ext.author)
2861 .bind(&ext.url)
2862 .bind(&labels_json)
2863 .bind(ext.parent_id)
2864 .bind(now)
2865 .bind(now)
2866 .fetch_one(conn)
2867 .await?;
2868 row.try_get("id")
2869 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
2870 }
2871
2872 fn ensure_allowed(updates: &[(String, serde_json::Value)]) -> anyhow::Result<()> {
2873 for (col, _) in updates {
2874 anyhow::ensure!(
2875 ALLOWED_FIELDS.contains(&col.as_str()),
2876 "column {col} is not in the update allowlist"
2877 );
2878 }
2879
2880 Ok(())
2881 }
2882
2883 pub(crate) async fn update_status_and_fields(
2884 conn: &mut SqliteConnection,
2885 now: &str,
2886 id: i64,
2887 status: TaskStatus,
2888 updates: &[(String, serde_json::Value)],
2889 ) -> anyhow::Result<()> {
2890 ensure_allowed(updates)?;
2891 let prev = previous(conn, id).await?;
2892
2893 let mut set_parts = vec!["status = ?".to_string()];
2894 let mut values: Vec<Option<String>> = vec![Some(status.as_str().to_string())];
2895 let mut block_reason_in_updates = false;
2896 for (col, val) in updates {
2897 if col == "block_reason" {
2898 block_reason_in_updates = true;
2899 }
2900
2901 set_parts.push(format!("{col} = ?"));
2902 match val {
2903 serde_json::Value::String(s) => values.push(Some(s.clone())),
2904 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
2905 serde_json::Value::Bool(b) => {
2906 values.push(Some(if *b { "1" } else { "0" }.to_string()));
2907 }
2908 serde_json::Value::Null => values.push(None),
2909 other => values.push(Some(serde_json::to_string(other)?)),
2910 }
2911 }
2912
2913 if !block_reason_in_updates && status != TaskStatus::Blocked {
2914 set_parts.push("block_reason = NULL".to_string());
2915 }
2916
2917 set_parts.push("updated_at = ?".to_string());
2918 values.push(Some(now.to_string()));
2919 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
2920 let mut query = sqlx::query(&sql);
2921 for v in &values {
2922 query = query.bind(v.as_deref());
2923 }
2924 query.bind(id).execute(&mut *conn).await?;
2925 status_change(conn, id, &prev, status, None).await
2926 }
2927
2928 pub(crate) async fn update_status(
2930 conn: &mut SqliteConnection,
2931 now: &str,
2932 id: i64,
2933 status: TaskStatus,
2934 expected: Option<TaskStatus>,
2935 ) -> anyhow::Result<bool> {
2936 let prev = previous(conn, id).await?;
2937 let clear_block = if status == TaskStatus::Blocked {
2938 ""
2939 } else {
2940 ", block_reason = NULL"
2941 };
2942 let guard = if expected.is_some() {
2943 " AND status = ?"
2944 } else {
2945 ""
2946 };
2947 let sql =
2948 format!("UPDATE tasks SET status = ?{clear_block}, updated_at = ? WHERE id = ?{guard}");
2949 let mut query = sqlx::query(&sql).bind(status.as_str()).bind(now).bind(id);
2950 if let Some(expected) = expected {
2951 query = query.bind(expected.as_str());
2952 }
2953
2954 if query.execute(&mut *conn).await?.rows_affected() == 0 {
2955 return Ok(false);
2956 }
2957
2958 status_change(conn, id, &prev, status, None).await?;
2959 Ok(true)
2960 }
2961
2962 pub(crate) async fn update_external_id(
2963 conn: &mut SqliteConnection,
2964 now: &str,
2965 id: i64,
2966 external_id: &str,
2967 ) -> anyhow::Result<()> {
2968 sqlx::query("UPDATE tasks SET external_id = ?, updated_at = ? WHERE id = ?")
2969 .bind(external_id)
2970 .bind(now)
2971 .bind(id)
2972 .execute(conn)
2973 .await?;
2974 Ok(())
2975 }
2976
2977 pub(crate) async fn touch(
2978 conn: &mut SqliteConnection,
2979 now: &str,
2980 id: i64,
2981 ) -> anyhow::Result<()> {
2982 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
2983 .bind(now)
2984 .bind(id)
2985 .execute(conn)
2986 .await?;
2987 Ok(())
2988 }
2989
2990 pub(crate) async fn reset_to_new(
2991 conn: &mut SqliteConnection,
2992 now: &str,
2993 id: i64,
2994 ) -> anyhow::Result<()> {
2995 let prev = previous(conn, id).await?;
2996 sqlx::query(
2997 "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = ? WHERE id = ?",
2998 )
2999 .bind(now)
3000 .bind(id)
3001 .execute(&mut *conn)
3002 .await?;
3003 let details = serde_json::json!({ "op": "reset_to_new" });
3004 status_change(conn, id, &prev, TaskStatus::New, Some(&details)).await
3005 }
3006
3007 pub(crate) async fn set_fields(
3009 conn: &mut SqliteConnection,
3010 now: &str,
3011 id: i64,
3012 updates: &[(String, serde_json::Value)],
3013 silent: bool,
3014 ) -> anyhow::Result<()> {
3015 if updates.is_empty() {
3016 return Ok(());
3017 }
3018
3019 ensure_allowed(updates)?;
3020 let mut set_parts = Vec::new();
3021 let mut values: Vec<Option<String>> = Vec::new();
3022 for (col, val) in updates {
3023 set_parts.push(format!("{col} = ?"));
3024 values.push(sql_value_for_task_field(col, val)?);
3025 }
3026
3027 if !silent {
3028 set_parts.push("updated_at = ?".to_string());
3029 values.push(Some(now.to_string()));
3030 }
3031
3032 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
3033 let mut query = sqlx::query(&sql);
3034 for v in &values {
3035 query = query.bind(v.as_deref());
3036 }
3037 query.bind(id).execute(conn).await?;
3038 Ok(())
3039 }
3040
3041 pub(crate) async fn increment(
3042 conn: &mut SqliteConnection,
3043 now: &str,
3044 id: i64,
3045 field: &str,
3046 ) -> anyhow::Result<i64> {
3047 anyhow::ensure!(
3048 INCREMENTABLE_FIELDS.contains(&field),
3049 "field {field} is not incrementable"
3050 );
3051 let sql = format!(
3052 "UPDATE tasks SET {field} = {field} + 1, updated_at = ? WHERE id = ? RETURNING {field} AS val"
3053 );
3054 let row = sqlx::query(&sql).bind(now).bind(id).fetch_one(conn).await?;
3055 let val: i32 = row.try_get("val")?;
3056 Ok(val.into())
3057 }
3058
3059 pub(crate) async fn reset_counters(
3062 conn: &mut SqliteConnection,
3063 now: &str,
3064 id: i64,
3065 failure_only: bool,
3066 ) -> anyhow::Result<()> {
3067 let all = if failure_only {
3068 ""
3069 } else {
3070 "attempts = 0, merge_conflict_retries = 0, ci_merge_failures = 0, review_cycles = 0, review_invocations = 0,"
3071 };
3072 let sql = format!(
3073 "UPDATE tasks SET {all}
3074 route_attempts = 0,
3075 review_agent_failures = 0,
3076 pr_create_failures = 0,
3077 push_failures = 0,
3078 network_retries = 0,
3079 review_session_expected = 0,
3080 auto_unblock_count = 0,
3081 auto_unblock_last_at = '',
3082 auto_unblock_last_reason = '',
3083 ci_recovery_count = 0,
3084 no_code_reroutes = 0,
3085 no_code_last_agent = '',
3086 needs_review_refires = 0,
3087 updated_at = ?
3088 WHERE id = ?"
3089 );
3090 sqlx::query(&sql).bind(now).bind(id).execute(conn).await?;
3091 Ok(())
3092 }
3093
3094 pub(crate) async fn store_route(
3095 conn: &mut SqliteConnection,
3096 now: &str,
3097 route: &StoreRouteCmd,
3098 ) -> anyhow::Result<()> {
3099 sqlx::query(
3100 "UPDATE tasks SET
3101 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
3102 agent_profile = ?, selected_skills = ?,
3103 updated_at = ?
3104 WHERE id = ?",
3105 )
3106 .bind(&route.agent)
3107 .bind(&route.model)
3108 .bind(&route.complexity)
3109 .bind(route.estimate as i64)
3110 .bind(&route.reason)
3111 .bind(&route.profile)
3112 .bind(&route.skills)
3113 .bind(now)
3114 .bind(route.id)
3115 .execute(&mut *conn)
3116 .await?;
3117 let details = serde_json::json!({
3118 "complexity": route.complexity,
3119 "estimate": route.estimate,
3120 "reason": route.reason,
3121 "skills": route.skills,
3122 });
3123 activity(
3124 conn,
3125 route.id,
3126 "dispatch",
3127 None,
3128 None,
3129 Some(&route.agent),
3130 route.model.as_deref(),
3131 Some(&details),
3132 )
3133 .await
3134 }
3135
3136 pub(crate) async fn set_estimate_if_zero(
3137 conn: &mut SqliteConnection,
3138 now: &str,
3139 id: i64,
3140 estimate: u8,
3141 ) -> anyhow::Result<bool> {
3142 let result = sqlx::query(
3143 "UPDATE tasks SET estimate = ?, updated_at = ? WHERE id = ? AND estimate = 0",
3144 )
3145 .bind(estimate as i64)
3146 .bind(now)
3147 .bind(id)
3148 .execute(conn)
3149 .await?;
3150 Ok(result.rows_affected() > 0)
3151 }
3152
3153 pub(crate) async fn append_memory(
3154 conn: &mut SqliteConnection,
3155 now: &str,
3156 id: i64,
3157 entry: &MemoryEntry,
3158 ) -> anyhow::Result<()> {
3159 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
3160 .bind(id)
3161 .fetch_one(&mut *conn)
3162 .await?;
3163 let memory_opt: Option<String> = row
3165 .try_get::<Option<String>, _>("memory")
3166 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
3167 let memory_str = memory_opt.unwrap_or_default();
3168 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
3169 Vec::new()
3170 } else {
3171 serde_json::from_str(&memory_str)
3172 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
3173 };
3174 memory.push(entry.clone());
3175 sqlx::query("UPDATE tasks SET memory = ?, updated_at = ? WHERE id = ?")
3176 .bind(serde_json::to_string(&memory)?)
3177 .bind(now)
3178 .bind(id)
3179 .execute(conn)
3180 .await?;
3181 Ok(())
3182 }
3183
3184 pub(crate) async fn mark_cleaned(
3185 conn: &mut SqliteConnection,
3186 now: &str,
3187 id: i64,
3188 ) -> anyhow::Result<()> {
3189 sqlx::query("UPDATE tasks SET worktree_cleaned = 1, updated_at = ? WHERE id = ?")
3190 .bind(now)
3191 .bind(id)
3192 .execute(&mut *conn)
3193 .await?;
3194 activity(
3195 conn,
3196 id,
3197 "branch_delete",
3198 None,
3199 None,
3200 None,
3201 None,
3202 Some(&serde_json::json!({ "worktree_cleaned": true })),
3203 )
3204 .await
3205 }
3206}
3207
3208#[cfg(test)]
3209mod row_to_task_tests {
3210 use super::*;
3211
3212 async fn store_with_task() -> (crate::store::TaskStore, i64) {
3213 let store = crate::store::TaskStore::open_memory().await.unwrap();
3214 let id = store
3215 .create(&NewTask {
3216 external_id: None,
3217 repo: "owner/repo".to_string(),
3218 origin: "internal".to_string(),
3219 title: "Test task".to_string(),
3220 body: "Test body".to_string(),
3221 source: "cron".to_string(),
3222 source_id: "test-1".to_string(),
3223 author: "tester".to_string(),
3224 url: "https://example.com/1".to_string(),
3225 labels: vec![],
3226 parent_id: None,
3227 })
3228 .await
3229 .unwrap();
3230 (store, id)
3231 }
3232
3233 macro_rules! missing_column_test {
3236 ($name:ident, $col:literal, $select:literal) => {
3237 #[tokio::test]
3238 async fn $name() {
3239 let (store, id) = store_with_task().await;
3240 let sql = format!("{} WHERE id = {id}", $select);
3241 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3242 let err = TaskStore::row_to_task(&row).unwrap_err();
3243 assert!(
3244 err.to_string().contains($col),
3245 "expected '{}' in error message, got: {err}",
3246 $col
3247 );
3248 }
3249 };
3250 }
3251
3252 missing_column_test!(
3253 row_to_task_fails_on_missing_repo,
3254 "repo",
3255 "SELECT id, status FROM tasks"
3256 );
3257 missing_column_test!(
3258 row_to_task_fails_on_missing_origin,
3259 "origin",
3260 "SELECT id, repo, status FROM tasks"
3261 );
3262 missing_column_test!(
3263 row_to_task_fails_on_missing_title,
3264 "title",
3265 "SELECT id, repo, origin, status FROM tasks"
3266 );
3267 missing_column_test!(
3268 row_to_task_fails_on_missing_body,
3269 "body",
3270 "SELECT id, repo, origin, title, status FROM tasks"
3271 );
3272 missing_column_test!(
3273 row_to_task_fails_on_missing_source,
3274 "source",
3275 "SELECT id, repo, origin, title, body, status FROM tasks"
3276 );
3277 missing_column_test!(
3278 row_to_task_fails_on_missing_source_id,
3279 "source_id",
3280 "SELECT id, repo, origin, title, body, source, status FROM tasks"
3281 );
3282 missing_column_test!(
3283 row_to_task_fails_on_missing_author,
3284 "author",
3285 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
3286 );
3287 missing_column_test!(
3288 row_to_task_fails_on_missing_url,
3289 "url",
3290 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
3291 );
3292
3293 #[tokio::test]
3300 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
3301 let (store, id) = store_with_task().await;
3302 let sql = format!(
3305 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
3306 source, source_id, author, url FROM tasks WHERE id = {id}"
3307 );
3308 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3309 let err = TaskStore::row_to_task(&row).unwrap_err();
3310 assert!(
3311 err.to_string().contains("repo"),
3312 "expected 'repo' in error message, got: {err}"
3313 );
3314 }
3315
3316 #[tokio::test]
3319 async fn distinct_repos_fails_on_decode_error() {
3320 let store = crate::store::TaskStore::open_memory().await.unwrap();
3321 let id = store
3322 .create(&NewTask {
3323 external_id: None,
3324 repo: "owner/repo".to_string(),
3325 origin: "internal".to_string(),
3326 title: "Test task".to_string(),
3327 body: "Test body".to_string(),
3328 source: "cron".to_string(),
3329 source_id: "test-1".to_string(),
3330 author: "tester".to_string(),
3331 url: "https://example.com/1".to_string(),
3332 labels: vec![],
3333 parent_id: None,
3334 })
3335 .await
3336 .unwrap();
3337
3338 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
3340 sqlx::query(&sql).execute(store.pool()).await.unwrap();
3341
3342 let err = store.distinct_repos().await.unwrap_err();
3343 assert!(
3344 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
3345 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
3346 );
3347 }
3348
3349 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
3352 let store = crate::store::TaskStore::open_memory().await.unwrap();
3353 let task_id = store
3354 .create(&NewTask {
3355 external_id: None,
3356 repo: "owner/repo".to_string(),
3357 origin: "internal".to_string(),
3358 title: "Test task".to_string(),
3359 body: "Test body".to_string(),
3360 source: "cron".to_string(),
3361 source_id: "test-1".to_string(),
3362 author: "tester".to_string(),
3363 url: "https://example.com/1".to_string(),
3364 labels: vec![],
3365 parent_id: None,
3366 })
3367 .await
3368 .unwrap();
3369 let run_id = store
3370 .start_run(&crate::store::StartRun {
3371 task_id,
3372 attempt: 1,
3373 run_type: "agent",
3374 agent: "claude",
3375 model: "sonnet",
3376 command: "claude -p ...",
3377 prompt: "system prompt",
3378 })
3379 .await
3380 .unwrap();
3381 (store, task_id, run_id)
3382 }
3383
3384 #[tokio::test]
3385 async fn start_run_upsert_clears_stale_completion_fields() {
3386 let (store, task_id, run_id) = store_with_run().await;
3387 store
3388 .complete_run(&CompleteRun {
3389 run_id,
3390 exit_code: Some(0),
3391 stdout: "",
3392 stderr: "",
3393 parsed: "",
3394 outcome: "success",
3395 error: "boom",
3396 tokens: RunTokenUsage::default(),
3397 })
3398 .await
3399 .unwrap();
3400
3401 let same_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 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
3415
3416 let row = sqlx::query(
3417 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
3418 )
3419 .bind(run_id)
3420 .fetch_one(store.pool())
3421 .await
3422 .unwrap();
3423 let outcome: Option<String> = row.try_get("outcome").unwrap();
3424 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
3425 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
3426 let error: Option<String> = row.try_get("error").unwrap();
3427 assert_eq!(outcome, None);
3428 assert_eq!(
3429 completed_at, None,
3430 "stale completion timestamp must be cleared"
3431 );
3432 assert_eq!(exit_code, None);
3433 assert_eq!(error, None);
3434 }
3435
3436 macro_rules! run_blob_decode_test {
3443 ($name:ident, $col:literal, $select:literal) => {
3444 #[tokio::test]
3445 async fn $name() {
3446 let (store, _task_id, run_id) = store_with_run().await;
3447 let sql = format!($select, run_id = run_id);
3448 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3449 let err = TaskStore::row_to_run(&row).unwrap_err();
3450 assert!(
3451 err.to_string().contains($col),
3452 "expected '{0}' in error message, got: {err}",
3453 $col
3454 );
3455 }
3456 };
3457 }
3458
3459 run_blob_decode_test!(
3460 row_to_run_fails_on_decode_error_invalid_utf8_id,
3461 "id",
3462 "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}"
3463 );
3464 run_blob_decode_test!(
3465 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3466 "task_id",
3467 "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}"
3468 );
3469 run_blob_decode_test!(
3470 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3471 "started_at",
3472 "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}"
3473 );
3474
3475 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
3478 let store = crate::store::TaskStore::open_memory().await.unwrap();
3479 let task_id = store
3480 .create(&NewTask {
3481 external_id: None,
3482 repo: "owner/repo".to_string(),
3483 origin: "internal".to_string(),
3484 title: "Test task".to_string(),
3485 body: "Test body".to_string(),
3486 source: "cron".to_string(),
3487 source_id: "test-1".to_string(),
3488 author: "tester".to_string(),
3489 url: "https://example.com/1".to_string(),
3490 labels: vec![],
3491 parent_id: None,
3492 })
3493 .await
3494 .unwrap();
3495 store
3496 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3497 .await
3498 .unwrap();
3499 (store, task_id, 1)
3500 }
3501
3502 #[tokio::test]
3505 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3506 let (store, _task_id, activity_id) = store_with_activity().await;
3507 let sql = format!(
3508 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3509 );
3510 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3511 let err = TaskStore::row_to_activity(&row).unwrap_err();
3512 assert!(
3513 err.to_string().contains("id"),
3514 "expected 'id' in error message, got: {err}"
3515 );
3516 }
3517
3518 #[tokio::test]
3519 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3520 let (store, _task_id, activity_id) = store_with_activity().await;
3521 let sql = format!(
3522 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3523 );
3524 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3525 let err = TaskStore::row_to_activity(&row).unwrap_err();
3526 assert!(
3527 err.to_string().contains("task_id"),
3528 "expected 'task_id' in error message, got: {err}"
3529 );
3530 }
3531
3532 #[tokio::test]
3533 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3534 let (store, _task_id, activity_id) = store_with_activity().await;
3535 let sql = format!(
3536 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3537 );
3538 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3539 let err = TaskStore::row_to_activity(&row).unwrap_err();
3540 assert!(
3541 err.to_string().contains("timestamp"),
3542 "expected 'timestamp' in error message, got: {err}"
3543 );
3544 }
3545
3546 #[tokio::test]
3547 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3548 let (store, _task_id, activity_id) = store_with_activity().await;
3549 let sql = format!(
3550 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3551 );
3552 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3553 let err = TaskStore::row_to_activity(&row).unwrap_err();
3554 assert!(
3555 err.to_string().contains("event_type"),
3556 "expected 'event_type' in error message, got: {err}"
3557 );
3558 }
3559
3560 #[tokio::test]
3561 async fn row_to_activity_fails_on_corrupt_details_json() {
3562 let (store, task_id, _activity_id) = store_with_activity().await;
3563 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3565 .bind(task_id)
3566 .bind("test")
3567 .bind("not valid json {{{")
3568 .execute(store.pool())
3569 .await
3570 .unwrap();
3571 let row = sqlx::query(&format!(
3572 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3573 ))
3574 .fetch_one(store.pool())
3575 .await
3576 .unwrap();
3577 let err = TaskStore::row_to_activity(&row).unwrap_err();
3578 assert!(
3579 err.to_string().contains("details")
3580 || err.to_string().contains("json")
3581 || err.to_string().contains("expected"),
3582 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3583 );
3584 }
3585
3586 run_blob_decode_test!(
3589 row_to_run_fails_on_decode_error_invalid_utf8_model,
3590 "model",
3591 "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}"
3592 );
3593 run_blob_decode_test!(
3594 row_to_run_fails_on_decode_error_invalid_utf8_command,
3595 "command",
3596 "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}"
3597 );
3598 run_blob_decode_test!(
3599 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3600 "prompt",
3601 "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}"
3602 );
3603 run_blob_decode_test!(
3604 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3605 "env_vars",
3606 "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}"
3607 );
3608 run_blob_decode_test!(
3609 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3610 "stdout",
3611 "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}"
3612 );
3613 run_blob_decode_test!(
3614 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3615 "stderr",
3616 "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}"
3617 );
3618 run_blob_decode_test!(
3619 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3620 "parsed_response",
3621 "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}"
3622 );
3623 run_blob_decode_test!(
3624 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3625 "outcome",
3626 "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}"
3627 );
3628 run_blob_decode_test!(
3629 row_to_run_fails_on_decode_error_invalid_utf8_error,
3630 "error",
3631 "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}"
3632 );
3633 run_blob_decode_test!(
3634 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3635 "input_tokens",
3636 "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}"
3637 );
3638 run_blob_decode_test!(
3639 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3640 "output_tokens",
3641 "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}"
3642 );
3643 run_blob_decode_test!(
3644 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3645 "total_cost_usd",
3646 "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}"
3647 );
3648 run_blob_decode_test!(
3649 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3650 "duration_secs",
3651 "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}"
3652 );
3653 run_blob_decode_test!(
3654 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3655 "exit_code",
3656 "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}"
3657 );
3658
3659 #[tokio::test]
3666 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3667 let (store, _task_id, activity_id) = store_with_activity().await;
3668 let sql = format!(
3669 "SELECT id, task_id, timestamp, event_type, \
3670 X'DeadbEEF' as from_status, NULL as to_status, \
3671 NULL as agent, NULL as model, details \
3672 FROM task_activity WHERE id = {activity_id}"
3673 );
3674 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3675 let err = TaskStore::row_to_activity(&row).unwrap_err();
3676 assert!(
3677 err.to_string().contains("from_status"),
3678 "expected 'from_status' in error message, got: {err}"
3679 );
3680 }
3681
3682 #[tokio::test]
3683 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3684 let (store, _task_id, activity_id) = store_with_activity().await;
3685 let sql = format!(
3686 "SELECT id, task_id, timestamp, event_type, \
3687 NULL as from_status, X'DeadbEEF' as to_status, \
3688 NULL as agent, NULL as model, details \
3689 FROM task_activity WHERE id = {activity_id}"
3690 );
3691 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3692 let err = TaskStore::row_to_activity(&row).unwrap_err();
3693 assert!(
3694 err.to_string().contains("to_status"),
3695 "expected 'to_status' in error message, got: {err}"
3696 );
3697 }
3698
3699 #[tokio::test]
3700 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3701 let (store, _task_id, activity_id) = store_with_activity().await;
3702 let sql = format!(
3703 "SELECT id, task_id, timestamp, event_type, \
3704 NULL as from_status, NULL as to_status, \
3705 X'DeadbEEF' as agent, NULL as model, details \
3706 FROM task_activity WHERE id = {activity_id}"
3707 );
3708 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3709 let err = TaskStore::row_to_activity(&row).unwrap_err();
3710 assert!(
3711 err.to_string().contains("agent"),
3712 "expected 'agent' in error message, got: {err}"
3713 );
3714 }
3715
3716 #[tokio::test]
3717 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3718 let (store, _task_id, activity_id) = store_with_activity().await;
3719 let sql = format!(
3720 "SELECT id, task_id, timestamp, event_type, \
3721 NULL as from_status, NULL as to_status, \
3722 NULL as agent, X'DeadbEEF' as model, details \
3723 FROM task_activity WHERE id = {activity_id}"
3724 );
3725 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3726 let err = TaskStore::row_to_activity(&row).unwrap_err();
3727 assert!(
3728 err.to_string().contains("model"),
3729 "expected 'model' in error message, got: {err}"
3730 );
3731 }
3732
3733 #[tokio::test]
3736 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3737 let store = crate::store::TaskStore::open_memory().await.unwrap();
3742 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3743 .execute(store.pool())
3744 .await
3745 .unwrap();
3746 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3748 .execute(store.pool())
3749 .await
3750 .unwrap();
3751
3752 let result = store
3753 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3754 .await
3755 .unwrap();
3756 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3757 assert_eq!(
3758 result.get("valid-id"),
3759 Some(&crate::store::TaskStatus::Done)
3760 );
3761 }
3762}