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 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 = ?",
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 let Some(run_ctx) = run_ctx {
1855 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1856 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1857 })?;
1858 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1859 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1860 })?;
1861 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1862 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1863 })?;
1864 let model: String = run_ctx.try_get("model").map_err(|e| {
1865 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1866 })?;
1867 if run.outcome == "timeout" {
1868 self.append_activity(
1869 task_id,
1870 "timeout",
1871 None,
1872 None,
1873 Some(&agent),
1874 Some(&model),
1875 Some(&serde_json::json!({
1876 "run_type": run_type,
1877 "error": run.error,
1878 "stderr": run.stderr,
1879 })),
1880 )
1881 .await?;
1882 } else if run.outcome != "success" {
1883 self.append_activity(
1884 task_id,
1885 "error",
1886 None,
1887 None,
1888 Some(&agent),
1889 Some(&model),
1890 Some(&serde_json::json!({
1891 "run_type": run_type,
1892 "outcome": run.outcome,
1893 "error": run.error,
1894 })),
1895 )
1896 .await?;
1897 }
1898 } else if run.outcome != "success" {
1899 tracing::warn!(
1900 run_id = run.run_id,
1901 outcome = run.outcome,
1902 "run_id not found in task_runs; skipping timeout/error activity log"
1903 );
1904 }
1905 Ok(())
1906 }
1907
1908 pub async fn finalize_incomplete_runs(
1913 &self,
1914 task_id: i64,
1915 outcome: &str,
1916 error: &str,
1917 ) -> anyhow::Result<u64> {
1918 let rows: Vec<(i64,)> = sqlx::query_as(
1919 "SELECT id FROM task_runs
1920 WHERE task_id = ?
1921 AND outcome IS NULL
1922 AND completed_at IS NULL
1923 ORDER BY id ASC",
1924 )
1925 .bind(task_id)
1926 .fetch_all(&self.pool)
1927 .await?;
1928
1929 for (run_id,) in &rows {
1930 self.complete_run(&CompleteRun {
1931 run_id: *run_id,
1932 exit_code: Some(-1),
1933 stdout: "",
1934 stderr: "",
1935 parsed: "",
1936 outcome,
1937 error,
1938 tokens: RunTokenUsage::default(),
1939 })
1940 .await?;
1941 }
1942
1943 Ok(rows.len() as u64)
1944 }
1945
1946 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
1948 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1949 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
1950 )))
1951 .bind(task_id)
1952 .fetch_all(&self.pool)
1953 .await?;
1954
1955 rows.iter().map(Self::row_to_run).collect()
1956 }
1957
1958 pub async fn get_runs_batch(
1961 &self,
1962 task_ids: &[i64],
1963 ) -> anyhow::Result<HashMap<i64, Vec<TaskRun>>> {
1964 const CHUNK_SIZE: usize = 500;
1967 if task_ids.is_empty() {
1968 return Ok(HashMap::new());
1969 }
1970
1971 let mut result: HashMap<i64, Vec<TaskRun>> = HashMap::new();
1972
1973 for chunk in task_ids.chunks(CHUNK_SIZE) {
1974 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1975 let sql = format!(
1976 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
1977 );
1978 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1979 for id in chunk {
1980 query = query.bind(*id);
1981 }
1982 let rows = query.fetch_all(&self.pool).await?;
1983 for row in &rows {
1984 let run = Self::row_to_run(row)?;
1985 result.entry(run.task_id).or_default().push(run);
1986 }
1987 }
1988 Ok(result)
1989 }
1990
1991 pub async fn get_batch(&self, ids: &[i64]) -> anyhow::Result<HashMap<i64, Task>> {
1994 const CHUNK_SIZE: usize = 500;
1997 if ids.is_empty() {
1998 return Ok(HashMap::new());
1999 }
2000
2001 let mut result = HashMap::new();
2002
2003 for chunk in ids.chunks(CHUNK_SIZE) {
2004 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2005 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2006 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2007 for id in chunk {
2008 query = query.bind(*id);
2009 }
2010 let rows = query.fetch_all(&self.pool).await?;
2011 for row in &rows {
2012 let task = Self::row_to_task(row)?;
2013 result.insert(task.id, task);
2014 }
2015 }
2016 Ok(result)
2017 }
2018
2019 pub async fn get_statuses_by_external_ids(
2024 &self,
2025 repo: &str,
2026 ext_ids: &[&str],
2027 ) -> anyhow::Result<HashMap<String, TaskStatus>> {
2028 const CHUNK_SIZE: usize = 500;
2029 let mut result = HashMap::new();
2030 if ext_ids.is_empty() {
2031 return Ok(result);
2032 }
2033
2034 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2035 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2036 let sql = format!(
2037 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2038 );
2039 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2040 query = query.bind(repo);
2041 for id in chunk {
2042 query = query.bind(id);
2043 }
2044 let rows = query.fetch_all(&self.pool).await?;
2045 for row in &rows {
2046 let external_id: Result<String, _> = row.try_get("external_id");
2047 let status_str: Result<String, _> = row.try_get("status");
2048 match (external_id, status_str) {
2049 (Ok(ext_id), Ok(sts)) => {
2050 if ext_id.is_empty() {
2051 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2052 continue;
2053 }
2054 if let Some(status) = TaskStatus::from_str(&sts) {
2055 result.insert(ext_id, status);
2056 } else {
2057 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2058 }
2059 }
2060 (Err(e), _) => {
2061 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2062 }
2063 (_, Err(e)) => {
2064 tracing::warn!(error = %e, "skipping task row with decode error for status");
2065 }
2066 }
2067 }
2068 }
2069
2070 Ok(result)
2071 }
2072
2073 #[cfg(test)]
2076 pub async fn get_last_run(
2077 &self,
2078 task_id: i64,
2079 run_type: &str,
2080 ) -> anyhow::Result<Option<TaskRun>> {
2081 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
2082 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2083 )))
2084 .bind(task_id)
2085 .bind(run_type)
2086 .fetch_optional(&self.pool)
2087 .await?;
2088
2089 match row {
2090 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2091 None => Ok(None),
2092 }
2093 }
2094
2095 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2099 let rows: Vec<(String,)> = sqlx::query_as(
2100 "SELECT DISTINCT agent FROM task_runs \
2101 WHERE task_id = ? AND run_type = 'review' \
2102 ORDER BY agent",
2103 )
2104 .bind(task_id)
2105 .fetch_all(&self.pool)
2106 .await?;
2107 Ok(rows.into_iter().map(|(a,)| a).collect())
2108 }
2109
2110 #[cfg(test)]
2113 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2114 let result = sqlx::query(
2115 "DELETE FROM task_runs WHERE task_id IN (
2116 SELECT id FROM tasks
2117 WHERE status IN ('done', 'blocked')
2118 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2119 )",
2120 )
2121 .bind(format!("-{days}"))
2122 .execute(&self.pool)
2123 .await?;
2124
2125 Ok(result.rows_affected())
2126 }
2127
2128 fn row_to_task(row: &SqliteRow) -> anyhow::Result<Task> {
2133 let col_count = row.len();
2136 if col_count != TASK_COLS_COUNT {
2137 tracing::warn!(
2138 expected = TASK_COLS_COUNT,
2139 actual = col_count,
2140 "TASK_COLS column count mismatch - schema may have changed"
2141 );
2142 }
2143
2144 let status_str: String = row
2145 .try_get("status")
2146 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2147 let labels_str: String = row.try_get("labels").unwrap_or_default();
2148 let memory_str: String = row.try_get("memory").unwrap_or_default();
2149 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2150
2151 Ok(Task {
2152 id: row
2153 .try_get("id")
2154 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2155 external_id: row.try_get("external_id").unwrap_or(None),
2156 repo: row
2157 .try_get("repo")
2158 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2159 origin: row
2160 .try_get("origin")
2161 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2162 title: row
2163 .try_get("title")
2164 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2165 body: row
2166 .try_get("body")
2167 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2168 status: TaskStatus::from_str(&status_str)
2169 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2170 source: row
2171 .try_get("source")
2172 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2173 source_id: row
2174 .try_get("source_id")
2175 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2176 author: row
2177 .try_get("author")
2178 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2179 url: row
2180 .try_get("url")
2181 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2182 labels: serde_json::from_str(&labels_str)
2183 .inspect_err(
2184 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2185 )
2186 .unwrap_or_default(),
2187 agent: row.try_get("agent").unwrap_or(None),
2188 model: row.try_get("model").unwrap_or(None),
2189 complexity: row.try_get("complexity").unwrap_or_default(),
2190 estimate: {
2191 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2192 u8::try_from(raw).unwrap_or_else(|_| {
2193 tracing::warn!(
2194 raw_estimate = raw,
2195 "estimate value out of u8 range; defaulting to 0"
2196 );
2197 0
2198 })
2199 },
2200 route_reason: row.try_get("route_reason").unwrap_or_default(),
2201 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2202 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2203 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2204 attempts: row.try_get("attempts").unwrap_or(0),
2205 branch: row.try_get("branch").unwrap_or_default(),
2206 worktree: row.try_get("worktree").unwrap_or_default(),
2207 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2208 owner_node: row.try_get("owner_node").unwrap_or(None),
2209 summary: row.try_get("summary").unwrap_or_default(),
2210 last_error: row.try_get("last_error").unwrap_or_default(),
2211 parent_id: row.try_get("parent_id").unwrap_or(None),
2212 block_reason: row.try_get("block_reason").unwrap_or(None),
2213 pr_number: row.try_get("pr_number").unwrap_or(None),
2214 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2215 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2216 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2217 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2218 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2219 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2220 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2221 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2222 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2223 timeout_retries: row.try_get::<i32, _>("timeout_retries").unwrap_or(0),
2224 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2225 review_resets: row.try_get::<i32, _>("review_resets").unwrap_or(0),
2226 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2227 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2228 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2229 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2230 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2231 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2232 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2233 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2234 memory: serde_json::from_str(&memory_str)
2235 .inspect_err(
2236 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2237 )
2238 .unwrap_or_default(),
2239 delegations: serde_json::from_str(&delegations_str)
2240 .inspect_err(
2241 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2242 )
2243 .unwrap_or_default(),
2244 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2249 auto_unblock_last_at: row
2250 .try_get::<String, _>("auto_unblock_last_at")
2251 .unwrap_or_default(),
2252 auto_unblock_last_reason: row
2253 .try_get::<String, _>("auto_unblock_last_reason")
2254 .unwrap_or_default(),
2255 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2256 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2257 no_code_last_agent: row
2258 .try_get::<String, _>("no_code_last_agent")
2259 .unwrap_or_default(),
2260 created_at: row
2261 .try_get::<String, _>("created_at")
2262 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2263 updated_at: row
2264 .try_get::<String, _>("updated_at")
2265 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2266 })
2267 }
2268 pub async fn ensure_external_task(
2269 &self,
2270 repo: &str,
2271 ext: &ExternalTask,
2272 ) -> anyhow::Result<i64> {
2273 self.upsert_external(&UpsertExternal {
2274 repo,
2275 ext_id: &ext.id.0,
2276 title: &ext.title,
2277 body: &ext.body,
2278 author: &ext.author,
2279 url: &ext.url,
2280 labels: &ext.labels,
2281 origin: "github",
2282 })
2283 .await
2284 }
2285
2286 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2289 if let Some(suffix) = task_id.strip_prefix("internal:") {
2290 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2292 return Ok(Some(id));
2293 }
2294 if let Ok(id) = suffix.parse::<i64>() {
2298 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2299 .bind(id)
2300 .bind(repo)
2301 .fetch_optional(&self.pool)
2302 .await?;
2303 if exists.is_some() {
2304 return Ok(Some(id));
2305 }
2306 }
2307 return Ok(None);
2308 }
2309 self.resolve_id_by_external(repo, task_id).await
2312 }
2313
2314 pub async fn resolve_task_id_globally(
2320 &self,
2321 task_id: &str,
2322 ) -> anyhow::Result<Option<(i64, String)>> {
2323 if let Some(suffix) = task_id.strip_prefix("internal:") {
2324 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2326 .bind(task_id)
2327 .fetch_optional(&self.pool)
2328 .await?;
2329 if let Some(r) = row {
2330 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2331 }
2332 if let Ok(id) = suffix.parse::<i64>() {
2333 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2334 .bind(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 }
2341 return Ok(None);
2342 }
2343 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2345 .bind(task_id)
2346 .fetch_all(&self.pool)
2347 .await?;
2348 match rows.len() {
2349 0 => Ok(None),
2350 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2351 _ => {
2352 let repos: Vec<String> = rows
2353 .iter()
2354 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2355 .collect();
2356 anyhow::bail!(
2357 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2358 Run from the repo worktree so the current-repo lookup resolves it.",
2359 repos.join(", ")
2360 )
2361 }
2362 }
2363 }
2364
2365 pub async fn resolve_cli_id(
2367 &self,
2368 spec: &str,
2369 current_repo: Option<&str>,
2370 ) -> anyhow::Result<Option<CliTaskRef>> {
2371 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2372 match parse_cli_task_spec(spec)? {
2373 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2374 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2375 CliTaskSpec::Bare(number) => {
2376 if let Some(repo) = current_repo {
2377 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2378 return Ok(Some(task));
2379 }
2380 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2381 return Ok(Some(task));
2382 }
2383 return Ok(None);
2384 }
2385 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2386 match matches.len() {
2387 0 => Ok(None),
2388 1 => Ok(matches.pop()),
2389 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2390 }
2391 }
2392 }
2393 }
2394
2395 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2396 let ext = format!("internal:{number}");
2397 let rows = sqlx::query(
2398 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2399 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2400 ORDER BY id",
2401 )
2402 .bind(number)
2403 .bind(&ext)
2404 .fetch_all(&self.pool)
2405 .await?;
2406 let mut matches = dedup_cli_refs(rows)?;
2407 match matches.len() {
2408 0 => Ok(None),
2409 1 => Ok(matches.pop()),
2410 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2411 }
2412 }
2413
2414 async fn lookup_internal_in_repo(
2415 &self,
2416 number: i64,
2417 repo: &str,
2418 ) -> anyhow::Result<Option<CliTaskRef>> {
2419 let row = sqlx::query(
2420 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2421 WHERE origin = 'internal' AND id = ? AND repo = ?",
2422 )
2423 .bind(number)
2424 .bind(repo)
2425 .fetch_optional(&self.pool)
2426 .await?;
2427 match row {
2428 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2429 None => Ok(None),
2430 }
2431 }
2432
2433 async fn lookup_issue_cli_id(
2434 &self,
2435 repo: &str,
2436 number: i64,
2437 ) -> anyhow::Result<Option<CliTaskRef>> {
2438 let ext = number.to_string();
2439 let rows = sqlx::query(
2440 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2441 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2442 ORDER BY id",
2443 )
2444 .bind(repo)
2445 .bind(&ext)
2446 .fetch_all(&self.pool)
2447 .await?;
2448 let mut matches = dedup_cli_refs(rows)?;
2449 match matches.len() {
2450 0 => Ok(None),
2451 1 => Ok(matches.pop()),
2452 _ => Err(ambiguous_cli_id_error(
2453 &format!("{repo}#{number}"),
2454 &matches,
2455 )),
2456 }
2457 }
2458
2459 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2460 let ext = number.to_string();
2461 let rows = sqlx::query(
2462 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2463 WHERE (origin = 'internal' AND id = ?) \
2464 OR (origin != 'internal' AND external_id = ?) \
2465 ORDER BY repo, id",
2466 )
2467 .bind(number)
2468 .bind(&ext)
2469 .fetch_all(&self.pool)
2470 .await?;
2471 dedup_cli_refs(rows)
2472 }
2473
2474 fn row_to_run(row: &SqliteRow) -> anyhow::Result<TaskRun> {
2475 Ok(TaskRun {
2476 id: row
2477 .try_get("id")
2478 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2479 task_id: row
2480 .try_get("task_id")
2481 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2482 attempt: row
2483 .try_get("attempt")
2484 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2485 run_type: row
2486 .try_get("run_type")
2487 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2488 agent: row
2489 .try_get("agent")
2490 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2491 model: row
2492 .try_get("model")
2493 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2494 command: row
2495 .try_get("command")
2496 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2497 prompt: row
2498 .try_get("prompt")
2499 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2500 env_vars: row
2501 .try_get("env_vars")
2502 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2503 exit_code: row
2504 .try_get("exit_code")
2505 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2506 stdout: row
2507 .try_get("stdout")
2508 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2509 stderr: row
2510 .try_get("stderr")
2511 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2512 parsed_response: row
2513 .try_get("parsed_response")
2514 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2515 outcome: row
2516 .try_get("outcome")
2517 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2518 error: row
2519 .try_get("error")
2520 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2521 input_tokens: row
2522 .try_get("input_tokens")
2523 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2524 output_tokens: row
2525 .try_get("output_tokens")
2526 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2527 total_cost_usd: row
2528 .try_get("total_cost_usd")
2529 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2530 duration_secs: row
2531 .try_get("duration_secs")
2532 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2533 started_at: row
2534 .try_get("started_at")
2535 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2536 completed_at: row.try_get("completed_at").unwrap_or(None),
2537 })
2538 }
2539
2540 fn row_to_activity(row: &SqliteRow) -> anyhow::Result<TaskActivity> {
2541 let details_str: String = row
2542 .try_get("details")
2543 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2544 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2545 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2546 )?;
2547 Ok(TaskActivity {
2548 id: row
2549 .try_get("id")
2550 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2551 task_id: row
2552 .try_get("task_id")
2553 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2554 timestamp: row
2555 .try_get("timestamp")
2556 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2557 event_type: row
2558 .try_get("event_type")
2559 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2560 from_status: row
2561 .try_get("from_status")
2562 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2563 to_status: row
2564 .try_get("to_status")
2565 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2566 agent: row
2567 .try_get("agent")
2568 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2569 model: row
2570 .try_get("model")
2571 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2572 details,
2573 })
2574 }
2575}
2576
2577const LOCAL_TASK_COLUMNS: &[&str] = &[
2579 "worktree",
2580 "input_tokens",
2581 "output_tokens",
2582 "input_cost_usd",
2583 "output_cost_usd",
2584 "total_cost_usd",
2585];
2586
2587pub(crate) fn is_local_column(col: &str) -> bool {
2588 LOCAL_TASK_COLUMNS.contains(&col)
2589}
2590
2591#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2593pub struct UpsertExternalCmd {
2594 pub repo: String,
2595 pub ext_id: String,
2596 pub title: String,
2597 pub body: String,
2598 pub author: String,
2599 pub url: String,
2600 pub labels: Vec<String>,
2601 pub origin: String,
2602 pub parent_id: Option<i64>,
2603}
2604
2605#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2607pub struct StoreRouteCmd {
2608 pub id: i64,
2609 pub agent: String,
2610 pub model: Option<String>,
2611 pub complexity: String,
2612 pub estimate: u8,
2613 pub reason: String,
2614 pub profile: String,
2615 pub skills: String,
2616}
2617
2618const TOMBSTONE_COLUMNS: &[&str] = &[
2620 "id",
2621 "external_id",
2622 "repo",
2623 "origin",
2624 "title",
2625 "status",
2626 "pr_number",
2627 "parent_id",
2628 "created_at",
2629 "updated_at",
2630];
2631
2632async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2633 let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2634 .fetch_all(conn)
2635 .await?;
2636 Ok(cols
2637 .into_iter()
2638 .map(|(c,)| c)
2639 .filter(|c| !is_local_column(c))
2640 .collect())
2641}
2642
2643pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2645 let mut conn = pool.acquire().await?;
2646 let cols = replicated_columns(&mut conn).await?;
2647 let full: Vec<&String> = cols.iter().collect();
2648 let tomb: Vec<&String> = cols
2649 .iter()
2650 .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2651 .collect();
2652 let sql = format!(
2653 "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2654 super::replica::json_row_sql(&tomb),
2655 super::replica::json_row_sql(&full)
2656 );
2657 let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
2658 .fetch_all(&mut *conn)
2659 .await?;
2660 rows.into_iter()
2661 .map(|(r,)| Ok(serde_json::from_str(&r)?))
2662 .collect()
2663}
2664
2665pub(crate) async fn replace_replicated(
2668 conn: &mut sqlx::SqliteConnection,
2669 rows: &[serde_json::Value],
2670) -> anyhow::Result<()> {
2671 let allowed = replicated_columns(conn).await?;
2672 sqlx::query("PRAGMA defer_foreign_keys = ON")
2673 .execute(&mut *conn)
2674 .await?;
2675 sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2676 .execute(&mut *conn)
2677 .await?;
2678 sqlx::query("DELETE FROM snapshot_ids")
2679 .execute(&mut *conn)
2680 .await?;
2681 for row in rows {
2682 let id = row["id"]
2683 .as_i64()
2684 .context("snapshot task row without an id")?;
2685 sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2686 .bind(id)
2687 .execute(&mut *conn)
2688 .await?;
2689 }
2690
2691 for table in ["task_runs", "task_activity"] {
2692 sqlx::query(sqlx::AssertSqlSafe(format!(
2693 "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2694 )))
2695 .execute(&mut *conn)
2696 .await?;
2697 }
2698
2699 sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2700 .execute(&mut *conn)
2701 .await?;
2702
2703 for row in rows {
2704 let obj = row
2705 .as_object()
2706 .context("snapshot task row is not an object")?;
2707 let cols: Vec<&String> = obj.keys().collect();
2708 for c in &cols {
2709 anyhow::ensure!(
2710 allowed.contains(c),
2711 "snapshot names unknown task column {c}"
2712 );
2713 }
2714
2715 let values: Vec<String> = cols
2716 .iter()
2717 .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2718 .collect();
2719 let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2720 let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2721 let sql = format!(
2722 "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2723 names.join(", "),
2724 values.join(", "),
2725 updates.join(", ")
2726 );
2727 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2728 .bind(serde_json::to_string(row)?)
2729 .execute(&mut *conn)
2730 .await?;
2731 }
2732
2733 sqlx::query("DROP TABLE snapshot_ids")
2734 .execute(&mut *conn)
2735 .await?;
2736 Ok(())
2737}
2738
2739#[cfg(test)]
2740mod row_to_task_tests {
2741 use super::*;
2742 use crate::store;
2743 use crate::store::TaskStatus;
2744
2745 async fn store_with_task() -> (store::TaskStore, i64) {
2746 let store = store::TaskStore::open_memory().await.unwrap();
2747 let id = store
2748 .create(&NewTask {
2749 external_id: None,
2750 repo: "owner/repo".to_string(),
2751 origin: "internal".to_string(),
2752 title: "Test task".to_string(),
2753 body: "Test body".to_string(),
2754 source: "cron".to_string(),
2755 source_id: "test-1".to_string(),
2756 author: "tester".to_string(),
2757 url: "https://example.com/1".to_string(),
2758 labels: vec![],
2759 parent_id: None,
2760 })
2761 .await
2762 .unwrap();
2763 (store, id)
2764 }
2765
2766 macro_rules! missing_column_test {
2769 ($name:ident, $col:literal, $select:literal) => {
2770 #[tokio::test]
2771 async fn $name() {
2772 let (store, id) = store_with_task().await;
2773 let sql = format!("{} WHERE id = {id}", $select);
2774 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2775 .fetch_one(store.pool())
2776 .await
2777 .unwrap();
2778 let err = TaskStore::row_to_task(&row).unwrap_err();
2779 assert!(
2780 err.to_string().contains($col),
2781 "expected '{}' in error message, got: {err}",
2782 $col
2783 );
2784 }
2785 };
2786 }
2787
2788 missing_column_test!(
2789 row_to_task_fails_on_missing_repo,
2790 "repo",
2791 "SELECT id, status FROM tasks"
2792 );
2793 missing_column_test!(
2794 row_to_task_fails_on_missing_origin,
2795 "origin",
2796 "SELECT id, repo, status FROM tasks"
2797 );
2798 missing_column_test!(
2799 row_to_task_fails_on_missing_title,
2800 "title",
2801 "SELECT id, repo, origin, status FROM tasks"
2802 );
2803 missing_column_test!(
2804 row_to_task_fails_on_missing_body,
2805 "body",
2806 "SELECT id, repo, origin, title, status FROM tasks"
2807 );
2808 missing_column_test!(
2809 row_to_task_fails_on_missing_source,
2810 "source",
2811 "SELECT id, repo, origin, title, body, status FROM tasks"
2812 );
2813 missing_column_test!(
2814 row_to_task_fails_on_missing_source_id,
2815 "source_id",
2816 "SELECT id, repo, origin, title, body, source, status FROM tasks"
2817 );
2818 missing_column_test!(
2819 row_to_task_fails_on_missing_author,
2820 "author",
2821 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2822 );
2823 missing_column_test!(
2824 row_to_task_fails_on_missing_url,
2825 "url",
2826 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2827 );
2828
2829 #[tokio::test]
2836 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2837 let (store, id) = store_with_task().await;
2838 let sql = format!(
2841 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2842 source, source_id, author, url FROM tasks WHERE id = {id}"
2843 );
2844 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2845 .fetch_one(store.pool())
2846 .await
2847 .unwrap();
2848 let err = TaskStore::row_to_task(&row).unwrap_err();
2849 assert!(
2850 err.to_string().contains("repo"),
2851 "expected 'repo' in error message, got: {err}"
2852 );
2853 }
2854
2855 #[tokio::test]
2858 async fn distinct_repos_fails_on_decode_error() {
2859 let store = store::TaskStore::open_memory().await.unwrap();
2860 let id = store
2861 .create(&NewTask {
2862 external_id: None,
2863 repo: "owner/repo".to_string(),
2864 origin: "internal".to_string(),
2865 title: "Test task".to_string(),
2866 body: "Test body".to_string(),
2867 source: "cron".to_string(),
2868 source_id: "test-1".to_string(),
2869 author: "tester".to_string(),
2870 url: "https://example.com/1".to_string(),
2871 labels: vec![],
2872 parent_id: None,
2873 })
2874 .await
2875 .unwrap();
2876
2877 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2879 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2880 .execute(store.pool())
2881 .await
2882 .unwrap();
2883
2884 let err = store.distinct_repos().await.unwrap_err();
2885 assert!(
2886 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2887 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2888 );
2889 }
2890
2891 async fn store_with_run() -> (store::TaskStore, i64, i64) {
2894 let store = store::TaskStore::open_memory().await.unwrap();
2895 let task_id = store
2896 .create(&NewTask {
2897 external_id: None,
2898 repo: "owner/repo".to_string(),
2899 origin: "internal".to_string(),
2900 title: "Test task".to_string(),
2901 body: "Test body".to_string(),
2902 source: "cron".to_string(),
2903 source_id: "test-1".to_string(),
2904 author: "tester".to_string(),
2905 url: "https://example.com/1".to_string(),
2906 labels: vec![],
2907 parent_id: None,
2908 })
2909 .await
2910 .unwrap();
2911 let run_id = store
2912 .start_run(&crate::store::StartRun {
2913 task_id,
2914 attempt: 1,
2915 run_type: "agent",
2916 agent: "claude",
2917 model: "sonnet",
2918 command: "claude -p ...",
2919 prompt: "system prompt",
2920 })
2921 .await
2922 .unwrap();
2923 (store, task_id, run_id)
2924 }
2925
2926 #[tokio::test]
2927 async fn start_run_upsert_clears_stale_completion_fields() {
2928 let (store, task_id, run_id) = store_with_run().await;
2929 store
2930 .complete_run(&CompleteRun {
2931 run_id,
2932 exit_code: Some(0),
2933 stdout: "",
2934 stderr: "",
2935 parsed: "",
2936 outcome: "success",
2937 error: "boom",
2938 tokens: RunTokenUsage::default(),
2939 })
2940 .await
2941 .unwrap();
2942
2943 let same_run_id = store
2945 .start_run(&crate::store::StartRun {
2946 task_id,
2947 attempt: 1,
2948 run_type: "agent",
2949 agent: "claude",
2950 model: "sonnet",
2951 command: "claude -p ...",
2952 prompt: "system prompt",
2953 })
2954 .await
2955 .unwrap();
2956 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
2957
2958 let row = sqlx::query(
2959 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
2960 )
2961 .bind(run_id)
2962 .fetch_one(store.pool())
2963 .await
2964 .unwrap();
2965 let outcome: Option<String> = row.try_get("outcome").unwrap();
2966 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
2967 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
2968 let error: Option<String> = row.try_get("error").unwrap();
2969 assert_eq!(outcome, None);
2970 assert_eq!(
2971 completed_at, None,
2972 "stale completion timestamp must be cleared"
2973 );
2974 assert_eq!(exit_code, None);
2975 assert_eq!(error, None);
2976 }
2977
2978 macro_rules! run_blob_decode_test {
2985 ($name:ident, $col:literal, $select:literal) => {
2986 #[tokio::test]
2987 async fn $name() {
2988 let (store, _task_id, run_id) = store_with_run().await;
2989 let sql = format!($select, run_id = run_id);
2990 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2991 .fetch_one(store.pool())
2992 .await
2993 .unwrap();
2994 let err = TaskStore::row_to_run(&row).unwrap_err();
2995 assert!(
2996 err.to_string().contains($col),
2997 "expected '{0}' in error message, got: {err}",
2998 $col
2999 );
3000 }
3001 };
3002 }
3003
3004 run_blob_decode_test!(
3005 row_to_run_fails_on_decode_error_invalid_utf8_id,
3006 "id",
3007 "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}"
3008 );
3009 run_blob_decode_test!(
3010 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3011 "task_id",
3012 "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}"
3013 );
3014 run_blob_decode_test!(
3015 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3016 "started_at",
3017 "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}"
3018 );
3019
3020 async fn store_with_activity() -> (store::TaskStore, i64, i64) {
3023 let store = store::TaskStore::open_memory().await.unwrap();
3024 let task_id = store
3025 .create(&NewTask {
3026 external_id: None,
3027 repo: "owner/repo".to_string(),
3028 origin: "internal".to_string(),
3029 title: "Test task".to_string(),
3030 body: "Test body".to_string(),
3031 source: "cron".to_string(),
3032 source_id: "test-1".to_string(),
3033 author: "tester".to_string(),
3034 url: "https://example.com/1".to_string(),
3035 labels: vec![],
3036 parent_id: None,
3037 })
3038 .await
3039 .unwrap();
3040 store
3041 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3042 .await
3043 .unwrap();
3044 (store, task_id, 1)
3045 }
3046
3047 #[tokio::test]
3050 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3051 let (store, _task_id, activity_id) = store_with_activity().await;
3052 let sql = format!(
3053 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3054 );
3055 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3056 .fetch_one(store.pool())
3057 .await
3058 .unwrap();
3059 let err = TaskStore::row_to_activity(&row).unwrap_err();
3060 assert!(
3061 err.to_string().contains("id"),
3062 "expected 'id' in error message, got: {err}"
3063 );
3064 }
3065
3066 #[tokio::test]
3067 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3068 let (store, _task_id, activity_id) = store_with_activity().await;
3069 let sql = format!(
3070 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3071 );
3072 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3073 .fetch_one(store.pool())
3074 .await
3075 .unwrap();
3076 let err = TaskStore::row_to_activity(&row).unwrap_err();
3077 assert!(
3078 err.to_string().contains("task_id"),
3079 "expected 'task_id' in error message, got: {err}"
3080 );
3081 }
3082
3083 #[tokio::test]
3084 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3085 let (store, _task_id, activity_id) = store_with_activity().await;
3086 let sql = format!(
3087 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3088 );
3089 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3090 .fetch_one(store.pool())
3091 .await
3092 .unwrap();
3093 let err = TaskStore::row_to_activity(&row).unwrap_err();
3094 assert!(
3095 err.to_string().contains("timestamp"),
3096 "expected 'timestamp' in error message, got: {err}"
3097 );
3098 }
3099
3100 #[tokio::test]
3101 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3102 let (store, _task_id, activity_id) = store_with_activity().await;
3103 let sql = format!(
3104 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3105 );
3106 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3107 .fetch_one(store.pool())
3108 .await
3109 .unwrap();
3110 let err = TaskStore::row_to_activity(&row).unwrap_err();
3111 assert!(
3112 err.to_string().contains("event_type"),
3113 "expected 'event_type' in error message, got: {err}"
3114 );
3115 }
3116
3117 #[tokio::test]
3118 async fn row_to_activity_fails_on_corrupt_details_json() {
3119 let (store, task_id, _activity_id) = store_with_activity().await;
3120 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3122 .bind(task_id)
3123 .bind("test")
3124 .bind("not valid json {{{")
3125 .execute(store.pool())
3126 .await
3127 .unwrap();
3128 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
3129 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3130 )))
3131 .fetch_one(store.pool())
3132 .await
3133 .unwrap();
3134 let err = TaskStore::row_to_activity(&row).unwrap_err();
3135 assert!(
3136 err.to_string().contains("details")
3137 || err.to_string().contains("json")
3138 || err.to_string().contains("expected"),
3139 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3140 );
3141 }
3142
3143 run_blob_decode_test!(
3146 row_to_run_fails_on_decode_error_invalid_utf8_model,
3147 "model",
3148 "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}"
3149 );
3150 run_blob_decode_test!(
3151 row_to_run_fails_on_decode_error_invalid_utf8_command,
3152 "command",
3153 "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}"
3154 );
3155 run_blob_decode_test!(
3156 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3157 "prompt",
3158 "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}"
3159 );
3160 run_blob_decode_test!(
3161 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3162 "env_vars",
3163 "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}"
3164 );
3165 run_blob_decode_test!(
3166 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3167 "stdout",
3168 "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}"
3169 );
3170 run_blob_decode_test!(
3171 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3172 "stderr",
3173 "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}"
3174 );
3175 run_blob_decode_test!(
3176 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3177 "parsed_response",
3178 "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}"
3179 );
3180 run_blob_decode_test!(
3181 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3182 "outcome",
3183 "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}"
3184 );
3185 run_blob_decode_test!(
3186 row_to_run_fails_on_decode_error_invalid_utf8_error,
3187 "error",
3188 "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}"
3189 );
3190 run_blob_decode_test!(
3191 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3192 "input_tokens",
3193 "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}"
3194 );
3195 run_blob_decode_test!(
3196 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3197 "output_tokens",
3198 "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}"
3199 );
3200 run_blob_decode_test!(
3201 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3202 "total_cost_usd",
3203 "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}"
3204 );
3205 run_blob_decode_test!(
3206 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3207 "duration_secs",
3208 "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}"
3209 );
3210 run_blob_decode_test!(
3211 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3212 "exit_code",
3213 "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}"
3214 );
3215
3216 #[tokio::test]
3223 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3224 let (store, _task_id, activity_id) = store_with_activity().await;
3225 let sql = format!(
3226 "SELECT id, task_id, timestamp, event_type, \
3227 X'DeadbEEF' as from_status, NULL as to_status, \
3228 NULL as agent, NULL as model, details \
3229 FROM task_activity WHERE id = {activity_id}"
3230 );
3231 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3232 .fetch_one(store.pool())
3233 .await
3234 .unwrap();
3235 let err = TaskStore::row_to_activity(&row).unwrap_err();
3236 assert!(
3237 err.to_string().contains("from_status"),
3238 "expected 'from_status' in error message, got: {err}"
3239 );
3240 }
3241
3242 #[tokio::test]
3243 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3244 let (store, _task_id, activity_id) = store_with_activity().await;
3245 let sql = format!(
3246 "SELECT id, task_id, timestamp, event_type, \
3247 NULL as from_status, X'DeadbEEF' as to_status, \
3248 NULL as agent, NULL as model, details \
3249 FROM task_activity WHERE id = {activity_id}"
3250 );
3251 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3252 .fetch_one(store.pool())
3253 .await
3254 .unwrap();
3255 let err = TaskStore::row_to_activity(&row).unwrap_err();
3256 assert!(
3257 err.to_string().contains("to_status"),
3258 "expected 'to_status' in error message, got: {err}"
3259 );
3260 }
3261
3262 #[tokio::test]
3263 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3264 let (store, _task_id, activity_id) = store_with_activity().await;
3265 let sql = format!(
3266 "SELECT id, task_id, timestamp, event_type, \
3267 NULL as from_status, NULL as to_status, \
3268 X'DeadbEEF' as agent, NULL as model, details \
3269 FROM task_activity WHERE id = {activity_id}"
3270 );
3271 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3272 .fetch_one(store.pool())
3273 .await
3274 .unwrap();
3275 let err = TaskStore::row_to_activity(&row).unwrap_err();
3276 assert!(
3277 err.to_string().contains("agent"),
3278 "expected 'agent' in error message, got: {err}"
3279 );
3280 }
3281
3282 #[tokio::test]
3283 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3284 let (store, _task_id, activity_id) = store_with_activity().await;
3285 let sql = format!(
3286 "SELECT id, task_id, timestamp, event_type, \
3287 NULL as from_status, NULL as to_status, \
3288 NULL as agent, X'DeadbEEF' as model, details \
3289 FROM task_activity WHERE id = {activity_id}"
3290 );
3291 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3292 .fetch_one(store.pool())
3293 .await
3294 .unwrap();
3295 let err = TaskStore::row_to_activity(&row).unwrap_err();
3296 assert!(
3297 err.to_string().contains("model"),
3298 "expected 'model' in error message, got: {err}"
3299 );
3300 }
3301
3302 #[tokio::test]
3305 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3306 let store = store::TaskStore::open_memory().await.unwrap();
3311 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3312 .execute(store.pool())
3313 .await
3314 .unwrap();
3315 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3317 .execute(store.pool())
3318 .await
3319 .unwrap();
3320
3321 let result = store
3322 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3323 .await
3324 .unwrap();
3325 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3326 assert_eq!(result.get("valid-id"), Some(&TaskStatus::Done));
3327 }
3328}