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