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