1pub(crate) mod apply;
3
4use std::collections::HashMap;
5use std::collections::HashSet;
6
7use super::{Context, MemoryEntry, SqlitePool, TaskStore, TokenUsage, pricing_for_model};
8use crate::backends::ExternalTask;
9use crate::cluster;
10use crate::cluster::Command;
11use crate::security;
12use serde::{Deserialize, Serialize};
13use sqlx::Row;
14use sqlx::sqlite::SqliteRow;
15
16pub(crate) const TASK_COLS: &str = "id, external_id, repo, origin, title, body, status, \
24 source, source_id, author, url, labels, agent, model, complexity, estimate, \
25 route_reason, agent_profile, selected_skills, route_attempts, attempts, \
26 branch, worktree, worktree_cleaned, summary, last_error, parent_id, \
27 block_reason, pr_number, pr_review_context, last_review_ts, review_ts_map, \
28 last_comment_review_ts, merge_conflict_retries, ci_merge_failures, \
29 pr_create_failures, review_agent_failures, review_resets, review_cycles, input_tokens, \
30 output_tokens, input_cost_usd, output_cost_usd, total_cost_usd, \
31 model_reroute_chain, limit_reroute_chain, \
32 memory, delegations, created_at, updated_at, \
33 review_invocations, needs_review_refires, push_failures, auto_unblock_count, auto_unblock_last_at, \
34 ci_recovery_count, auto_unblock_last_reason, no_code_reroutes, network_retries, no_code_last_agent, \
35 owner_node, timeout_retries";
36
37pub(crate) const TASK_COLS_COUNT: usize = 62;
39
40const TASK_RUN_COLS: &str = "id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, \
42 exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, \
43 total_cost_usd, duration_secs, started_at, completed_at";
44
45const TASK_ACTIVITY_COLS: &str =
47 "id, task_id, timestamp, event_type, from_status, to_status, agent, model, details";
48
49const ALLOWED_FIELDS: &[&str] = &[
51 "agent",
52 "model",
53 "complexity",
54 "route_reason",
55 "agent_profile",
56 "selected_skills",
57 "route_attempts",
58 "attempts",
59 "branch",
60 "worktree",
61 "worktree_cleaned",
62 "owner_node",
63 "summary",
64 "last_error",
65 "parent_id",
66 "block_reason",
67 "pr_number",
68 "pr_review_context",
69 "last_review_ts",
70 "review_ts_map",
71 "last_comment_review_ts",
72 "merge_conflict_retries",
73 "ci_merge_failures",
74 "pr_create_failures",
75 "push_failures",
76 "review_agent_failures",
77 "review_resets",
78 "review_cycles",
79 "review_invocations",
80 "needs_review_refires",
81 "input_tokens",
82 "output_tokens",
83 "input_cost_usd",
84 "output_cost_usd",
85 "total_cost_usd",
86 "model_reroute_chain",
87 "limit_reroute_chain",
88 "memory",
89 "delegations",
90 "source",
91 "source_id",
92 "labels",
93 "auto_unblock_count",
94 "auto_unblock_last_at",
95 "auto_unblock_last_reason",
96 "ci_recovery_count",
97 "no_code_reroutes",
98 "network_retries",
99 "timeout_retries",
100 "no_code_last_agent",
101];
102
103const INCREMENTABLE_FIELDS: &[&str] = &[
105 "attempts",
106 "route_attempts",
107 "merge_conflict_retries",
108 "ci_merge_failures",
109 "pr_create_failures",
110 "push_failures",
111 "review_agent_failures",
112 "review_resets",
113 "review_cycles",
114 "review_invocations",
115 "needs_review_refires",
116 "auto_unblock_count",
117 "ci_recovery_count",
118 "no_code_reroutes",
119 "network_retries",
120 "timeout_retries",
121];
122
123fn sanitize_activity_details(value: &serde_json::Value) -> serde_json::Value {
124 match value {
125 serde_json::Value::String(text) => serde_json::Value::String(security::redact(text)),
126 serde_json::Value::Array(items) => serde_json::Value::Array(
127 items
128 .iter()
129 .map(sanitize_activity_details)
130 .collect::<Vec<_>>(),
131 ),
132 serde_json::Value::Object(map) => serde_json::Value::Object(
133 map.iter()
134 .map(|(key, value)| (key.clone(), sanitize_activity_details(value)))
135 .collect(),
136 ),
137 other => other.clone(),
138 }
139}
140
141fn sanitize_task_field_value(col: &str, value: &serde_json::Value) -> serde_json::Value {
142 match (col, value) {
143 ("last_error" | "block_reason", serde_json::Value::String(text)) => {
144 serde_json::Value::String(security::redact(text))
145 }
146 _ => value.clone(),
147 }
148}
149
150fn sql_value_for_task_field(
151 col: &str,
152 value: &serde_json::Value,
153) -> anyhow::Result<Option<String>> {
154 let sanitized = sanitize_task_field_value(col, value);
155 match sanitized {
156 serde_json::Value::String(text) => Ok(Some(text)),
157 serde_json::Value::Number(number) => Ok(Some(number.to_string())),
158 serde_json::Value::Bool(boolean) => Ok(Some(if boolean { "1" } else { "0" }.to_string())),
159 serde_json::Value::Null => Ok(None),
160 other => Ok(Some(serde_json::to_string(&other)?)),
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum TaskStatus {
167 New,
168 Routed,
169 InProgress,
170 Done,
171 Blocked,
172 InReview,
173 Approved,
174 NeedsReview,
175 Waiting,
176}
177
178impl TaskStatus {
179 pub fn as_str(&self) -> &'static str {
180 match self {
181 Self::New => "new",
182 Self::Routed => "routed",
183 Self::InProgress => "in_progress",
184 Self::Done => "done",
185 Self::Blocked => "blocked",
186 Self::InReview => "in_review",
187 Self::Approved => "approved",
188 Self::NeedsReview => "needs_review",
189 Self::Waiting => "waiting",
190 }
191 }
192
193 pub fn from_str(s: &str) -> Option<Self> {
194 match s {
195 "new" => Some(Self::New),
196 "routed" => Some(Self::Routed),
197 "in_progress" => Some(Self::InProgress),
198 "done" => Some(Self::Done),
199 "blocked" => Some(Self::Blocked),
200 "in_review" => Some(Self::InReview),
201 "approved" => Some(Self::Approved),
202 "needs_review" => Some(Self::NeedsReview),
203 "waiting" => Some(Self::Waiting),
204 _ => None,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct Task {
212 pub id: i64,
213 pub external_id: Option<String>,
214 pub repo: String,
215 pub origin: String,
216
217 pub title: String,
218 pub body: String,
219 pub status: TaskStatus,
220 pub source: String,
221 pub source_id: String,
222 pub author: String,
223 pub url: String,
224 pub labels: Vec<String>,
225
226 pub agent: Option<String>,
228 pub model: Option<String>,
229 pub complexity: String,
230 pub estimate: u8,
232 pub route_reason: String,
233 pub agent_profile: String,
234 pub selected_skills: String,
235 pub route_attempts: i32,
236
237 pub attempts: i32,
239 pub branch: String,
240 pub worktree: String,
241 pub worktree_cleaned: bool,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub owner_node: Option<String>,
245 pub summary: String,
246 pub last_error: String,
247 pub parent_id: Option<i64>,
248 pub block_reason: Option<String>,
249
250 pub pr_number: Option<i32>,
252 pub pr_review_context: String,
253 pub last_review_ts: String,
254 pub review_ts_map: String,
255 pub last_comment_review_ts: String,
256 pub merge_conflict_retries: i32,
257 pub ci_merge_failures: i32,
258 pub pr_create_failures: i32,
259 pub push_failures: i32,
260 pub network_retries: i32,
261 pub timeout_retries: i32,
263 pub review_agent_failures: i32,
264 pub review_resets: i32,
266 pub review_cycles: i32,
267 pub review_invocations: i32,
268 pub needs_review_refires: i32,
270
271 pub input_tokens: i64,
273 pub output_tokens: i64,
274 pub input_cost_usd: f64,
275 pub output_cost_usd: f64,
276 pub total_cost_usd: f64,
277
278 pub model_reroute_chain: String,
280 pub limit_reroute_chain: String,
281
282 pub memory: Vec<MemoryEntry>,
284 pub delegations: Vec<serde_json::Value>,
285
286 pub auto_unblock_count: i32,
288 pub auto_unblock_last_at: String,
289 pub auto_unblock_last_reason: String,
290
291 pub ci_recovery_count: i32,
293
294 pub no_code_reroutes: i32,
296 pub no_code_last_agent: String,
298
299 pub created_at: String,
301 pub updated_at: String,
302}
303
304#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
306pub struct NewTask {
307 pub external_id: Option<String>,
308 pub repo: String,
309 pub origin: String,
310 pub title: String,
311 pub body: String,
312 pub source: String,
313 pub source_id: String,
314 pub author: String,
315 pub url: String,
316 pub labels: Vec<String>,
317 pub parent_id: Option<i64>,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum CliTaskSpec {
324 Internal(i64),
326 Issue { repo: String, number: i64 },
328 Bare(i64),
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct CliTaskRef {
335 pub store_id: Option<i64>,
336 pub repo: String,
337 pub origin: String,
338 pub title: String,
339 pub external_id: String,
340 pub status: String,
341}
342
343impl CliTaskRef {
344 pub fn is_internal(&self) -> bool {
345 self.origin == "internal"
346 }
347
348 pub fn unsynced_issue(repo: String, number: i64) -> Self {
349 Self {
350 store_id: None,
351 repo,
352 origin: "github".to_string(),
353 title: String::new(),
354 external_id: number.to_string(),
355 status: String::new(),
356 }
357 }
358
359 pub fn display_spec(&self) -> String {
360 if self.is_internal() {
361 match self.store_id {
362 Some(id) => format!("internal:{id}"),
363 None => self.external_id.clone(),
364 }
365 } else {
366 format!("{}#{}", self.repo, self.external_id)
367 }
368 }
369
370 fn from_row(row: &SqliteRow) -> anyhow::Result<Self> {
371 let id: i64 = row.try_get("id")?;
372 let origin: String = row.try_get("origin")?;
373 let external_id: Option<String> = row.try_get("external_id")?;
374 let external_id = external_id.unwrap_or_else(|| {
375 if origin == "internal" {
376 format!("internal:{id}")
377 } else {
378 String::new()
379 }
380 });
381 Ok(Self {
382 store_id: Some(id),
383 repo: row.try_get("repo")?,
384 origin,
385 title: row.try_get("title")?,
386 external_id,
387 status: row.try_get("status")?,
388 })
389 }
390
391 fn format_match(&self) -> String {
392 format!(
393 " {} [{}] {}",
394 self.display_spec(),
395 if self.status.is_empty() {
396 "unknown"
397 } else {
398 &self.status
399 },
400 if self.title.is_empty() {
401 "-"
402 } else {
403 &self.title
404 }
405 )
406 }
407}
408
409pub fn parse_cli_task_spec(raw: &str) -> anyhow::Result<CliTaskSpec> {
411 let spec = raw.trim();
412 if spec.is_empty() {
413 anyhow::bail!("empty task id");
414 }
415 if let Some(suffix) = spec.strip_prefix("internal:") {
416 let number: i64 = suffix
417 .parse()
418 .map_err(|_| anyhow::anyhow!("internal task id '{suffix}' is not numeric"))?;
419 if number <= 0 {
420 anyhow::bail!("internal task id must be a positive number");
421 }
422 return Ok(CliTaskSpec::Internal(number));
423 }
424 if let Some((repo, number)) = parse_repo_issue(spec) {
425 return Ok(CliTaskSpec::Issue { repo, number });
426 }
427 let bare = spec.strip_prefix('#').unwrap_or(spec);
428 if let Ok(number) = bare.parse::<i64>()
429 && number > 0
430 {
431 return Ok(CliTaskSpec::Bare(number));
432 }
433 anyhow::bail!("invalid task id '{spec}': use a number, owner/repo#N, or internal:N")
434}
435
436fn parse_repo_issue(spec: &str) -> Option<(String, i64)> {
437 let (repo, number) = spec.rsplit_once('#')?;
438 if repo.is_empty() {
439 return None;
440 }
441 let number: i64 = number.parse().ok()?;
442 if number <= 0 {
443 return None;
444 }
445 let mut parts = repo.split('/');
446 let owner = parts.next()?;
447 let name = parts.next()?;
448 if parts.next().is_some() || owner.is_empty() || name.is_empty() {
449 return None;
450 }
451 let ok = |part: &str| {
452 part.chars()
453 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
454 };
455 if !ok(owner) || !ok(name) {
456 return None;
457 }
458 Some((repo.to_string(), number))
459}
460
461fn ambiguous_cli_id_error(spec: &str, matches: &[CliTaskRef]) -> anyhow::Error {
462 let mut lines = vec![format!("task ID {spec} matches more than one task:")];
463 for task in matches {
464 lines.push(task.format_match());
465 }
466 lines.push(ambiguous_cli_id_hint(matches));
467 anyhow::anyhow!(lines.join("\n"))
468}
469
470fn ambiguous_cli_id_hint(matches: &[CliTaskRef]) -> String {
471 let github = matches
472 .iter()
473 .find(|task| !task.is_internal())
474 .map(CliTaskRef::display_spec);
475 let internal = matches
476 .iter()
477 .find(|task| task.is_internal())
478 .map(CliTaskRef::display_spec);
479 match (github, internal) {
480 (Some(github), Some(internal)) => {
481 format!("Use {github} for the GitHub task or {internal} for the internal one")
482 }
483 _ => "Use owner/repo#N for an issue, or internal:N for an internal task.".to_string(),
484 }
485}
486
487fn dedup_cli_refs(rows: Vec<SqliteRow>) -> anyhow::Result<Vec<CliTaskRef>> {
488 let mut seen = Vec::new();
489 let mut out = Vec::new();
490 for row in rows {
491 let task = CliTaskRef::from_row(&row)?;
492 if let Some(id) = task.store_id {
493 if seen.contains(&id) {
494 continue;
495 }
496 seen.push(id);
497 }
498 out.push(task);
499 }
500 Ok(out)
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct TaskRun {
506 pub id: i64,
507 pub task_id: i64,
508 pub attempt: i32,
509 pub run_type: String,
510 pub agent: String,
511 pub model: String,
512 pub command: String,
513 pub prompt: String,
514 pub env_vars: String,
515 pub exit_code: Option<i32>,
516 pub stdout: String,
517 pub stderr: String,
518 pub parsed_response: String,
519 pub outcome: String,
520 pub error: String,
521 pub input_tokens: i64,
522 pub output_tokens: i64,
523 pub total_cost_usd: f64,
524 pub duration_secs: f64,
525 pub started_at: String,
526 pub completed_at: Option<String>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct TaskActivity {
532 pub id: i64,
533 pub task_id: i64,
534 pub timestamp: String,
535 pub event_type: String,
536 pub from_status: Option<String>,
537 pub to_status: Option<String>,
538 pub agent: Option<String>,
539 pub model: Option<String>,
540 pub details: serde_json::Value,
541}
542
543#[derive(Debug, Clone, Copy, Default)]
545pub struct RunTokenUsage {
546 pub input_tokens: u64,
547 pub output_tokens: u64,
548 pub total_cost_usd: f64,
549 pub duration_secs: f64,
550}
551
552#[derive(Debug, Clone)]
554pub struct StartRun<'a> {
555 pub task_id: i64,
556 pub attempt: i32,
557 pub run_type: &'a str,
558 pub agent: &'a str,
559 pub model: &'a str,
560 pub command: &'a str,
561 pub prompt: &'a str,
562}
563
564#[derive(Debug, Clone)]
566pub struct CompleteRun<'a> {
567 pub run_id: i64,
568 pub exit_code: Option<i32>,
569 pub stdout: &'a str,
570 pub stderr: &'a str,
571 pub parsed: &'a str,
572 pub outcome: &'a str,
573 pub error: &'a str,
574 pub tokens: RunTokenUsage,
575}
576
577#[derive(Debug, Clone)]
579pub struct StoreRoute<'a> {
580 pub id: i64,
581 pub agent: &'a str,
582 pub model: Option<&'a str>,
583 pub complexity: &'a str,
584 pub estimate: u8,
586 pub reason: &'a str,
587 pub profile: &'a str,
588 pub skills: &'a str,
589}
590
591#[derive(Debug, Clone)]
593pub struct UpsertExternal<'a> {
594 pub repo: &'a str,
595 pub ext_id: &'a str,
596 pub title: &'a str,
597 pub body: &'a str,
598 pub author: &'a str,
599 pub url: &'a str,
600 pub labels: &'a [String],
601 pub origin: &'a str,
602}
603
604fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
605 labels
606 .iter()
607 .find_map(|label| label.strip_prefix("parent:"))
608 .filter(|parent| !parent.is_empty())
609}
610
611fn owned_updates(
612 updates: &[(&str, serde_json::Value)],
613) -> anyhow::Result<Vec<(String, serde_json::Value)>> {
614 updates
615 .iter()
616 .map(|(col, val)| {
617 anyhow::ensure!(
618 ALLOWED_FIELDS.contains(col),
619 "column {col} is not in the update allowlist"
620 );
621 Ok((col.to_string(), val.clone()))
622 })
623 .collect()
624}
625
626impl TaskStore {
627 #[expect(
629 clippy::too_many_arguments,
630 reason = "one argument per column of the activity row"
631 )]
632 pub async fn append_activity(
633 &self,
634 task_id: i64,
635 event_type: &str,
636 from_status: Option<&str>,
637 to_status: Option<&str>,
638 agent: Option<&str>,
639 model: Option<&str>,
640 details: Option<&serde_json::Value>,
641 ) -> anyhow::Result<()> {
642 let details_json = match details {
643 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
644 None => "{}".to_string(),
645 };
646 sqlx::query(
647 "INSERT INTO task_activity
648 (task_id, event_type, from_status, to_status, agent, model, details)
649 VALUES (?, ?, ?, ?, ?, ?, ?)",
650 )
651 .bind(task_id)
652 .bind(event_type)
653 .bind(from_status)
654 .bind(to_status)
655 .bind(agent)
656 .bind(model)
657 .bind(details_json)
658 .execute(&self.pool)
659 .await?;
660 Ok(())
661 }
662
663 pub async fn status_entered_at(
665 &self,
666 task_id: i64,
667 status: TaskStatus,
668 ) -> anyhow::Result<Option<String>> {
669 let row: Option<(String,)> = sqlx::query_as(
670 "SELECT timestamp FROM task_activity
671 WHERE task_id = ? AND event_type = 'status_change' AND to_status = ?
672 ORDER BY id DESC LIMIT 1",
673 )
674 .bind(task_id)
675 .bind(status.as_str())
676 .fetch_optional(&self.pool)
677 .await?;
678 Ok(row.map(|(ts,)| ts))
679 }
680
681 pub async fn in_review_entry(&self, task_id: i64) -> anyhow::Result<Option<(String, bool)>> {
683 let row: Option<(String, bool)> = sqlx::query_as(
684 "SELECT a.timestamp, EXISTS (
685 SELECT 1 FROM task_activity d
686 WHERE d.task_id = a.task_id AND d.event_type = 'review_decision' AND d.id > a.id
687 )
688 FROM task_activity a
689 WHERE a.task_id = ? AND a.event_type = 'status_change' AND a.to_status = 'in_review'
690 ORDER BY a.id DESC LIMIT 1",
691 )
692 .bind(task_id)
693 .fetch_optional(&self.pool)
694 .await?;
695 Ok(row)
696 }
697
698 pub async fn get_activity(
700 &self,
701 task_id: i64,
702 limit: Option<usize>,
703 ) -> anyhow::Result<Vec<TaskActivity>> {
704 let rows = if let Some(limit) = limit {
705 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
706 sqlx::query(sqlx::AssertSqlSafe(format!(
707 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
708 )))
709 .bind(task_id)
710 .bind(limit_i64)
711 .fetch_all(&self.pool)
712 .await?
713 } else {
714 sqlx::query(sqlx::AssertSqlSafe(format!(
715 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
716 )))
717 .bind(task_id)
718 .fetch_all(&self.pool)
719 .await?
720 };
721
722 rows.iter().map(Self::row_to_activity).collect()
723 }
724
725 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
726 self.write_int(Command::TaskCreate {
727 new: new.clone(),
728 internal: false,
729 })
730 .await
731 }
732
733 pub async fn create_internal(
738 &self,
739 repo: &str,
740 title: &str,
741 body: &str,
742 source: &str,
743 source_id: &str,
744 parent_id: Option<i64>,
745 ) -> anyhow::Result<i64> {
746 let new = NewTask {
747 external_id: None,
748 repo: repo.to_string(),
749 origin: "internal".to_string(),
750 title: title.to_string(),
751 body: body.to_string(),
752 source: source.to_string(),
753 source_id: source_id.to_string(),
754 author: String::new(),
755 url: String::new(),
756 labels: vec![],
757 parent_id,
758 };
759 self.write_int(Command::TaskCreate {
760 new,
761 internal: true,
762 })
763 .await
764 }
765
766 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
768 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
769 "SELECT {TASK_COLS} FROM tasks WHERE id = ?"
770 )))
771 .bind(id)
772 .fetch_optional(&self.pool)
773 .await?
774 .with_context(|| format!("task {id} not found"))?;
775
776 Self::row_to_task(&row)
777 }
778
779 pub async fn get_by_external_id(
781 &self,
782 repo: &str,
783 ext_id: &str,
784 ) -> anyhow::Result<Option<Task>> {
785 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
786 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
787 )))
788 .bind(repo)
789 .bind(ext_id)
790 .fetch_optional(&self.pool)
791 .await?;
792
793 match row {
794 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
795 None => Ok(None),
796 }
797 }
798
799 pub async fn resolve_id_by_external(
805 &self,
806 repo: &str,
807 ext_id: &str,
808 ) -> anyhow::Result<Option<i64>> {
809 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
810 .bind(repo)
811 .bind(ext_id)
812 .fetch_optional(&self.pool)
813 .await?;
814 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
815 Ok(id)
816 }
817
818 pub async fn resolve_id_by_worktree(&self, path: &str) -> anyhow::Result<Option<i64>> {
820 if path.is_empty() {
821 return Ok(None);
822 }
823
824 let row = sqlx::query("SELECT id FROM tasks WHERE worktree = ? LIMIT 1")
825 .bind(path)
826 .fetch_optional(&self.pool)
827 .await?;
828 Ok(row.map(|r| r.try_get::<i64, _>("id")).transpose()?)
829 }
830
831 pub async fn resolve_id_by_pr_number(
837 &self,
838 repo: &str,
839 pr_number: i32,
840 ) -> anyhow::Result<Option<i64>> {
841 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
842 .bind(repo)
843 .bind(pr_number)
844 .fetch_optional(&self.pool)
845 .await?;
846 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
847 Ok(id)
848 }
849
850 pub async fn get_agent_by_external_id(
853 &self,
854 repo: &str,
855 ext_id: &str,
856 ) -> anyhow::Result<Option<String>> {
857 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
858 .bind(repo)
859 .bind(ext_id)
860 .fetch_optional(&self.pool)
861 .await?;
862 let agent = row
863 .map(|r| r.try_get::<Option<String>, _>("agent"))
864 .transpose()?
865 .flatten();
866 Ok(agent)
867 }
868
869 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
871 let parent_id = match parent_external_id_from_labels(ext.labels) {
872 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
873 None => None,
874 };
875 self.write_int(Command::TaskUpsertExternal(UpsertExternalCmd {
876 repo: ext.repo.to_string(),
877 ext_id: ext.ext_id.to_string(),
878 title: ext.title.to_string(),
879 body: ext.body.to_string(),
880 author: ext.author.to_string(),
881 url: ext.url.to_string(),
882 labels: ext.labels.to_vec(),
883 origin: ext.origin.to_string(),
884 parent_id,
885 }))
886 .await
887 }
888
889 pub async fn update_status_and_fields(
894 &self,
895 id: i64,
896 status: TaskStatus,
897 updates: &[(&str, serde_json::Value)],
898 ) -> anyhow::Result<()> {
899 let mut updates = owned_updates(updates)?;
900 if self.replicates() {
902 let local: Vec<_> = updates
903 .iter()
904 .filter(|(col, _)| is_local_column(col))
905 .cloned()
906 .collect();
907 updates.retain(|(col, _)| !is_local_column(col));
908
909 if !local.is_empty() {
910 let mut tx = self.begin_write().await?;
911 apply::set_fields(&mut tx, &cluster::now_stamp(), id, &local, true).await?;
912 tx.commit().await?;
913 }
914 }
915
916 self.write(Command::TaskUpdateStatusAndFields {
917 id,
918 status,
919 updates,
920 })
921 .await
922 .map(|_| ())
923 }
924
925 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
927 self.write_bool(Command::TaskUpdateStatus {
928 id,
929 status,
930 expected: None,
931 })
932 .await
933 .map(|_| ())
934 }
935
936 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
938 self.write(Command::TaskUpdateExternalId {
939 id,
940 external_id: external_id.to_string(),
941 })
942 .await
943 .map(|_| ())
944 }
945
946 pub async fn update_status_if(
951 &self,
952 id: i64,
953 status: TaskStatus,
954 expected: TaskStatus,
955 ) -> anyhow::Result<bool> {
956 self.write_bool(Command::TaskUpdateStatus {
957 id,
958 status,
959 expected: Some(expected),
960 })
961 .await
962 }
963
964 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
972 self.write(Command::TaskTouch { id }).await.map(|_| ())
973 }
974
975 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
977 self.write(Command::TaskResetToNew { id }).await.map(|_| ())
978 }
979
980 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
982 let value = reason.map_or(serde_json::Value::Null, |r| {
983 serde_json::Value::String(r.to_string())
984 });
985 self.set_fields(id, &[("block_reason", value)]).await
986 }
987
988 pub async fn list_by_status(
990 &self,
991 repo: &str,
992 status: TaskStatus,
993 ) -> anyhow::Result<Vec<Task>> {
994 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
995 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
996 )))
997 .bind(repo)
998 .bind(status.as_str())
999 .fetch_all(&self.pool)
1000 .await?;
1001
1002 rows.iter().map(Self::row_to_task).collect()
1003 }
1004
1005 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
1007 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1008 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
1009 )))
1010 .bind(repo)
1011 .bind(parent_id)
1012 .fetch_all(&self.pool)
1013 .await?;
1014
1015 rows.iter().map(Self::row_to_task).collect()
1016 }
1017
1018 pub async fn list_external_by_status(
1020 &self,
1021 repo: &str,
1022 status: TaskStatus,
1023 ) -> anyhow::Result<Vec<Task>> {
1024 let rows = sqlx::query(
1025 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC")),
1026 )
1027 .bind(repo)
1028 .bind(status.as_str())
1029 .fetch_all(&self.pool)
1030 .await?;
1031
1032 rows.iter().map(Self::row_to_task).collect()
1033 }
1034
1035 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1037 self.list_by_status(repo, TaskStatus::New).await
1038 }
1039
1040 pub async fn list_internal_by_status(
1042 &self,
1043 repo: &str,
1044 status: TaskStatus,
1045 ) -> anyhow::Result<Vec<Task>> {
1046 let rows = sqlx::query(
1047 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC")),
1048 )
1049 .bind(repo)
1050 .bind(status.as_str())
1051 .fetch_all(&self.pool)
1052 .await?;
1053
1054 rows.iter().map(Self::row_to_task).collect()
1055 }
1056
1057 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1059 let rows = sqlx::query(
1060 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC")),
1061 )
1062 .bind(repo)
1063 .fetch_all(&self.pool)
1064 .await?;
1065
1066 rows.iter().map(Self::row_to_task).collect()
1067 }
1068
1069 pub async fn list_internal_by_source(
1072 &self,
1073 repo: &str,
1074 source: &str,
1075 ) -> anyhow::Result<Vec<Task>> {
1076 let rows = sqlx::query(
1077 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC")),
1078 )
1079 .bind(repo)
1080 .bind(source)
1081 .fetch_all(&self.pool)
1082 .await?;
1083
1084 rows.iter().map(Self::row_to_task).collect()
1085 }
1086
1087 pub async fn list_source_ids_by_source(
1092 &self,
1093 repo: &str,
1094 source: &str,
1095 since_days: i64,
1096 ) -> anyhow::Result<Vec<String>> {
1097 let cutoff = format!("-{since_days} days");
1098 let rows = sqlx::query(
1099 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1100 )
1101 .bind(repo)
1102 .bind(source)
1103 .bind(&cutoff)
1104 .fetch_all(&self.pool)
1105 .await?;
1106
1107 let mut ids = Vec::with_capacity(rows.len());
1108 for row in &rows {
1109 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1110 anyhow::anyhow!(
1111 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1112 )
1113 })?);
1114 }
1115 Ok(ids)
1116 }
1117
1118 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1120 let rows = sqlx::query(
1121 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC")),
1122 )
1123 .bind(repo)
1124 .fetch_all(&self.pool)
1125 .await?;
1126
1127 rows.iter().map(Self::row_to_task).collect()
1128 }
1129
1130 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1132 let rows = sqlx::query(
1133 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC")),
1134 )
1135 .bind(repo)
1136 .fetch_all(&self.pool)
1137 .await?;
1138
1139 rows.iter().map(Self::row_to_task).collect()
1140 }
1141
1142 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1144 let has_external = sqlx::query_scalar::<_, i32>(
1145 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1146 )
1147 .bind(repo)
1148 .fetch_optional(&self.pool)
1149 .await?
1150 .is_some();
1151 Ok(has_external)
1152 }
1153
1154 pub async fn existing_external_ids(&self, repo: &str) -> anyhow::Result<HashSet<String>> {
1158 let rows: Vec<(String,)> = sqlx::query_as(
1159 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1160 )
1161 .bind(repo)
1162 .fetch_all(&self.pool)
1163 .await?;
1164
1165 Ok(rows
1166 .into_iter()
1167 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1168 .collect())
1169 }
1170
1171 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1174 let rows = sqlx::query(
1175 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC")),
1176 )
1177 .bind(repo)
1178 .bind(cutoff_str)
1179 .fetch_all(&self.pool)
1180 .await?;
1181 rows.iter().map(Self::row_to_task).collect()
1182 }
1183
1184 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1186 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1187 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1188 )))
1189 .bind(repo)
1190 .fetch_all(&self.pool)
1191 .await?;
1192 rows.iter().map(Self::row_to_task).collect()
1193 }
1194
1195 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1197 let rows =
1198 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1199 .fetch_all(&self.pool)
1200 .await?;
1201 let mut repos = Vec::new();
1202 for row in &rows {
1203 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1204 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1205 })?);
1206 }
1207 Ok(repos)
1208 }
1209
1210 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1215 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1216 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1217 )))
1218 .fetch_all(&self.pool)
1219 .await?;
1220 rows.iter().map(Self::row_to_task).collect()
1221 }
1222
1223 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1227 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1228 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1229 )))
1230 .fetch_all(&self.pool)
1231 .await?;
1232 rows.iter().map(Self::row_to_task).collect()
1233 }
1234
1235 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1239 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1240 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1241 )))
1242 .bind(status.as_str())
1243 .fetch_all(&self.pool)
1244 .await?;
1245 rows.iter().map(Self::row_to_task).collect()
1246 }
1247
1248 pub async fn set_fields(
1251 &self,
1252 id: i64,
1253 updates: &[(&str, serde_json::Value)],
1254 ) -> anyhow::Result<()> {
1255 self.set_fields_split(id, updates, false).await
1256 }
1257
1258 pub async fn set_fields_silent(
1263 &self,
1264 id: i64,
1265 updates: &[(&str, serde_json::Value)],
1266 ) -> anyhow::Result<()> {
1267 self.set_fields_split(id, updates, true).await
1268 }
1269
1270 async fn set_fields_split(
1272 &self,
1273 id: i64,
1274 updates: &[(&str, serde_json::Value)],
1275 silent: bool,
1276 ) -> anyhow::Result<()> {
1277 let all = owned_updates(updates)?;
1278 if !self.replicates() {
1280 let mut tx = self.begin_write().await?;
1281 apply::set_fields(&mut tx, &cluster::now_stamp(), id, &all, silent).await?;
1282 tx.commit().await?;
1283 return Ok(());
1284 }
1285
1286 let (local, replicated): (Vec<_>, Vec<_>) =
1287 all.into_iter().partition(|(col, _)| is_local_column(col));
1288 if !local.is_empty() {
1289 let mut tx = self.begin_write().await?;
1290 apply::set_fields(&mut tx, &cluster::now_stamp(), id, &local, silent).await?;
1291 tx.commit().await?;
1292 }
1293
1294 if replicated.is_empty() {
1295 return Ok(());
1296 }
1297
1298 self.write(Command::TaskSetFields {
1299 id,
1300 updates: replicated,
1301 silent,
1302 })
1303 .await
1304 .map(|_| ())
1305 }
1306
1307 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1309 anyhow::ensure!(
1310 INCREMENTABLE_FIELDS.contains(&field),
1311 "field {field} is not incrementable"
1312 );
1313 let n = self
1314 .write_int(Command::TaskIncrement {
1315 id,
1316 field: field.to_string(),
1317 })
1318 .await?;
1319 Ok(i32::try_from(n)?)
1320 }
1321
1322 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1324 self.write(Command::TaskResetCounters {
1325 id,
1326 failure_only: false,
1327 })
1328 .await
1329 .map(|_| ())
1330 }
1331
1332 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1347 self.write(Command::TaskResetCounters {
1348 id,
1349 failure_only: true,
1350 })
1351 .await
1352 .map(|_| ())
1353 }
1354
1355 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1361 self.write(Command::TaskStoreRoute(StoreRouteCmd {
1362 id: route.id,
1363 agent: route.agent.to_string(),
1364 model: route.model.map(str::to_string),
1365 complexity: route.complexity.to_string(),
1366 estimate: route.estimate,
1367 reason: route.reason.to_string(),
1368 profile: route.profile.to_string(),
1369 skills: route.skills.to_string(),
1370 }))
1371 .await
1372 .map(|_| ())
1373 }
1374
1375 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1383 let rows = sqlx::query(
1384 "SELECT external_id, estimate FROM tasks \
1385 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1386 AND status NOT IN ('done', 'blocked')",
1387 )
1388 .fetch_all(&self.pool)
1389 .await?;
1390
1391 let mut result = Vec::new();
1392 for row in &rows {
1393 let external_id: String = row.try_get("external_id")?;
1394 let estimate_raw: i64 = row.try_get("estimate")?;
1395 if let Ok(estimate @ 1..) = u8::try_from(estimate_raw) {
1396 result.push((external_id, estimate));
1397 }
1398 }
1399 Ok(result)
1400 }
1401
1402 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1408 self.write_bool(Command::TaskSetEstimateIfZero { id, estimate })
1409 .await
1410 }
1411
1412 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1421 self.write(Command::TaskAppendMemory {
1422 id,
1423 entry: entry.clone(),
1424 })
1425 .await
1426 .map(|_| ())
1427 }
1428
1429 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1431 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1432 .bind(id)
1433 .fetch_one(&self.pool)
1434 .await?;
1435
1436 let memory_opt: Option<String> = row
1437 .try_get::<Option<String>, _>("memory")
1438 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1439 let memory_str = memory_opt.unwrap_or_default();
1440
1441 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1442 Vec::new()
1443 } else {
1444 serde_json::from_str(&memory_str)
1445 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1446 };
1447
1448 memory.sort_by_key(|m| m.attempt);
1449 if memory.len() > max {
1450 memory = memory.split_off(memory.len() - max);
1451 }
1452 Ok(memory)
1453 }
1454
1455 pub async fn store_tokens(
1461 &self,
1462 id: i64,
1463 input: u64,
1464 output: u64,
1465 model: &str,
1466 ) -> anyhow::Result<()> {
1467 let pricing = pricing_for_model(model);
1468 let usage = TokenUsage {
1469 input_tokens: input,
1470 output_tokens: output,
1471 };
1472 let cost = pricing.estimate_cost_usd(usage);
1473
1474 sqlx::query(
1475 "UPDATE tasks SET
1476 input_tokens = COALESCE(input_tokens, 0) + ?,
1477 output_tokens = COALESCE(output_tokens, 0) + ?,
1478 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1479 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1480 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1481 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1482 WHERE id = ?",
1483 )
1484 .bind(i64::try_from(input).unwrap_or_else(|_| {
1485 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1486 i64::MAX
1487 }))
1488 .bind(i64::try_from(output).unwrap_or_else(|_| {
1489 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1490 i64::MAX
1491 }))
1492 .bind(cost.input_cost_usd)
1493 .bind(cost.output_cost_usd)
1494 .bind(cost.total_cost_usd)
1495 .bind(id)
1496 .execute(&self.pool)
1497 .await?;
1498
1499 Ok(())
1500 }
1501
1502 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1508 self.write(Command::TaskMarkCleaned { id })
1509 .await
1510 .map(|_| ())
1511 }
1512
1513 #[cfg(test)]
1519 pub async fn batch_set_fields(
1520 &self,
1521 updates: &[(i64, &[(&str, serde_json::Value)])],
1522 ) -> anyhow::Result<()> {
1523 if updates.is_empty() {
1524 return Ok(());
1525 }
1526
1527 for (_, entry_updates) in updates {
1528 for (col, _) in *entry_updates {
1529 anyhow::ensure!(
1530 ALLOWED_FIELDS.contains(col),
1531 "column {col} is not in the update allowlist"
1532 );
1533 }
1534 }
1535
1536 let mut tx = self.begin_write().await?;
1537 for (id, entry_updates) in updates {
1538 if entry_updates.is_empty() {
1539 continue;
1540 }
1541 let mut set_parts = Vec::new();
1542 let mut values: Vec<Option<String>> = Vec::new();
1543 for (col, val) in *entry_updates {
1544 set_parts.push(format!("{col} = ?"));
1545 values.push(sql_value_for_task_field(col, val)?);
1546 }
1547 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1548 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1549 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1550 for v in &values {
1551 query = query.bind(v.as_deref());
1552 }
1553 query = query.bind(*id);
1554 query.execute(&mut *tx).await?;
1555 }
1556 tx.commit().await?;
1557 Ok(())
1558 }
1559
1560 #[cfg(test)]
1565 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1566 if entries.is_empty() {
1567 return Ok(());
1568 }
1569
1570 for (_, field) in entries {
1571 anyhow::ensure!(
1572 INCREMENTABLE_FIELDS.contains(field),
1573 "field {field} is not incrementable"
1574 );
1575 }
1576
1577 let mut tx = self.begin_write().await?;
1578 for (id, field) in entries {
1579 let sql = format!(
1580 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1581 );
1582 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
1583 .bind(*id)
1584 .execute(&mut *tx)
1585 .await?;
1586 }
1587 tx.commit().await?;
1588 Ok(())
1589 }
1590
1591 #[cfg(test)]
1598 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1599 const CHUNK_SIZE: usize = 500;
1602 if ids.is_empty() {
1603 return Ok(());
1604 }
1605
1606 let mut tx = self.begin_write().await?;
1607
1608 for chunk in ids.chunks(CHUNK_SIZE) {
1609 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1611 let sql = format!(
1612 "UPDATE tasks SET
1613 route_attempts = 0,
1614 review_agent_failures = 0,
1615 review_resets = 0,
1616 review_invocations = 0,
1617 pr_create_failures = 0,
1618 push_failures = 0,
1619 network_retries = 0,
1620 timeout_retries = 0,
1621 auto_unblock_count = 0,
1622 auto_unblock_last_at = '',
1623 auto_unblock_last_reason = '',
1624 ci_recovery_count = 0,
1625 no_code_reroutes = 0,
1626 needs_review_refires = 0,
1627 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1628 WHERE id IN ({placeholders})",
1629 );
1630 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1631 for id in chunk {
1632 query = query.bind(*id);
1633 }
1634 query.execute(&mut *tx).await?;
1635 }
1636
1637 tx.commit().await?;
1638 Ok(())
1639 }
1640
1641 #[cfg(test)]
1646 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1647 const CHUNK_SIZE: usize = 500;
1649 if ids.is_empty() {
1650 return Ok(());
1651 }
1652
1653 let mut tx = self.begin_write().await?;
1654
1655 for chunk in ids.chunks(CHUNK_SIZE) {
1656 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1657 let sql = format!(
1658 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1659 );
1660 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1661 for id in chunk {
1662 query = query.bind(*id);
1663 }
1664 query.execute(&mut *tx).await?;
1665 }
1666
1667 let details = serde_json::json!({ "worktree_cleaned": true });
1671 let details_json = serde_json::to_string(&details)?;
1672 for id in ids {
1673 sqlx::query(
1674 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1675 )
1676 .bind(*id)
1677 .bind("branch_delete")
1678 .bind(None::<&str>)
1679 .bind(None::<&str>)
1680 .bind(None::<&str>)
1681 .bind(None::<&str>)
1682 .bind(details_json.as_str())
1683 .execute(&mut *tx)
1684 .await?;
1685 }
1686
1687 tx.commit().await?;
1688 Ok(())
1689 }
1690
1691 #[cfg(test)]
1694 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1695 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1696 "SELECT {TASK_COLS} FROM tasks
1697 WHERE repo = ?
1698 AND worktree != ''
1699 AND worktree_cleaned = 0
1700 AND status IN ('done', 'blocked')
1701 ORDER BY updated_at ASC"
1702 )))
1703 .bind(repo)
1704 .fetch_all(&self.pool)
1705 .await?;
1706
1707 rows.iter().map(Self::row_to_task).collect()
1708 }
1709
1710 pub async fn cleaned_external_ids(&self, repo: &str) -> anyhow::Result<HashSet<String>> {
1715 let rows: Vec<(String,)> =
1716 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1717 .bind(repo)
1718 .fetch_all(&self.pool)
1719 .await?;
1720
1721 Ok(rows.into_iter().map(|(id,)| id).collect())
1722 }
1723
1724 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1730 let orphaned: Vec<(i64,)> = sqlx::query_as(
1742 "SELECT id FROM task_runs
1743 WHERE task_id = ? AND run_type = ? AND attempt != ?
1744 AND outcome IS NULL AND completed_at IS NULL",
1745 )
1746 .bind(run.task_id)
1747 .bind(run.run_type)
1748 .bind(run.attempt)
1749 .fetch_all(&self.pool)
1750 .await?;
1751
1752 for (run_id,) in orphaned {
1753 self.complete_run(&CompleteRun {
1754 run_id,
1755 exit_code: Some(-1),
1756 stdout: "",
1757 stderr: "",
1758 parsed: "",
1759 outcome: "aborted",
1760 error: "superseded by a new run start before this one completed",
1761 tokens: RunTokenUsage::default(),
1762 })
1763 .await?;
1764 }
1765
1766 let row = sqlx::query(
1767 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
1768 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
1769 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
1770 agent = excluded.agent, model = excluded.model,
1771 command = excluded.command, prompt = excluded.prompt,
1772 outcome = NULL,
1773 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
1774 completed_at = NULL, error = NULL, exit_code = NULL,
1775 stdout = NULL, stderr = NULL, parsed_response = NULL,
1776 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
1777 total_cost_usd = NULL
1778 RETURNING id",
1779 )
1780 .bind(run.task_id)
1781 .bind(run.attempt)
1782 .bind(run.run_type)
1783 .bind(run.agent)
1784 .bind(run.model)
1785 .bind(run.command)
1786 .bind(run.prompt)
1787 .fetch_one(&self.pool)
1788 .await?;
1789
1790 let run_id: i64 = row
1791 .try_get("id")
1792 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
1793 let event_type = match run.run_type {
1794 "review" => "review_start",
1795 _ => "dispatch",
1796 };
1797 let details = serde_json::json!({
1798 "run_type": run.run_type,
1799 "attempt": run.attempt,
1800 "command": run.command,
1801 });
1802 self.append_activity(
1803 run.task_id,
1804 event_type,
1805 None,
1806 None,
1807 Some(run.agent),
1808 Some(run.model),
1809 Some(&details),
1810 )
1811 .await?;
1812
1813 Ok(run_id)
1814 }
1815
1816 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
1818 let run_ctx =
1819 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
1820 .bind(run.run_id)
1821 .fetch_optional(&self.pool)
1822 .await?;
1823 sqlx::query(
1824 "UPDATE task_runs SET
1825 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
1826 outcome = ?, error = NULLIF(?, ''),
1827 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
1828 duration_secs = CASE WHEN ? > 0 THEN ?
1829 ELSE MAX(0, (julianday('now') - julianday(started_at)) * 86400.0) END,
1830 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1831 WHERE id = ?",
1832 )
1833 .bind(run.exit_code)
1834 .bind(run.stdout)
1835 .bind(security::redact(run.stderr))
1836 .bind(run.parsed)
1837 .bind(run.outcome)
1838 .bind(security::redact(run.error))
1839 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1840 tracing::warn!(
1841 run.tokens.input_tokens,
1842 "input_tokens overflows i64, clamping to MAX"
1843 );
1844 i64::MAX
1845 }))
1846 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1847 tracing::warn!(
1848 run.tokens.output_tokens,
1849 "output_tokens overflows i64, clamping to MAX"
1850 );
1851 i64::MAX
1852 }))
1853 .bind(run.tokens.total_cost_usd)
1854 .bind(run.tokens.duration_secs)
1855 .bind(run.tokens.duration_secs)
1856 .bind(run.run_id)
1857 .execute(&self.pool)
1858 .await?;
1859 if let Some(run_ctx) = run_ctx {
1860 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1861 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1862 })?;
1863 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1864 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1865 })?;
1866 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1867 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1868 })?;
1869 let model: String = run_ctx.try_get("model").map_err(|e| {
1870 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1871 })?;
1872 if run.outcome == "timeout" {
1873 self.append_activity(
1874 task_id,
1875 "timeout",
1876 None,
1877 None,
1878 Some(&agent),
1879 Some(&model),
1880 Some(&serde_json::json!({
1881 "run_type": run_type,
1882 "error": run.error,
1883 "stderr": run.stderr,
1884 })),
1885 )
1886 .await?;
1887 } else if run.outcome != "success" {
1888 self.append_activity(
1889 task_id,
1890 "error",
1891 None,
1892 None,
1893 Some(&agent),
1894 Some(&model),
1895 Some(&serde_json::json!({
1896 "run_type": run_type,
1897 "outcome": run.outcome,
1898 "error": run.error,
1899 })),
1900 )
1901 .await?;
1902 }
1903 } else if run.outcome != "success" {
1904 tracing::warn!(
1905 run_id = run.run_id,
1906 outcome = run.outcome,
1907 "run_id not found in task_runs; skipping timeout/error activity log"
1908 );
1909 }
1910 Ok(())
1911 }
1912
1913 pub async fn finalize_incomplete_runs(
1918 &self,
1919 task_id: i64,
1920 outcome: &str,
1921 error: &str,
1922 ) -> anyhow::Result<u64> {
1923 let rows: Vec<(i64,)> = sqlx::query_as(
1924 "SELECT id FROM task_runs
1925 WHERE task_id = ?
1926 AND outcome IS NULL
1927 AND completed_at IS NULL
1928 ORDER BY id ASC",
1929 )
1930 .bind(task_id)
1931 .fetch_all(&self.pool)
1932 .await?;
1933
1934 for (run_id,) in &rows {
1935 self.complete_run(&CompleteRun {
1936 run_id: *run_id,
1937 exit_code: Some(-1),
1938 stdout: "",
1939 stderr: "",
1940 parsed: "",
1941 outcome,
1942 error,
1943 tokens: RunTokenUsage::default(),
1944 })
1945 .await?;
1946 }
1947
1948 Ok(rows.len() as u64)
1949 }
1950
1951 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
1953 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1954 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
1955 )))
1956 .bind(task_id)
1957 .fetch_all(&self.pool)
1958 .await?;
1959
1960 rows.iter().map(Self::row_to_run).collect()
1961 }
1962
1963 pub async fn get_runs_batch(
1966 &self,
1967 task_ids: &[i64],
1968 ) -> anyhow::Result<HashMap<i64, Vec<TaskRun>>> {
1969 const CHUNK_SIZE: usize = 500;
1972 if task_ids.is_empty() {
1973 return Ok(HashMap::new());
1974 }
1975
1976 let mut result: HashMap<i64, Vec<TaskRun>> = HashMap::new();
1977
1978 for chunk in task_ids.chunks(CHUNK_SIZE) {
1979 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1980 let sql = format!(
1981 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
1982 );
1983 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1984 for id in chunk {
1985 query = query.bind(*id);
1986 }
1987 let rows = query.fetch_all(&self.pool).await?;
1988 for row in &rows {
1989 let run = Self::row_to_run(row)?;
1990 result.entry(run.task_id).or_default().push(run);
1991 }
1992 }
1993 Ok(result)
1994 }
1995
1996 pub async fn get_batch(&self, ids: &[i64]) -> anyhow::Result<HashMap<i64, Task>> {
1999 const CHUNK_SIZE: usize = 500;
2002 if ids.is_empty() {
2003 return Ok(HashMap::new());
2004 }
2005
2006 let mut result = HashMap::new();
2007
2008 for chunk in ids.chunks(CHUNK_SIZE) {
2009 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2010 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2011 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2012 for id in chunk {
2013 query = query.bind(*id);
2014 }
2015 let rows = query.fetch_all(&self.pool).await?;
2016 for row in &rows {
2017 let task = Self::row_to_task(row)?;
2018 result.insert(task.id, task);
2019 }
2020 }
2021 Ok(result)
2022 }
2023
2024 pub async fn get_statuses_by_external_ids(
2029 &self,
2030 repo: &str,
2031 ext_ids: &[&str],
2032 ) -> anyhow::Result<HashMap<String, TaskStatus>> {
2033 const CHUNK_SIZE: usize = 500;
2034 let mut result = HashMap::new();
2035 if ext_ids.is_empty() {
2036 return Ok(result);
2037 }
2038
2039 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2040 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2041 let sql = format!(
2042 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2043 );
2044 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2045 query = query.bind(repo);
2046 for id in chunk {
2047 query = query.bind(id);
2048 }
2049 let rows = query.fetch_all(&self.pool).await?;
2050 for row in &rows {
2051 let external_id: Result<String, _> = row.try_get("external_id");
2052 let status_str: Result<String, _> = row.try_get("status");
2053 match (external_id, status_str) {
2054 (Ok(ext_id), Ok(sts)) => {
2055 if ext_id.is_empty() {
2056 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2057 continue;
2058 }
2059 if let Some(status) = TaskStatus::from_str(&sts) {
2060 result.insert(ext_id, status);
2061 } else {
2062 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2063 }
2064 }
2065 (Err(e), _) => {
2066 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2067 }
2068 (_, Err(e)) => {
2069 tracing::warn!(error = %e, "skipping task row with decode error for status");
2070 }
2071 }
2072 }
2073 }
2074
2075 Ok(result)
2076 }
2077
2078 #[cfg(test)]
2081 pub async fn get_last_run(
2082 &self,
2083 task_id: i64,
2084 run_type: &str,
2085 ) -> anyhow::Result<Option<TaskRun>> {
2086 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
2087 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2088 )))
2089 .bind(task_id)
2090 .bind(run_type)
2091 .fetch_optional(&self.pool)
2092 .await?;
2093
2094 match row {
2095 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2096 None => Ok(None),
2097 }
2098 }
2099
2100 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2104 let rows: Vec<(String,)> = sqlx::query_as(
2105 "SELECT DISTINCT agent FROM task_runs \
2106 WHERE task_id = ? AND run_type = 'review' \
2107 ORDER BY agent",
2108 )
2109 .bind(task_id)
2110 .fetch_all(&self.pool)
2111 .await?;
2112 Ok(rows.into_iter().map(|(a,)| a).collect())
2113 }
2114
2115 #[cfg(test)]
2118 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2119 let result = sqlx::query(
2120 "DELETE FROM task_runs WHERE task_id IN (
2121 SELECT id FROM tasks
2122 WHERE status IN ('done', 'blocked')
2123 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2124 )",
2125 )
2126 .bind(format!("-{days}"))
2127 .execute(&self.pool)
2128 .await?;
2129
2130 Ok(result.rows_affected())
2131 }
2132
2133 fn row_to_task(row: &SqliteRow) -> anyhow::Result<Task> {
2138 let col_count = row.len();
2141 if col_count != TASK_COLS_COUNT {
2142 tracing::warn!(
2143 expected = TASK_COLS_COUNT,
2144 actual = col_count,
2145 "TASK_COLS column count mismatch - schema may have changed"
2146 );
2147 }
2148
2149 let status_str: String = row
2150 .try_get("status")
2151 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2152 let labels_str: String = row.try_get("labels").unwrap_or_default();
2153 let memory_str: String = row.try_get("memory").unwrap_or_default();
2154 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2155
2156 Ok(Task {
2157 id: row
2158 .try_get("id")
2159 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2160 external_id: row.try_get("external_id").unwrap_or(None),
2161 repo: row
2162 .try_get("repo")
2163 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2164 origin: row
2165 .try_get("origin")
2166 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2167 title: row
2168 .try_get("title")
2169 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2170 body: row
2171 .try_get("body")
2172 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2173 status: TaskStatus::from_str(&status_str)
2174 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2175 source: row
2176 .try_get("source")
2177 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2178 source_id: row
2179 .try_get("source_id")
2180 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2181 author: row
2182 .try_get("author")
2183 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2184 url: row
2185 .try_get("url")
2186 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2187 labels: serde_json::from_str(&labels_str)
2188 .inspect_err(
2189 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2190 )
2191 .unwrap_or_default(),
2192 agent: row.try_get("agent").unwrap_or(None),
2193 model: row.try_get("model").unwrap_or(None),
2194 complexity: row.try_get("complexity").unwrap_or_default(),
2195 estimate: {
2196 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2197 u8::try_from(raw).unwrap_or_else(|_| {
2198 tracing::warn!(
2199 raw_estimate = raw,
2200 "estimate value out of u8 range; defaulting to 0"
2201 );
2202 0
2203 })
2204 },
2205 route_reason: row.try_get("route_reason").unwrap_or_default(),
2206 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2207 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2208 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2209 attempts: row.try_get("attempts").unwrap_or(0),
2210 branch: row.try_get("branch").unwrap_or_default(),
2211 worktree: row.try_get("worktree").unwrap_or_default(),
2212 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2213 owner_node: row.try_get("owner_node").unwrap_or(None),
2214 summary: row.try_get("summary").unwrap_or_default(),
2215 last_error: row.try_get("last_error").unwrap_or_default(),
2216 parent_id: row.try_get("parent_id").unwrap_or(None),
2217 block_reason: row.try_get("block_reason").unwrap_or(None),
2218 pr_number: row.try_get("pr_number").unwrap_or(None),
2219 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2220 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2221 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2222 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2223 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2224 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2225 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2226 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2227 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2228 timeout_retries: row.try_get::<i32, _>("timeout_retries").unwrap_or(0),
2229 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2230 review_resets: row.try_get::<i32, _>("review_resets").unwrap_or(0),
2231 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2232 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2233 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2234 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2235 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2236 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2237 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2238 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2239 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2240 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2241 memory: serde_json::from_str(&memory_str)
2242 .inspect_err(
2243 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2244 )
2245 .unwrap_or_default(),
2246 delegations: serde_json::from_str(&delegations_str)
2247 .inspect_err(
2248 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2249 )
2250 .unwrap_or_default(),
2251 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2256 auto_unblock_last_at: row
2257 .try_get::<String, _>("auto_unblock_last_at")
2258 .unwrap_or_default(),
2259 auto_unblock_last_reason: row
2260 .try_get::<String, _>("auto_unblock_last_reason")
2261 .unwrap_or_default(),
2262 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2263 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2264 no_code_last_agent: row
2265 .try_get::<String, _>("no_code_last_agent")
2266 .unwrap_or_default(),
2267 created_at: row
2268 .try_get::<String, _>("created_at")
2269 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2270 updated_at: row
2271 .try_get::<String, _>("updated_at")
2272 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2273 })
2274 }
2275 pub async fn ensure_external_task(
2276 &self,
2277 repo: &str,
2278 ext: &ExternalTask,
2279 ) -> anyhow::Result<i64> {
2280 self.upsert_external(&UpsertExternal {
2281 repo,
2282 ext_id: &ext.id.0,
2283 title: &ext.title,
2284 body: &ext.body,
2285 author: &ext.author,
2286 url: &ext.url,
2287 labels: &ext.labels,
2288 origin: "github",
2289 })
2290 .await
2291 }
2292
2293 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2296 if let Some(suffix) = task_id.strip_prefix("internal:") {
2297 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2299 return Ok(Some(id));
2300 }
2301 if let Ok(id) = suffix.parse::<i64>() {
2305 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2306 .bind(id)
2307 .bind(repo)
2308 .fetch_optional(&self.pool)
2309 .await?;
2310 if exists.is_some() {
2311 return Ok(Some(id));
2312 }
2313 }
2314 return Ok(None);
2315 }
2316 self.resolve_id_by_external(repo, task_id).await
2319 }
2320
2321 pub async fn resolve_task_id_globally(
2327 &self,
2328 task_id: &str,
2329 ) -> anyhow::Result<Option<(i64, String)>> {
2330 if let Some(suffix) = task_id.strip_prefix("internal:") {
2331 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2333 .bind(task_id)
2334 .fetch_optional(&self.pool)
2335 .await?;
2336 if let Some(r) = row {
2337 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2338 }
2339 if let Ok(id) = suffix.parse::<i64>() {
2340 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2341 .bind(id)
2342 .fetch_optional(&self.pool)
2343 .await?;
2344 if let Some(r) = row {
2345 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2346 }
2347 }
2348 return Ok(None);
2349 }
2350 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2352 .bind(task_id)
2353 .fetch_all(&self.pool)
2354 .await?;
2355 match rows.len() {
2356 0 => Ok(None),
2357 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2358 _ => {
2359 let repos: Vec<String> = rows
2360 .iter()
2361 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2362 .collect();
2363 anyhow::bail!(
2364 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2365 Run from the repo worktree so the current-repo lookup resolves it.",
2366 repos.join(", ")
2367 )
2368 }
2369 }
2370 }
2371
2372 pub async fn resolve_cli_id(
2374 &self,
2375 spec: &str,
2376 current_repo: Option<&str>,
2377 ) -> anyhow::Result<Option<CliTaskRef>> {
2378 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2379 match parse_cli_task_spec(spec)? {
2380 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2381 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2382 CliTaskSpec::Bare(number) => {
2383 if let Some(repo) = current_repo {
2384 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2385 return Ok(Some(task));
2386 }
2387 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2388 return Ok(Some(task));
2389 }
2390 return Ok(None);
2391 }
2392 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2393 match matches.len() {
2394 0 => Ok(None),
2395 1 => Ok(matches.pop()),
2396 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2397 }
2398 }
2399 }
2400 }
2401
2402 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2403 let ext = format!("internal:{number}");
2404 let rows = sqlx::query(
2405 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2406 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2407 ORDER BY id",
2408 )
2409 .bind(number)
2410 .bind(&ext)
2411 .fetch_all(&self.pool)
2412 .await?;
2413 let mut matches = dedup_cli_refs(rows)?;
2414 match matches.len() {
2415 0 => Ok(None),
2416 1 => Ok(matches.pop()),
2417 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2418 }
2419 }
2420
2421 async fn lookup_internal_in_repo(
2422 &self,
2423 number: i64,
2424 repo: &str,
2425 ) -> anyhow::Result<Option<CliTaskRef>> {
2426 let row = sqlx::query(
2427 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2428 WHERE origin = 'internal' AND id = ? AND repo = ?",
2429 )
2430 .bind(number)
2431 .bind(repo)
2432 .fetch_optional(&self.pool)
2433 .await?;
2434 match row {
2435 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2436 None => Ok(None),
2437 }
2438 }
2439
2440 async fn lookup_issue_cli_id(
2441 &self,
2442 repo: &str,
2443 number: i64,
2444 ) -> anyhow::Result<Option<CliTaskRef>> {
2445 let ext = number.to_string();
2446 let rows = sqlx::query(
2447 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2448 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2449 ORDER BY id",
2450 )
2451 .bind(repo)
2452 .bind(&ext)
2453 .fetch_all(&self.pool)
2454 .await?;
2455 let mut matches = dedup_cli_refs(rows)?;
2456 match matches.len() {
2457 0 => Ok(None),
2458 1 => Ok(matches.pop()),
2459 _ => Err(ambiguous_cli_id_error(
2460 &format!("{repo}#{number}"),
2461 &matches,
2462 )),
2463 }
2464 }
2465
2466 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2467 let ext = number.to_string();
2468 let rows = sqlx::query(
2469 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2470 WHERE (origin = 'internal' AND id = ?) \
2471 OR (origin != 'internal' AND external_id = ?) \
2472 ORDER BY repo, id",
2473 )
2474 .bind(number)
2475 .bind(&ext)
2476 .fetch_all(&self.pool)
2477 .await?;
2478 dedup_cli_refs(rows)
2479 }
2480
2481 fn row_to_run(row: &SqliteRow) -> anyhow::Result<TaskRun> {
2482 Ok(TaskRun {
2483 id: row
2484 .try_get("id")
2485 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2486 task_id: row
2487 .try_get("task_id")
2488 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2489 attempt: row
2490 .try_get("attempt")
2491 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2492 run_type: row
2493 .try_get("run_type")
2494 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2495 agent: row
2496 .try_get("agent")
2497 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2498 model: row
2499 .try_get("model")
2500 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2501 command: row
2502 .try_get("command")
2503 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2504 prompt: row
2505 .try_get("prompt")
2506 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2507 env_vars: row
2508 .try_get("env_vars")
2509 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2510 exit_code: row
2511 .try_get("exit_code")
2512 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2513 stdout: row
2514 .try_get("stdout")
2515 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2516 stderr: row
2517 .try_get("stderr")
2518 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2519 parsed_response: row
2520 .try_get("parsed_response")
2521 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2522 outcome: row
2523 .try_get("outcome")
2524 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2525 error: row
2526 .try_get("error")
2527 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2528 input_tokens: row
2529 .try_get("input_tokens")
2530 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2531 output_tokens: row
2532 .try_get("output_tokens")
2533 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2534 total_cost_usd: row
2535 .try_get("total_cost_usd")
2536 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2537 duration_secs: row
2538 .try_get("duration_secs")
2539 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2540 started_at: row
2541 .try_get("started_at")
2542 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2543 completed_at: row.try_get("completed_at").unwrap_or(None),
2544 })
2545 }
2546
2547 fn row_to_activity(row: &SqliteRow) -> anyhow::Result<TaskActivity> {
2548 let details_str: String = row
2549 .try_get("details")
2550 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2551 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2552 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2553 )?;
2554 Ok(TaskActivity {
2555 id: row
2556 .try_get("id")
2557 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2558 task_id: row
2559 .try_get("task_id")
2560 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2561 timestamp: row
2562 .try_get("timestamp")
2563 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2564 event_type: row
2565 .try_get("event_type")
2566 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2567 from_status: row
2568 .try_get("from_status")
2569 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2570 to_status: row
2571 .try_get("to_status")
2572 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2573 agent: row
2574 .try_get("agent")
2575 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2576 model: row
2577 .try_get("model")
2578 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2579 details,
2580 })
2581 }
2582}
2583
2584const LOCAL_TASK_COLUMNS: &[&str] = &[
2586 "worktree",
2587 "input_tokens",
2588 "output_tokens",
2589 "input_cost_usd",
2590 "output_cost_usd",
2591 "total_cost_usd",
2592];
2593
2594pub(crate) fn is_local_column(col: &str) -> bool {
2595 LOCAL_TASK_COLUMNS.contains(&col)
2596}
2597
2598#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2600pub struct UpsertExternalCmd {
2601 pub repo: String,
2602 pub ext_id: String,
2603 pub title: String,
2604 pub body: String,
2605 pub author: String,
2606 pub url: String,
2607 pub labels: Vec<String>,
2608 pub origin: String,
2609 pub parent_id: Option<i64>,
2610}
2611
2612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2614pub struct StoreRouteCmd {
2615 pub id: i64,
2616 pub agent: String,
2617 pub model: Option<String>,
2618 pub complexity: String,
2619 pub estimate: u8,
2620 pub reason: String,
2621 pub profile: String,
2622 pub skills: String,
2623}
2624
2625const TOMBSTONE_COLUMNS: &[&str] = &[
2627 "id",
2628 "external_id",
2629 "repo",
2630 "origin",
2631 "title",
2632 "status",
2633 "pr_number",
2634 "parent_id",
2635 "created_at",
2636 "updated_at",
2637];
2638
2639async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2640 let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2641 .fetch_all(conn)
2642 .await?;
2643 Ok(cols
2644 .into_iter()
2645 .map(|(c,)| c)
2646 .filter(|c| !is_local_column(c))
2647 .collect())
2648}
2649
2650pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2652 let mut conn = pool.acquire().await?;
2653 let cols = replicated_columns(&mut conn).await?;
2654 let full: Vec<&String> = cols.iter().collect();
2655 let tomb: Vec<&String> = cols
2656 .iter()
2657 .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2658 .collect();
2659 let sql = format!(
2660 "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2661 super::replica::json_row_sql(&tomb),
2662 super::replica::json_row_sql(&full)
2663 );
2664 let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
2665 .fetch_all(&mut *conn)
2666 .await?;
2667 rows.into_iter()
2668 .map(|(r,)| Ok(serde_json::from_str(&r)?))
2669 .collect()
2670}
2671
2672pub(crate) async fn replace_replicated(
2675 conn: &mut sqlx::SqliteConnection,
2676 rows: &[serde_json::Value],
2677) -> anyhow::Result<()> {
2678 let allowed = replicated_columns(conn).await?;
2679 sqlx::query("PRAGMA defer_foreign_keys = ON")
2680 .execute(&mut *conn)
2681 .await?;
2682 sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2683 .execute(&mut *conn)
2684 .await?;
2685 sqlx::query("DELETE FROM snapshot_ids")
2686 .execute(&mut *conn)
2687 .await?;
2688 for row in rows {
2689 let id = row["id"]
2690 .as_i64()
2691 .context("snapshot task row without an id")?;
2692 sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2693 .bind(id)
2694 .execute(&mut *conn)
2695 .await?;
2696 }
2697
2698 for table in ["task_runs", "task_activity"] {
2699 sqlx::query(sqlx::AssertSqlSafe(format!(
2700 "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2701 )))
2702 .execute(&mut *conn)
2703 .await?;
2704 }
2705
2706 sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2707 .execute(&mut *conn)
2708 .await?;
2709
2710 for row in rows {
2711 let obj = row
2712 .as_object()
2713 .context("snapshot task row is not an object")?;
2714 let cols: Vec<&String> = obj.keys().collect();
2715 for c in &cols {
2716 anyhow::ensure!(
2717 allowed.contains(c),
2718 "snapshot names unknown task column {c}"
2719 );
2720 }
2721
2722 let values: Vec<String> = cols
2723 .iter()
2724 .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2725 .collect();
2726 let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2727 let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2728 let sql = format!(
2729 "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2730 names.join(", "),
2731 values.join(", "),
2732 updates.join(", ")
2733 );
2734 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2735 .bind(serde_json::to_string(row)?)
2736 .execute(&mut *conn)
2737 .await?;
2738 }
2739
2740 sqlx::query("DROP TABLE snapshot_ids")
2741 .execute(&mut *conn)
2742 .await?;
2743 Ok(())
2744}
2745
2746#[cfg(test)]
2747mod row_to_task_tests {
2748 use super::*;
2749 use crate::store;
2750 use crate::store::TaskStatus;
2751
2752 async fn store_with_task() -> (store::TaskStore, i64) {
2753 let store = store::TaskStore::open_memory().await.unwrap();
2754 let id = store
2755 .create(&NewTask {
2756 external_id: None,
2757 repo: "owner/repo".to_string(),
2758 origin: "internal".to_string(),
2759 title: "Test task".to_string(),
2760 body: "Test body".to_string(),
2761 source: "cron".to_string(),
2762 source_id: "test-1".to_string(),
2763 author: "tester".to_string(),
2764 url: "https://example.com/1".to_string(),
2765 labels: vec![],
2766 parent_id: None,
2767 })
2768 .await
2769 .unwrap();
2770 (store, id)
2771 }
2772
2773 macro_rules! missing_column_test {
2776 ($name:ident, $col:literal, $select:literal) => {
2777 #[tokio::test]
2778 async fn $name() {
2779 let (store, id) = store_with_task().await;
2780 let sql = format!("{} WHERE id = {id}", $select);
2781 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2782 .fetch_one(store.pool())
2783 .await
2784 .unwrap();
2785 let err = TaskStore::row_to_task(&row).unwrap_err();
2786 assert!(
2787 err.to_string().contains($col),
2788 "expected '{}' in error message, got: {err}",
2789 $col
2790 );
2791 }
2792 };
2793 }
2794
2795 missing_column_test!(
2796 row_to_task_fails_on_missing_repo,
2797 "repo",
2798 "SELECT id, status FROM tasks"
2799 );
2800 missing_column_test!(
2801 row_to_task_fails_on_missing_origin,
2802 "origin",
2803 "SELECT id, repo, status FROM tasks"
2804 );
2805 missing_column_test!(
2806 row_to_task_fails_on_missing_title,
2807 "title",
2808 "SELECT id, repo, origin, status FROM tasks"
2809 );
2810 missing_column_test!(
2811 row_to_task_fails_on_missing_body,
2812 "body",
2813 "SELECT id, repo, origin, title, status FROM tasks"
2814 );
2815 missing_column_test!(
2816 row_to_task_fails_on_missing_source,
2817 "source",
2818 "SELECT id, repo, origin, title, body, status FROM tasks"
2819 );
2820 missing_column_test!(
2821 row_to_task_fails_on_missing_source_id,
2822 "source_id",
2823 "SELECT id, repo, origin, title, body, source, status FROM tasks"
2824 );
2825 missing_column_test!(
2826 row_to_task_fails_on_missing_author,
2827 "author",
2828 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2829 );
2830 missing_column_test!(
2831 row_to_task_fails_on_missing_url,
2832 "url",
2833 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2834 );
2835
2836 #[tokio::test]
2843 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2844 let (store, id) = store_with_task().await;
2845 let sql = format!(
2848 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2849 source, source_id, author, url FROM tasks WHERE id = {id}"
2850 );
2851 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2852 .fetch_one(store.pool())
2853 .await
2854 .unwrap();
2855 let err = TaskStore::row_to_task(&row).unwrap_err();
2856 assert!(
2857 err.to_string().contains("repo"),
2858 "expected 'repo' in error message, got: {err}"
2859 );
2860 }
2861
2862 #[tokio::test]
2865 async fn distinct_repos_fails_on_decode_error() {
2866 let store = store::TaskStore::open_memory().await.unwrap();
2867 let id = store
2868 .create(&NewTask {
2869 external_id: None,
2870 repo: "owner/repo".to_string(),
2871 origin: "internal".to_string(),
2872 title: "Test task".to_string(),
2873 body: "Test body".to_string(),
2874 source: "cron".to_string(),
2875 source_id: "test-1".to_string(),
2876 author: "tester".to_string(),
2877 url: "https://example.com/1".to_string(),
2878 labels: vec![],
2879 parent_id: None,
2880 })
2881 .await
2882 .unwrap();
2883
2884 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2886 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2887 .execute(store.pool())
2888 .await
2889 .unwrap();
2890
2891 let err = store.distinct_repos().await.unwrap_err();
2892 assert!(
2893 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2894 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2895 );
2896 }
2897
2898 async fn store_with_run() -> (store::TaskStore, i64, i64) {
2901 let store = store::TaskStore::open_memory().await.unwrap();
2902 let task_id = store
2903 .create(&NewTask {
2904 external_id: None,
2905 repo: "owner/repo".to_string(),
2906 origin: "internal".to_string(),
2907 title: "Test task".to_string(),
2908 body: "Test body".to_string(),
2909 source: "cron".to_string(),
2910 source_id: "test-1".to_string(),
2911 author: "tester".to_string(),
2912 url: "https://example.com/1".to_string(),
2913 labels: vec![],
2914 parent_id: None,
2915 })
2916 .await
2917 .unwrap();
2918 let run_id = store
2919 .start_run(&crate::store::StartRun {
2920 task_id,
2921 attempt: 1,
2922 run_type: "agent",
2923 agent: "claude",
2924 model: "sonnet",
2925 command: "claude -p ...",
2926 prompt: "system prompt",
2927 })
2928 .await
2929 .unwrap();
2930 (store, task_id, run_id)
2931 }
2932
2933 #[tokio::test]
2934 async fn start_run_upsert_clears_stale_completion_fields() {
2935 let (store, task_id, run_id) = store_with_run().await;
2936 store
2937 .complete_run(&CompleteRun {
2938 run_id,
2939 exit_code: Some(0),
2940 stdout: "",
2941 stderr: "",
2942 parsed: "",
2943 outcome: "success",
2944 error: "boom",
2945 tokens: RunTokenUsage::default(),
2946 })
2947 .await
2948 .unwrap();
2949
2950 let same_run_id = store
2952 .start_run(&crate::store::StartRun {
2953 task_id,
2954 attempt: 1,
2955 run_type: "agent",
2956 agent: "claude",
2957 model: "sonnet",
2958 command: "claude -p ...",
2959 prompt: "system prompt",
2960 })
2961 .await
2962 .unwrap();
2963 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
2964
2965 let row = sqlx::query(
2966 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
2967 )
2968 .bind(run_id)
2969 .fetch_one(store.pool())
2970 .await
2971 .unwrap();
2972 let outcome: Option<String> = row.try_get("outcome").unwrap();
2973 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
2974 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
2975 let error: Option<String> = row.try_get("error").unwrap();
2976 assert_eq!(outcome, None);
2977 assert_eq!(
2978 completed_at, None,
2979 "stale completion timestamp must be cleared"
2980 );
2981 assert_eq!(exit_code, None);
2982 assert_eq!(error, None);
2983 }
2984
2985 macro_rules! run_blob_decode_test {
2992 ($name:ident, $col:literal, $select:literal) => {
2993 #[tokio::test]
2994 async fn $name() {
2995 let (store, _task_id, run_id) = store_with_run().await;
2996 let sql = format!($select, run_id = run_id);
2997 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2998 .fetch_one(store.pool())
2999 .await
3000 .unwrap();
3001 let err = TaskStore::row_to_run(&row).unwrap_err();
3002 assert!(
3003 err.to_string().contains($col),
3004 "expected '{0}' in error message, got: {err}",
3005 $col
3006 );
3007 }
3008 };
3009 }
3010
3011 run_blob_decode_test!(
3012 row_to_run_fails_on_decode_error_invalid_utf8_id,
3013 "id",
3014 "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}"
3015 );
3016 run_blob_decode_test!(
3017 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3018 "task_id",
3019 "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}"
3020 );
3021 run_blob_decode_test!(
3022 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3023 "started_at",
3024 "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}"
3025 );
3026
3027 async fn store_with_activity() -> (store::TaskStore, i64, i64) {
3030 let store = store::TaskStore::open_memory().await.unwrap();
3031 let task_id = store
3032 .create(&NewTask {
3033 external_id: None,
3034 repo: "owner/repo".to_string(),
3035 origin: "internal".to_string(),
3036 title: "Test task".to_string(),
3037 body: "Test body".to_string(),
3038 source: "cron".to_string(),
3039 source_id: "test-1".to_string(),
3040 author: "tester".to_string(),
3041 url: "https://example.com/1".to_string(),
3042 labels: vec![],
3043 parent_id: None,
3044 })
3045 .await
3046 .unwrap();
3047 store
3048 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3049 .await
3050 .unwrap();
3051 (store, task_id, 1)
3052 }
3053
3054 #[tokio::test]
3057 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3058 let (store, _task_id, activity_id) = store_with_activity().await;
3059 let sql = format!(
3060 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3061 );
3062 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3063 .fetch_one(store.pool())
3064 .await
3065 .unwrap();
3066 let err = TaskStore::row_to_activity(&row).unwrap_err();
3067 assert!(
3068 err.to_string().contains("id"),
3069 "expected 'id' in error message, got: {err}"
3070 );
3071 }
3072
3073 #[tokio::test]
3074 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3075 let (store, _task_id, activity_id) = store_with_activity().await;
3076 let sql = format!(
3077 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3078 );
3079 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3080 .fetch_one(store.pool())
3081 .await
3082 .unwrap();
3083 let err = TaskStore::row_to_activity(&row).unwrap_err();
3084 assert!(
3085 err.to_string().contains("task_id"),
3086 "expected 'task_id' in error message, got: {err}"
3087 );
3088 }
3089
3090 #[tokio::test]
3091 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3092 let (store, _task_id, activity_id) = store_with_activity().await;
3093 let sql = format!(
3094 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3095 );
3096 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3097 .fetch_one(store.pool())
3098 .await
3099 .unwrap();
3100 let err = TaskStore::row_to_activity(&row).unwrap_err();
3101 assert!(
3102 err.to_string().contains("timestamp"),
3103 "expected 'timestamp' in error message, got: {err}"
3104 );
3105 }
3106
3107 #[tokio::test]
3108 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3109 let (store, _task_id, activity_id) = store_with_activity().await;
3110 let sql = format!(
3111 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3112 );
3113 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3114 .fetch_one(store.pool())
3115 .await
3116 .unwrap();
3117 let err = TaskStore::row_to_activity(&row).unwrap_err();
3118 assert!(
3119 err.to_string().contains("event_type"),
3120 "expected 'event_type' in error message, got: {err}"
3121 );
3122 }
3123
3124 #[tokio::test]
3125 async fn row_to_activity_fails_on_corrupt_details_json() {
3126 let (store, task_id, _activity_id) = store_with_activity().await;
3127 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3129 .bind(task_id)
3130 .bind("test")
3131 .bind("not valid json {{{")
3132 .execute(store.pool())
3133 .await
3134 .unwrap();
3135 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
3136 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3137 )))
3138 .fetch_one(store.pool())
3139 .await
3140 .unwrap();
3141 let err = TaskStore::row_to_activity(&row).unwrap_err();
3142 assert!(
3143 err.to_string().contains("details")
3144 || err.to_string().contains("json")
3145 || err.to_string().contains("expected"),
3146 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3147 );
3148 }
3149
3150 run_blob_decode_test!(
3153 row_to_run_fails_on_decode_error_invalid_utf8_model,
3154 "model",
3155 "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}"
3156 );
3157 run_blob_decode_test!(
3158 row_to_run_fails_on_decode_error_invalid_utf8_command,
3159 "command",
3160 "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}"
3161 );
3162 run_blob_decode_test!(
3163 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3164 "prompt",
3165 "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}"
3166 );
3167 run_blob_decode_test!(
3168 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3169 "env_vars",
3170 "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}"
3171 );
3172 run_blob_decode_test!(
3173 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3174 "stdout",
3175 "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}"
3176 );
3177 run_blob_decode_test!(
3178 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3179 "stderr",
3180 "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}"
3181 );
3182 run_blob_decode_test!(
3183 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3184 "parsed_response",
3185 "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}"
3186 );
3187 run_blob_decode_test!(
3188 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3189 "outcome",
3190 "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}"
3191 );
3192 run_blob_decode_test!(
3193 row_to_run_fails_on_decode_error_invalid_utf8_error,
3194 "error",
3195 "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}"
3196 );
3197 run_blob_decode_test!(
3198 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3199 "input_tokens",
3200 "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}"
3201 );
3202 run_blob_decode_test!(
3203 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3204 "output_tokens",
3205 "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}"
3206 );
3207 run_blob_decode_test!(
3208 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3209 "total_cost_usd",
3210 "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}"
3211 );
3212 run_blob_decode_test!(
3213 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3214 "duration_secs",
3215 "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}"
3216 );
3217 run_blob_decode_test!(
3218 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3219 "exit_code",
3220 "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}"
3221 );
3222
3223 #[tokio::test]
3230 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3231 let (store, _task_id, activity_id) = store_with_activity().await;
3232 let sql = format!(
3233 "SELECT id, task_id, timestamp, event_type, \
3234 X'DeadbEEF' as from_status, NULL as to_status, \
3235 NULL as agent, NULL as model, details \
3236 FROM task_activity WHERE id = {activity_id}"
3237 );
3238 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3239 .fetch_one(store.pool())
3240 .await
3241 .unwrap();
3242 let err = TaskStore::row_to_activity(&row).unwrap_err();
3243 assert!(
3244 err.to_string().contains("from_status"),
3245 "expected 'from_status' in error message, got: {err}"
3246 );
3247 }
3248
3249 #[tokio::test]
3250 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3251 let (store, _task_id, activity_id) = store_with_activity().await;
3252 let sql = format!(
3253 "SELECT id, task_id, timestamp, event_type, \
3254 NULL as from_status, X'DeadbEEF' as to_status, \
3255 NULL as agent, NULL as model, details \
3256 FROM task_activity WHERE id = {activity_id}"
3257 );
3258 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3259 .fetch_one(store.pool())
3260 .await
3261 .unwrap();
3262 let err = TaskStore::row_to_activity(&row).unwrap_err();
3263 assert!(
3264 err.to_string().contains("to_status"),
3265 "expected 'to_status' in error message, got: {err}"
3266 );
3267 }
3268
3269 #[tokio::test]
3270 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3271 let (store, _task_id, activity_id) = store_with_activity().await;
3272 let sql = format!(
3273 "SELECT id, task_id, timestamp, event_type, \
3274 NULL as from_status, NULL as to_status, \
3275 X'DeadbEEF' as agent, NULL as model, details \
3276 FROM task_activity WHERE id = {activity_id}"
3277 );
3278 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3279 .fetch_one(store.pool())
3280 .await
3281 .unwrap();
3282 let err = TaskStore::row_to_activity(&row).unwrap_err();
3283 assert!(
3284 err.to_string().contains("agent"),
3285 "expected 'agent' in error message, got: {err}"
3286 );
3287 }
3288
3289 #[tokio::test]
3290 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3291 let (store, _task_id, activity_id) = store_with_activity().await;
3292 let sql = format!(
3293 "SELECT id, task_id, timestamp, event_type, \
3294 NULL as from_status, NULL as to_status, \
3295 NULL as agent, X'DeadbEEF' as model, details \
3296 FROM task_activity WHERE id = {activity_id}"
3297 );
3298 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3299 .fetch_one(store.pool())
3300 .await
3301 .unwrap();
3302 let err = TaskStore::row_to_activity(&row).unwrap_err();
3303 assert!(
3304 err.to_string().contains("model"),
3305 "expected 'model' in error message, got: {err}"
3306 );
3307 }
3308
3309 #[tokio::test]
3312 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3313 let store = store::TaskStore::open_memory().await.unwrap();
3318 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3319 .execute(store.pool())
3320 .await
3321 .unwrap();
3322 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3324 .execute(store.pool())
3325 .await
3326 .unwrap();
3327
3328 let result = store
3329 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3330 .await
3331 .unwrap();
3332 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3333 assert_eq!(result.get("valid-id"), Some(&TaskStatus::Done));
3334 }
3335}