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