1pub(crate) mod apply;
3
4use std::collections::HashMap;
5use std::collections::HashSet;
6
7use super::{Context, MemoryEntry, SqlitePool, TaskStore, TokenUsage, pricing_for_model};
8use crate::backends::ExternalTask;
9use crate::cluster;
10use crate::cluster::Command;
11use crate::security;
12use serde::{Deserialize, Serialize};
13use sqlx::Row;
14use sqlx::sqlite::SqliteRow;
15
16pub(crate) const TASK_COLS: &str = "id, external_id, repo, origin, title, body, status, \
24 source, source_id, author, url, labels, agent, model, complexity, estimate, \
25 route_reason, agent_profile, selected_skills, route_attempts, attempts, \
26 branch, worktree, worktree_cleaned, summary, last_error, parent_id, \
27 block_reason, pr_number, pr_review_context, last_review_ts, review_ts_map, \
28 last_comment_review_ts, merge_conflict_retries, ci_merge_failures, \
29 pr_create_failures, review_agent_failures, review_resets, review_cycles, input_tokens, \
30 output_tokens, input_cost_usd, output_cost_usd, total_cost_usd, \
31 model_reroute_chain, limit_reroute_chain, \
32 memory, delegations, created_at, updated_at, \
33 review_invocations, needs_review_refires, push_failures, auto_unblock_count, auto_unblock_last_at, \
34 ci_recovery_count, auto_unblock_last_reason, no_code_reroutes, network_retries, no_code_last_agent, \
35 owner_node";
36
37pub(crate) const TASK_COLS_COUNT: usize = 61;
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 "no_code_last_agent",
100];
101
102const INCREMENTABLE_FIELDS: &[&str] = &[
104 "attempts",
105 "route_attempts",
106 "merge_conflict_retries",
107 "ci_merge_failures",
108 "pr_create_failures",
109 "push_failures",
110 "review_agent_failures",
111 "review_resets",
112 "review_cycles",
113 "review_invocations",
114 "needs_review_refires",
115 "auto_unblock_count",
116 "ci_recovery_count",
117 "no_code_reroutes",
118 "network_retries",
119];
120
121fn sanitize_activity_details(value: &serde_json::Value) -> serde_json::Value {
122 match value {
123 serde_json::Value::String(text) => serde_json::Value::String(security::redact(text)),
124 serde_json::Value::Array(items) => serde_json::Value::Array(
125 items
126 .iter()
127 .map(sanitize_activity_details)
128 .collect::<Vec<_>>(),
129 ),
130 serde_json::Value::Object(map) => serde_json::Value::Object(
131 map.iter()
132 .map(|(key, value)| (key.clone(), sanitize_activity_details(value)))
133 .collect(),
134 ),
135 other => other.clone(),
136 }
137}
138
139fn sanitize_task_field_value(col: &str, value: &serde_json::Value) -> serde_json::Value {
140 match (col, value) {
141 ("last_error" | "block_reason", serde_json::Value::String(text)) => {
142 serde_json::Value::String(security::redact(text))
143 }
144 _ => value.clone(),
145 }
146}
147
148fn sql_value_for_task_field(
149 col: &str,
150 value: &serde_json::Value,
151) -> anyhow::Result<Option<String>> {
152 let sanitized = sanitize_task_field_value(col, value);
153 match sanitized {
154 serde_json::Value::String(text) => Ok(Some(text)),
155 serde_json::Value::Number(number) => Ok(Some(number.to_string())),
156 serde_json::Value::Bool(boolean) => Ok(Some(if boolean { "1" } else { "0" }.to_string())),
157 serde_json::Value::Null => Ok(None),
158 other => Ok(Some(serde_json::to_string(&other)?)),
159 }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum TaskStatus {
165 New,
166 Routed,
167 InProgress,
168 Done,
169 Blocked,
170 InReview,
171 Approved,
172 NeedsReview,
173 Waiting,
174}
175
176impl TaskStatus {
177 pub fn as_str(&self) -> &'static str {
178 match self {
179 Self::New => "new",
180 Self::Routed => "routed",
181 Self::InProgress => "in_progress",
182 Self::Done => "done",
183 Self::Blocked => "blocked",
184 Self::InReview => "in_review",
185 Self::Approved => "approved",
186 Self::NeedsReview => "needs_review",
187 Self::Waiting => "waiting",
188 }
189 }
190
191 pub fn from_str(s: &str) -> Option<Self> {
192 match s {
193 "new" => Some(Self::New),
194 "routed" => Some(Self::Routed),
195 "in_progress" => Some(Self::InProgress),
196 "done" => Some(Self::Done),
197 "blocked" => Some(Self::Blocked),
198 "in_review" => Some(Self::InReview),
199 "approved" => Some(Self::Approved),
200 "needs_review" => Some(Self::NeedsReview),
201 "waiting" => Some(Self::Waiting),
202 _ => None,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct Task {
210 pub id: i64,
211 pub external_id: Option<String>,
212 pub repo: String,
213 pub origin: String,
214
215 pub title: String,
216 pub body: String,
217 pub status: TaskStatus,
218 pub source: String,
219 pub source_id: String,
220 pub author: String,
221 pub url: String,
222 pub labels: Vec<String>,
223
224 pub agent: Option<String>,
226 pub model: Option<String>,
227 pub complexity: String,
228 pub estimate: u8,
230 pub route_reason: String,
231 pub agent_profile: String,
232 pub selected_skills: String,
233 pub route_attempts: i32,
234
235 pub attempts: i32,
237 pub branch: String,
238 pub worktree: String,
239 pub worktree_cleaned: bool,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub owner_node: Option<String>,
243 pub summary: String,
244 pub last_error: String,
245 pub parent_id: Option<i64>,
246 pub block_reason: Option<String>,
247
248 pub pr_number: Option<i32>,
250 pub pr_review_context: String,
251 pub last_review_ts: String,
252 pub review_ts_map: String,
253 pub last_comment_review_ts: String,
254 pub merge_conflict_retries: i32,
255 pub ci_merge_failures: i32,
256 pub pr_create_failures: i32,
257 pub push_failures: i32,
258 pub network_retries: i32,
259 pub review_agent_failures: i32,
260 pub review_resets: i32,
262 pub review_cycles: i32,
263 pub review_invocations: i32,
264 pub needs_review_refires: i32,
266
267 pub input_tokens: i64,
269 pub output_tokens: i64,
270 pub input_cost_usd: f64,
271 pub output_cost_usd: f64,
272 pub total_cost_usd: f64,
273
274 pub model_reroute_chain: String,
276 pub limit_reroute_chain: String,
277
278 pub memory: Vec<MemoryEntry>,
280 pub delegations: Vec<serde_json::Value>,
281
282 pub auto_unblock_count: i32,
284 pub auto_unblock_last_at: String,
285 pub auto_unblock_last_reason: String,
286
287 pub ci_recovery_count: i32,
289
290 pub no_code_reroutes: i32,
292 pub no_code_last_agent: String,
294
295 pub created_at: String,
297 pub updated_at: String,
298}
299
300#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
302pub struct NewTask {
303 pub external_id: Option<String>,
304 pub repo: String,
305 pub origin: String,
306 pub title: String,
307 pub body: String,
308 pub source: String,
309 pub source_id: String,
310 pub author: String,
311 pub url: String,
312 pub labels: Vec<String>,
313 pub parent_id: Option<i64>,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum CliTaskSpec {
320 Internal(i64),
322 Issue { repo: String, number: i64 },
324 Bare(i64),
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct CliTaskRef {
331 pub store_id: Option<i64>,
332 pub repo: String,
333 pub origin: String,
334 pub title: String,
335 pub external_id: String,
336 pub status: String,
337}
338
339impl CliTaskRef {
340 pub fn is_internal(&self) -> bool {
341 self.origin == "internal"
342 }
343
344 pub fn unsynced_issue(repo: String, number: i64) -> Self {
345 Self {
346 store_id: None,
347 repo,
348 origin: "github".to_string(),
349 title: String::new(),
350 external_id: number.to_string(),
351 status: String::new(),
352 }
353 }
354
355 pub fn display_spec(&self) -> String {
356 if self.is_internal() {
357 match self.store_id {
358 Some(id) => format!("internal:{id}"),
359 None => self.external_id.clone(),
360 }
361 } else {
362 format!("{}#{}", self.repo, self.external_id)
363 }
364 }
365
366 fn from_row(row: &SqliteRow) -> anyhow::Result<Self> {
367 let id: i64 = row.try_get("id")?;
368 let origin: String = row.try_get("origin")?;
369 let external_id: Option<String> = row.try_get("external_id")?;
370 let external_id = external_id.unwrap_or_else(|| {
371 if origin == "internal" {
372 format!("internal:{id}")
373 } else {
374 String::new()
375 }
376 });
377 Ok(Self {
378 store_id: Some(id),
379 repo: row.try_get("repo")?,
380 origin,
381 title: row.try_get("title")?,
382 external_id,
383 status: row.try_get("status")?,
384 })
385 }
386
387 fn format_match(&self) -> String {
388 format!(
389 " {} [{}] {}",
390 self.display_spec(),
391 if self.status.is_empty() {
392 "unknown"
393 } else {
394 &self.status
395 },
396 if self.title.is_empty() {
397 "-"
398 } else {
399 &self.title
400 }
401 )
402 }
403}
404
405pub fn parse_cli_task_spec(raw: &str) -> anyhow::Result<CliTaskSpec> {
407 let spec = raw.trim();
408 if spec.is_empty() {
409 anyhow::bail!("empty task id");
410 }
411 if let Some(suffix) = spec.strip_prefix("internal:") {
412 let number: i64 = suffix
413 .parse()
414 .map_err(|_| anyhow::anyhow!("internal task id '{suffix}' is not numeric"))?;
415 if number <= 0 {
416 anyhow::bail!("internal task id must be a positive number");
417 }
418 return Ok(CliTaskSpec::Internal(number));
419 }
420 if let Some((repo, number)) = parse_repo_issue(spec) {
421 return Ok(CliTaskSpec::Issue { repo, number });
422 }
423 let bare = spec.strip_prefix('#').unwrap_or(spec);
424 if let Ok(number) = bare.parse::<i64>()
425 && number > 0
426 {
427 return Ok(CliTaskSpec::Bare(number));
428 }
429 anyhow::bail!("invalid task id '{spec}': use a number, owner/repo#N, or internal:N")
430}
431
432fn parse_repo_issue(spec: &str) -> Option<(String, i64)> {
433 let (repo, number) = spec.rsplit_once('#')?;
434 if repo.is_empty() {
435 return None;
436 }
437 let number: i64 = number.parse().ok()?;
438 if number <= 0 {
439 return None;
440 }
441 let mut parts = repo.split('/');
442 let owner = parts.next()?;
443 let name = parts.next()?;
444 if parts.next().is_some() || owner.is_empty() || name.is_empty() {
445 return None;
446 }
447 let ok = |part: &str| {
448 part.chars()
449 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
450 };
451 if !ok(owner) || !ok(name) {
452 return None;
453 }
454 Some((repo.to_string(), number))
455}
456
457fn ambiguous_cli_id_error(spec: &str, matches: &[CliTaskRef]) -> anyhow::Error {
458 let mut lines = vec![format!("task ID {spec} matches more than one task:")];
459 for task in matches {
460 lines.push(task.format_match());
461 }
462 lines.push(ambiguous_cli_id_hint(matches));
463 anyhow::anyhow!(lines.join("\n"))
464}
465
466fn ambiguous_cli_id_hint(matches: &[CliTaskRef]) -> String {
467 let github = matches
468 .iter()
469 .find(|task| !task.is_internal())
470 .map(CliTaskRef::display_spec);
471 let internal = matches
472 .iter()
473 .find(|task| task.is_internal())
474 .map(CliTaskRef::display_spec);
475 match (github, internal) {
476 (Some(github), Some(internal)) => {
477 format!("Use {github} for the GitHub task or {internal} for the internal one")
478 }
479 _ => "Use owner/repo#N for an issue, or internal:N for an internal task.".to_string(),
480 }
481}
482
483fn dedup_cli_refs(rows: Vec<SqliteRow>) -> anyhow::Result<Vec<CliTaskRef>> {
484 let mut seen = Vec::new();
485 let mut out = Vec::new();
486 for row in rows {
487 let task = CliTaskRef::from_row(&row)?;
488 if let Some(id) = task.store_id {
489 if seen.contains(&id) {
490 continue;
491 }
492 seen.push(id);
493 }
494 out.push(task);
495 }
496 Ok(out)
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct TaskRun {
502 pub id: i64,
503 pub task_id: i64,
504 pub attempt: i32,
505 pub run_type: String,
506 pub agent: String,
507 pub model: String,
508 pub command: String,
509 pub prompt: String,
510 pub env_vars: String,
511 pub exit_code: Option<i32>,
512 pub stdout: String,
513 pub stderr: String,
514 pub parsed_response: String,
515 pub outcome: String,
516 pub error: String,
517 pub input_tokens: i64,
518 pub output_tokens: i64,
519 pub total_cost_usd: f64,
520 pub duration_secs: f64,
521 pub started_at: String,
522 pub completed_at: Option<String>,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct TaskActivity {
528 pub id: i64,
529 pub task_id: i64,
530 pub timestamp: String,
531 pub event_type: String,
532 pub from_status: Option<String>,
533 pub to_status: Option<String>,
534 pub agent: Option<String>,
535 pub model: Option<String>,
536 pub details: serde_json::Value,
537}
538
539#[derive(Debug, Clone, Copy, Default)]
541pub struct RunTokenUsage {
542 pub input_tokens: u64,
543 pub output_tokens: u64,
544 pub total_cost_usd: f64,
545 pub duration_secs: f64,
546}
547
548#[derive(Debug, Clone)]
550pub struct StartRun<'a> {
551 pub task_id: i64,
552 pub attempt: i32,
553 pub run_type: &'a str,
554 pub agent: &'a str,
555 pub model: &'a str,
556 pub command: &'a str,
557 pub prompt: &'a str,
558}
559
560#[derive(Debug, Clone)]
562pub struct CompleteRun<'a> {
563 pub run_id: i64,
564 pub exit_code: Option<i32>,
565 pub stdout: &'a str,
566 pub stderr: &'a str,
567 pub parsed: &'a str,
568 pub outcome: &'a str,
569 pub error: &'a str,
570 pub tokens: RunTokenUsage,
571}
572
573#[derive(Debug, Clone)]
575pub struct StoreRoute<'a> {
576 pub id: i64,
577 pub agent: &'a str,
578 pub model: Option<&'a str>,
579 pub complexity: &'a str,
580 pub estimate: u8,
582 pub reason: &'a str,
583 pub profile: &'a str,
584 pub skills: &'a str,
585}
586
587#[derive(Debug, Clone)]
589pub struct UpsertExternal<'a> {
590 pub repo: &'a str,
591 pub ext_id: &'a str,
592 pub title: &'a str,
593 pub body: &'a str,
594 pub author: &'a str,
595 pub url: &'a str,
596 pub labels: &'a [String],
597 pub origin: &'a str,
598}
599
600fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
601 labels
602 .iter()
603 .find_map(|label| label.strip_prefix("parent:"))
604 .filter(|parent| !parent.is_empty())
605}
606
607fn owned_updates(
608 updates: &[(&str, serde_json::Value)],
609) -> anyhow::Result<Vec<(String, serde_json::Value)>> {
610 updates
611 .iter()
612 .map(|(col, val)| {
613 anyhow::ensure!(
614 ALLOWED_FIELDS.contains(col),
615 "column {col} is not in the update allowlist"
616 );
617 Ok((col.to_string(), val.clone()))
618 })
619 .collect()
620}
621
622impl TaskStore {
623 #[expect(
625 clippy::too_many_arguments,
626 reason = "one argument per column of the activity row"
627 )]
628 pub async fn append_activity(
629 &self,
630 task_id: i64,
631 event_type: &str,
632 from_status: Option<&str>,
633 to_status: Option<&str>,
634 agent: Option<&str>,
635 model: Option<&str>,
636 details: Option<&serde_json::Value>,
637 ) -> anyhow::Result<()> {
638 let details_json = match details {
639 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
640 None => "{}".to_string(),
641 };
642 sqlx::query(
643 "INSERT INTO task_activity
644 (task_id, event_type, from_status, to_status, agent, model, details)
645 VALUES (?, ?, ?, ?, ?, ?, ?)",
646 )
647 .bind(task_id)
648 .bind(event_type)
649 .bind(from_status)
650 .bind(to_status)
651 .bind(agent)
652 .bind(model)
653 .bind(details_json)
654 .execute(&self.pool)
655 .await?;
656 Ok(())
657 }
658
659 pub async fn status_entered_at(
661 &self,
662 task_id: i64,
663 status: TaskStatus,
664 ) -> anyhow::Result<Option<String>> {
665 let row: Option<(String,)> = sqlx::query_as(
666 "SELECT timestamp FROM task_activity
667 WHERE task_id = ? AND event_type = 'status_change' AND to_status = ?
668 ORDER BY id DESC LIMIT 1",
669 )
670 .bind(task_id)
671 .bind(status.as_str())
672 .fetch_optional(&self.pool)
673 .await?;
674 Ok(row.map(|(ts,)| ts))
675 }
676
677 pub async fn in_review_entry(&self, task_id: i64) -> anyhow::Result<Option<(String, bool)>> {
679 let row: Option<(String, bool)> = sqlx::query_as(
680 "SELECT a.timestamp, EXISTS (
681 SELECT 1 FROM task_activity d
682 WHERE d.task_id = a.task_id AND d.event_type = 'review_decision' AND d.id > a.id
683 )
684 FROM task_activity a
685 WHERE a.task_id = ? AND a.event_type = 'status_change' AND a.to_status = 'in_review'
686 ORDER BY a.id DESC LIMIT 1",
687 )
688 .bind(task_id)
689 .fetch_optional(&self.pool)
690 .await?;
691 Ok(row)
692 }
693
694 pub async fn get_activity(
696 &self,
697 task_id: i64,
698 limit: Option<usize>,
699 ) -> anyhow::Result<Vec<TaskActivity>> {
700 let rows = if let Some(limit) = limit {
701 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
702 sqlx::query(sqlx::AssertSqlSafe(format!(
703 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
704 )))
705 .bind(task_id)
706 .bind(limit_i64)
707 .fetch_all(&self.pool)
708 .await?
709 } else {
710 sqlx::query(sqlx::AssertSqlSafe(format!(
711 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
712 )))
713 .bind(task_id)
714 .fetch_all(&self.pool)
715 .await?
716 };
717
718 rows.iter().map(Self::row_to_activity).collect()
719 }
720
721 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
722 self.write_int(Command::TaskCreate {
723 new: new.clone(),
724 internal: false,
725 })
726 .await
727 }
728
729 pub async fn create_internal(
734 &self,
735 repo: &str,
736 title: &str,
737 body: &str,
738 source: &str,
739 source_id: &str,
740 parent_id: Option<i64>,
741 ) -> anyhow::Result<i64> {
742 let new = NewTask {
743 external_id: None,
744 repo: repo.to_string(),
745 origin: "internal".to_string(),
746 title: title.to_string(),
747 body: body.to_string(),
748 source: source.to_string(),
749 source_id: source_id.to_string(),
750 author: String::new(),
751 url: String::new(),
752 labels: vec![],
753 parent_id,
754 };
755 self.write_int(Command::TaskCreate {
756 new,
757 internal: true,
758 })
759 .await
760 }
761
762 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
764 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
765 "SELECT {TASK_COLS} FROM tasks WHERE id = ?"
766 )))
767 .bind(id)
768 .fetch_optional(&self.pool)
769 .await?
770 .with_context(|| format!("task {id} not found"))?;
771
772 Self::row_to_task(&row)
773 }
774
775 pub async fn get_by_external_id(
777 &self,
778 repo: &str,
779 ext_id: &str,
780 ) -> anyhow::Result<Option<Task>> {
781 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
782 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
783 )))
784 .bind(repo)
785 .bind(ext_id)
786 .fetch_optional(&self.pool)
787 .await?;
788
789 match row {
790 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
791 None => Ok(None),
792 }
793 }
794
795 pub async fn resolve_id_by_external(
801 &self,
802 repo: &str,
803 ext_id: &str,
804 ) -> anyhow::Result<Option<i64>> {
805 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
806 .bind(repo)
807 .bind(ext_id)
808 .fetch_optional(&self.pool)
809 .await?;
810 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
811 Ok(id)
812 }
813
814 pub async fn resolve_id_by_worktree(&self, path: &str) -> anyhow::Result<Option<i64>> {
816 if path.is_empty() {
817 return Ok(None);
818 }
819
820 let row = sqlx::query("SELECT id FROM tasks WHERE worktree = ? LIMIT 1")
821 .bind(path)
822 .fetch_optional(&self.pool)
823 .await?;
824 Ok(row.map(|r| r.try_get::<i64, _>("id")).transpose()?)
825 }
826
827 pub async fn resolve_id_by_pr_number(
833 &self,
834 repo: &str,
835 pr_number: i32,
836 ) -> anyhow::Result<Option<i64>> {
837 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
838 .bind(repo)
839 .bind(pr_number)
840 .fetch_optional(&self.pool)
841 .await?;
842 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
843 Ok(id)
844 }
845
846 pub async fn get_agent_by_external_id(
849 &self,
850 repo: &str,
851 ext_id: &str,
852 ) -> anyhow::Result<Option<String>> {
853 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
854 .bind(repo)
855 .bind(ext_id)
856 .fetch_optional(&self.pool)
857 .await?;
858 let agent = row
859 .map(|r| r.try_get::<Option<String>, _>("agent"))
860 .transpose()?
861 .flatten();
862 Ok(agent)
863 }
864
865 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
867 let parent_id = match parent_external_id_from_labels(ext.labels) {
868 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
869 None => None,
870 };
871 self.write_int(Command::TaskUpsertExternal(UpsertExternalCmd {
872 repo: ext.repo.to_string(),
873 ext_id: ext.ext_id.to_string(),
874 title: ext.title.to_string(),
875 body: ext.body.to_string(),
876 author: ext.author.to_string(),
877 url: ext.url.to_string(),
878 labels: ext.labels.to_vec(),
879 origin: ext.origin.to_string(),
880 parent_id,
881 }))
882 .await
883 }
884
885 pub async fn update_status_and_fields(
890 &self,
891 id: i64,
892 status: TaskStatus,
893 updates: &[(&str, serde_json::Value)],
894 ) -> anyhow::Result<()> {
895 let mut updates = owned_updates(updates)?;
896 if self.replicates() {
898 let local: Vec<_> = updates
899 .iter()
900 .filter(|(col, _)| is_local_column(col))
901 .cloned()
902 .collect();
903 updates.retain(|(col, _)| !is_local_column(col));
904
905 if !local.is_empty() {
906 let mut tx = self.begin_write().await?;
907 apply::set_fields(&mut tx, &cluster::now_stamp(), id, &local, true).await?;
908 tx.commit().await?;
909 }
910 }
911
912 self.write(Command::TaskUpdateStatusAndFields {
913 id,
914 status,
915 updates,
916 })
917 .await
918 .map(|_| ())
919 }
920
921 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
923 self.write_bool(Command::TaskUpdateStatus {
924 id,
925 status,
926 expected: None,
927 })
928 .await
929 .map(|_| ())
930 }
931
932 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
934 self.write(Command::TaskUpdateExternalId {
935 id,
936 external_id: external_id.to_string(),
937 })
938 .await
939 .map(|_| ())
940 }
941
942 pub async fn update_status_if(
947 &self,
948 id: i64,
949 status: TaskStatus,
950 expected: TaskStatus,
951 ) -> anyhow::Result<bool> {
952 self.write_bool(Command::TaskUpdateStatus {
953 id,
954 status,
955 expected: Some(expected),
956 })
957 .await
958 }
959
960 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
968 self.write(Command::TaskTouch { id }).await.map(|_| ())
969 }
970
971 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
973 self.write(Command::TaskResetToNew { id }).await.map(|_| ())
974 }
975
976 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
978 let value = reason.map_or(serde_json::Value::Null, |r| {
979 serde_json::Value::String(r.to_string())
980 });
981 self.set_fields(id, &[("block_reason", value)]).await
982 }
983
984 pub async fn list_by_status(
986 &self,
987 repo: &str,
988 status: TaskStatus,
989 ) -> anyhow::Result<Vec<Task>> {
990 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
991 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
992 )))
993 .bind(repo)
994 .bind(status.as_str())
995 .fetch_all(&self.pool)
996 .await?;
997
998 rows.iter().map(Self::row_to_task).collect()
999 }
1000
1001 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
1003 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1004 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
1005 )))
1006 .bind(repo)
1007 .bind(parent_id)
1008 .fetch_all(&self.pool)
1009 .await?;
1010
1011 rows.iter().map(Self::row_to_task).collect()
1012 }
1013
1014 pub async fn list_external_by_status(
1016 &self,
1017 repo: &str,
1018 status: TaskStatus,
1019 ) -> anyhow::Result<Vec<Task>> {
1020 let rows = sqlx::query(
1021 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC")),
1022 )
1023 .bind(repo)
1024 .bind(status.as_str())
1025 .fetch_all(&self.pool)
1026 .await?;
1027
1028 rows.iter().map(Self::row_to_task).collect()
1029 }
1030
1031 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1033 self.list_by_status(repo, TaskStatus::New).await
1034 }
1035
1036 pub async fn list_internal_by_status(
1038 &self,
1039 repo: &str,
1040 status: TaskStatus,
1041 ) -> anyhow::Result<Vec<Task>> {
1042 let rows = sqlx::query(
1043 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC")),
1044 )
1045 .bind(repo)
1046 .bind(status.as_str())
1047 .fetch_all(&self.pool)
1048 .await?;
1049
1050 rows.iter().map(Self::row_to_task).collect()
1051 }
1052
1053 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1055 let rows = sqlx::query(
1056 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC")),
1057 )
1058 .bind(repo)
1059 .fetch_all(&self.pool)
1060 .await?;
1061
1062 rows.iter().map(Self::row_to_task).collect()
1063 }
1064
1065 pub async fn list_internal_by_source(
1068 &self,
1069 repo: &str,
1070 source: &str,
1071 ) -> anyhow::Result<Vec<Task>> {
1072 let rows = sqlx::query(
1073 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC")),
1074 )
1075 .bind(repo)
1076 .bind(source)
1077 .fetch_all(&self.pool)
1078 .await?;
1079
1080 rows.iter().map(Self::row_to_task).collect()
1081 }
1082
1083 pub async fn list_source_ids_by_source(
1088 &self,
1089 repo: &str,
1090 source: &str,
1091 since_days: i64,
1092 ) -> anyhow::Result<Vec<String>> {
1093 let cutoff = format!("-{since_days} days");
1094 let rows = sqlx::query(
1095 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1096 )
1097 .bind(repo)
1098 .bind(source)
1099 .bind(&cutoff)
1100 .fetch_all(&self.pool)
1101 .await?;
1102
1103 let mut ids = Vec::with_capacity(rows.len());
1104 for row in &rows {
1105 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1106 anyhow::anyhow!(
1107 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1108 )
1109 })?);
1110 }
1111 Ok(ids)
1112 }
1113
1114 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1116 let rows = sqlx::query(
1117 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC")),
1118 )
1119 .bind(repo)
1120 .fetch_all(&self.pool)
1121 .await?;
1122
1123 rows.iter().map(Self::row_to_task).collect()
1124 }
1125
1126 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1128 let rows = sqlx::query(
1129 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC")),
1130 )
1131 .bind(repo)
1132 .fetch_all(&self.pool)
1133 .await?;
1134
1135 rows.iter().map(Self::row_to_task).collect()
1136 }
1137
1138 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1140 let has_external = sqlx::query_scalar::<_, i32>(
1141 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1142 )
1143 .bind(repo)
1144 .fetch_optional(&self.pool)
1145 .await?
1146 .is_some();
1147 Ok(has_external)
1148 }
1149
1150 pub async fn existing_external_ids(&self, repo: &str) -> anyhow::Result<HashSet<String>> {
1154 let rows: Vec<(String,)> = sqlx::query_as(
1155 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1156 )
1157 .bind(repo)
1158 .fetch_all(&self.pool)
1159 .await?;
1160
1161 Ok(rows
1162 .into_iter()
1163 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1164 .collect())
1165 }
1166
1167 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1170 let rows = sqlx::query(
1171 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC")),
1172 )
1173 .bind(repo)
1174 .bind(cutoff_str)
1175 .fetch_all(&self.pool)
1176 .await?;
1177 rows.iter().map(Self::row_to_task).collect()
1178 }
1179
1180 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1182 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1183 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1184 )))
1185 .bind(repo)
1186 .fetch_all(&self.pool)
1187 .await?;
1188 rows.iter().map(Self::row_to_task).collect()
1189 }
1190
1191 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1193 let rows =
1194 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1195 .fetch_all(&self.pool)
1196 .await?;
1197 let mut repos = Vec::new();
1198 for row in &rows {
1199 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1200 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1201 })?);
1202 }
1203 Ok(repos)
1204 }
1205
1206 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1211 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1212 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1213 )))
1214 .fetch_all(&self.pool)
1215 .await?;
1216 rows.iter().map(Self::row_to_task).collect()
1217 }
1218
1219 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1223 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1224 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1225 )))
1226 .fetch_all(&self.pool)
1227 .await?;
1228 rows.iter().map(Self::row_to_task).collect()
1229 }
1230
1231 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1235 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1236 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1237 )))
1238 .bind(status.as_str())
1239 .fetch_all(&self.pool)
1240 .await?;
1241 rows.iter().map(Self::row_to_task).collect()
1242 }
1243
1244 pub async fn set_fields(
1247 &self,
1248 id: i64,
1249 updates: &[(&str, serde_json::Value)],
1250 ) -> anyhow::Result<()> {
1251 self.set_fields_split(id, updates, false).await
1252 }
1253
1254 pub async fn set_fields_silent(
1259 &self,
1260 id: i64,
1261 updates: &[(&str, serde_json::Value)],
1262 ) -> anyhow::Result<()> {
1263 self.set_fields_split(id, updates, true).await
1264 }
1265
1266 async fn set_fields_split(
1268 &self,
1269 id: i64,
1270 updates: &[(&str, serde_json::Value)],
1271 silent: bool,
1272 ) -> anyhow::Result<()> {
1273 let all = owned_updates(updates)?;
1274 if !self.replicates() {
1276 let mut tx = self.begin_write().await?;
1277 apply::set_fields(&mut tx, &cluster::now_stamp(), id, &all, silent).await?;
1278 tx.commit().await?;
1279 return Ok(());
1280 }
1281
1282 let (local, replicated): (Vec<_>, Vec<_>) =
1283 all.into_iter().partition(|(col, _)| is_local_column(col));
1284 if !local.is_empty() {
1285 let mut tx = self.begin_write().await?;
1286 apply::set_fields(&mut tx, &cluster::now_stamp(), id, &local, silent).await?;
1287 tx.commit().await?;
1288 }
1289
1290 if replicated.is_empty() {
1291 return Ok(());
1292 }
1293
1294 self.write(Command::TaskSetFields {
1295 id,
1296 updates: replicated,
1297 silent,
1298 })
1299 .await
1300 .map(|_| ())
1301 }
1302
1303 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1305 anyhow::ensure!(
1306 INCREMENTABLE_FIELDS.contains(&field),
1307 "field {field} is not incrementable"
1308 );
1309 let n = self
1310 .write_int(Command::TaskIncrement {
1311 id,
1312 field: field.to_string(),
1313 })
1314 .await?;
1315 Ok(i32::try_from(n)?)
1316 }
1317
1318 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1320 self.write(Command::TaskResetCounters {
1321 id,
1322 failure_only: false,
1323 })
1324 .await
1325 .map(|_| ())
1326 }
1327
1328 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1343 self.write(Command::TaskResetCounters {
1344 id,
1345 failure_only: true,
1346 })
1347 .await
1348 .map(|_| ())
1349 }
1350
1351 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1357 self.write(Command::TaskStoreRoute(StoreRouteCmd {
1358 id: route.id,
1359 agent: route.agent.to_string(),
1360 model: route.model.map(str::to_string),
1361 complexity: route.complexity.to_string(),
1362 estimate: route.estimate,
1363 reason: route.reason.to_string(),
1364 profile: route.profile.to_string(),
1365 skills: route.skills.to_string(),
1366 }))
1367 .await
1368 .map(|_| ())
1369 }
1370
1371 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1379 let rows = sqlx::query(
1380 "SELECT external_id, estimate FROM tasks \
1381 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1382 AND status NOT IN ('done', 'blocked')",
1383 )
1384 .fetch_all(&self.pool)
1385 .await?;
1386
1387 let mut result = Vec::new();
1388 for row in &rows {
1389 let external_id: String = row.try_get("external_id")?;
1390 let estimate_raw: i64 = row.try_get("estimate")?;
1391 if let Ok(estimate @ 1..) = u8::try_from(estimate_raw) {
1392 result.push((external_id, estimate));
1393 }
1394 }
1395 Ok(result)
1396 }
1397
1398 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1404 self.write_bool(Command::TaskSetEstimateIfZero { id, estimate })
1405 .await
1406 }
1407
1408 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1417 self.write(Command::TaskAppendMemory {
1418 id,
1419 entry: entry.clone(),
1420 })
1421 .await
1422 .map(|_| ())
1423 }
1424
1425 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1427 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1428 .bind(id)
1429 .fetch_one(&self.pool)
1430 .await?;
1431
1432 let memory_opt: Option<String> = row
1433 .try_get::<Option<String>, _>("memory")
1434 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1435 let memory_str = memory_opt.unwrap_or_default();
1436
1437 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1438 Vec::new()
1439 } else {
1440 serde_json::from_str(&memory_str)
1441 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1442 };
1443
1444 memory.sort_by_key(|m| m.attempt);
1445 if memory.len() > max {
1446 memory = memory.split_off(memory.len() - max);
1447 }
1448 Ok(memory)
1449 }
1450
1451 pub async fn store_tokens(
1457 &self,
1458 id: i64,
1459 input: u64,
1460 output: u64,
1461 model: &str,
1462 ) -> anyhow::Result<()> {
1463 let pricing = pricing_for_model(model);
1464 let usage = TokenUsage {
1465 input_tokens: input,
1466 output_tokens: output,
1467 };
1468 let cost = pricing.estimate_cost_usd(usage);
1469
1470 sqlx::query(
1471 "UPDATE tasks SET
1472 input_tokens = COALESCE(input_tokens, 0) + ?,
1473 output_tokens = COALESCE(output_tokens, 0) + ?,
1474 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1475 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1476 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1477 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1478 WHERE id = ?",
1479 )
1480 .bind(i64::try_from(input).unwrap_or_else(|_| {
1481 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1482 i64::MAX
1483 }))
1484 .bind(i64::try_from(output).unwrap_or_else(|_| {
1485 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1486 i64::MAX
1487 }))
1488 .bind(cost.input_cost_usd)
1489 .bind(cost.output_cost_usd)
1490 .bind(cost.total_cost_usd)
1491 .bind(id)
1492 .execute(&self.pool)
1493 .await?;
1494
1495 Ok(())
1496 }
1497
1498 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1504 self.write(Command::TaskMarkCleaned { id })
1505 .await
1506 .map(|_| ())
1507 }
1508
1509 #[cfg(test)]
1515 pub async fn batch_set_fields(
1516 &self,
1517 updates: &[(i64, &[(&str, serde_json::Value)])],
1518 ) -> anyhow::Result<()> {
1519 if updates.is_empty() {
1520 return Ok(());
1521 }
1522
1523 for (_, entry_updates) in updates {
1524 for (col, _) in *entry_updates {
1525 anyhow::ensure!(
1526 ALLOWED_FIELDS.contains(col),
1527 "column {col} is not in the update allowlist"
1528 );
1529 }
1530 }
1531
1532 let mut tx = self.begin_write().await?;
1533 for (id, entry_updates) in updates {
1534 if entry_updates.is_empty() {
1535 continue;
1536 }
1537 let mut set_parts = Vec::new();
1538 let mut values: Vec<Option<String>> = Vec::new();
1539 for (col, val) in *entry_updates {
1540 set_parts.push(format!("{col} = ?"));
1541 values.push(sql_value_for_task_field(col, val)?);
1542 }
1543 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1544 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1545 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1546 for v in &values {
1547 query = query.bind(v.as_deref());
1548 }
1549 query = query.bind(*id);
1550 query.execute(&mut *tx).await?;
1551 }
1552 tx.commit().await?;
1553 Ok(())
1554 }
1555
1556 #[cfg(test)]
1561 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1562 if entries.is_empty() {
1563 return Ok(());
1564 }
1565
1566 for (_, field) in entries {
1567 anyhow::ensure!(
1568 INCREMENTABLE_FIELDS.contains(field),
1569 "field {field} is not incrementable"
1570 );
1571 }
1572
1573 let mut tx = self.begin_write().await?;
1574 for (id, field) in entries {
1575 let sql = format!(
1576 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1577 );
1578 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
1579 .bind(*id)
1580 .execute(&mut *tx)
1581 .await?;
1582 }
1583 tx.commit().await?;
1584 Ok(())
1585 }
1586
1587 #[cfg(test)]
1594 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1595 const CHUNK_SIZE: usize = 500;
1598 if ids.is_empty() {
1599 return Ok(());
1600 }
1601
1602 let mut tx = self.begin_write().await?;
1603
1604 for chunk in ids.chunks(CHUNK_SIZE) {
1605 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1607 let sql = format!(
1608 "UPDATE tasks SET
1609 route_attempts = 0,
1610 review_agent_failures = 0,
1611 review_resets = 0,
1612 review_invocations = 0,
1613 pr_create_failures = 0,
1614 push_failures = 0,
1615 network_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 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2224 review_resets: row.try_get::<i32, _>("review_resets").unwrap_or(0),
2225 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2226 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2227 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2228 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2229 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2230 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2231 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2232 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2233 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2234 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2235 memory: serde_json::from_str(&memory_str)
2236 .inspect_err(
2237 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2238 )
2239 .unwrap_or_default(),
2240 delegations: serde_json::from_str(&delegations_str)
2241 .inspect_err(
2242 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2243 )
2244 .unwrap_or_default(),
2245 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2250 auto_unblock_last_at: row
2251 .try_get::<String, _>("auto_unblock_last_at")
2252 .unwrap_or_default(),
2253 auto_unblock_last_reason: row
2254 .try_get::<String, _>("auto_unblock_last_reason")
2255 .unwrap_or_default(),
2256 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2257 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2258 no_code_last_agent: row
2259 .try_get::<String, _>("no_code_last_agent")
2260 .unwrap_or_default(),
2261 created_at: row
2262 .try_get::<String, _>("created_at")
2263 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2264 updated_at: row
2265 .try_get::<String, _>("updated_at")
2266 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2267 })
2268 }
2269 pub async fn ensure_external_task(
2270 &self,
2271 repo: &str,
2272 ext: &ExternalTask,
2273 ) -> anyhow::Result<i64> {
2274 self.upsert_external(&UpsertExternal {
2275 repo,
2276 ext_id: &ext.id.0,
2277 title: &ext.title,
2278 body: &ext.body,
2279 author: &ext.author,
2280 url: &ext.url,
2281 labels: &ext.labels,
2282 origin: "github",
2283 })
2284 .await
2285 }
2286
2287 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2290 if let Some(suffix) = task_id.strip_prefix("internal:") {
2291 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2293 return Ok(Some(id));
2294 }
2295 if let Ok(id) = suffix.parse::<i64>() {
2299 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2300 .bind(id)
2301 .bind(repo)
2302 .fetch_optional(&self.pool)
2303 .await?;
2304 if exists.is_some() {
2305 return Ok(Some(id));
2306 }
2307 }
2308 return Ok(None);
2309 }
2310 self.resolve_id_by_external(repo, task_id).await
2313 }
2314
2315 pub async fn resolve_task_id_globally(
2321 &self,
2322 task_id: &str,
2323 ) -> anyhow::Result<Option<(i64, String)>> {
2324 if let Some(suffix) = task_id.strip_prefix("internal:") {
2325 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2327 .bind(task_id)
2328 .fetch_optional(&self.pool)
2329 .await?;
2330 if let Some(r) = row {
2331 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2332 }
2333 if let Ok(id) = suffix.parse::<i64>() {
2334 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2335 .bind(id)
2336 .fetch_optional(&self.pool)
2337 .await?;
2338 if let Some(r) = row {
2339 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2340 }
2341 }
2342 return Ok(None);
2343 }
2344 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2346 .bind(task_id)
2347 .fetch_all(&self.pool)
2348 .await?;
2349 match rows.len() {
2350 0 => Ok(None),
2351 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2352 _ => {
2353 let repos: Vec<String> = rows
2354 .iter()
2355 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2356 .collect();
2357 anyhow::bail!(
2358 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2359 Run from the repo worktree so the current-repo lookup resolves it.",
2360 repos.join(", ")
2361 )
2362 }
2363 }
2364 }
2365
2366 pub async fn resolve_cli_id(
2368 &self,
2369 spec: &str,
2370 current_repo: Option<&str>,
2371 ) -> anyhow::Result<Option<CliTaskRef>> {
2372 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2373 match parse_cli_task_spec(spec)? {
2374 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2375 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2376 CliTaskSpec::Bare(number) => {
2377 if let Some(repo) = current_repo {
2378 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2379 return Ok(Some(task));
2380 }
2381 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2382 return Ok(Some(task));
2383 }
2384 return Ok(None);
2385 }
2386 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2387 match matches.len() {
2388 0 => Ok(None),
2389 1 => Ok(matches.pop()),
2390 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2391 }
2392 }
2393 }
2394 }
2395
2396 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2397 let ext = format!("internal:{number}");
2398 let rows = sqlx::query(
2399 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2400 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2401 ORDER BY id",
2402 )
2403 .bind(number)
2404 .bind(&ext)
2405 .fetch_all(&self.pool)
2406 .await?;
2407 let mut matches = dedup_cli_refs(rows)?;
2408 match matches.len() {
2409 0 => Ok(None),
2410 1 => Ok(matches.pop()),
2411 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2412 }
2413 }
2414
2415 async fn lookup_internal_in_repo(
2416 &self,
2417 number: i64,
2418 repo: &str,
2419 ) -> anyhow::Result<Option<CliTaskRef>> {
2420 let row = sqlx::query(
2421 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2422 WHERE origin = 'internal' AND id = ? AND repo = ?",
2423 )
2424 .bind(number)
2425 .bind(repo)
2426 .fetch_optional(&self.pool)
2427 .await?;
2428 match row {
2429 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2430 None => Ok(None),
2431 }
2432 }
2433
2434 async fn lookup_issue_cli_id(
2435 &self,
2436 repo: &str,
2437 number: i64,
2438 ) -> anyhow::Result<Option<CliTaskRef>> {
2439 let ext = number.to_string();
2440 let rows = sqlx::query(
2441 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2442 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2443 ORDER BY id",
2444 )
2445 .bind(repo)
2446 .bind(&ext)
2447 .fetch_all(&self.pool)
2448 .await?;
2449 let mut matches = dedup_cli_refs(rows)?;
2450 match matches.len() {
2451 0 => Ok(None),
2452 1 => Ok(matches.pop()),
2453 _ => Err(ambiguous_cli_id_error(
2454 &format!("{repo}#{number}"),
2455 &matches,
2456 )),
2457 }
2458 }
2459
2460 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2461 let ext = number.to_string();
2462 let rows = sqlx::query(
2463 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2464 WHERE (origin = 'internal' AND id = ?) \
2465 OR (origin != 'internal' AND external_id = ?) \
2466 ORDER BY repo, id",
2467 )
2468 .bind(number)
2469 .bind(&ext)
2470 .fetch_all(&self.pool)
2471 .await?;
2472 dedup_cli_refs(rows)
2473 }
2474
2475 fn row_to_run(row: &SqliteRow) -> anyhow::Result<TaskRun> {
2476 Ok(TaskRun {
2477 id: row
2478 .try_get("id")
2479 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2480 task_id: row
2481 .try_get("task_id")
2482 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2483 attempt: row
2484 .try_get("attempt")
2485 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2486 run_type: row
2487 .try_get("run_type")
2488 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2489 agent: row
2490 .try_get("agent")
2491 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2492 model: row
2493 .try_get("model")
2494 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2495 command: row
2496 .try_get("command")
2497 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2498 prompt: row
2499 .try_get("prompt")
2500 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2501 env_vars: row
2502 .try_get("env_vars")
2503 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2504 exit_code: row
2505 .try_get("exit_code")
2506 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2507 stdout: row
2508 .try_get("stdout")
2509 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2510 stderr: row
2511 .try_get("stderr")
2512 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2513 parsed_response: row
2514 .try_get("parsed_response")
2515 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2516 outcome: row
2517 .try_get("outcome")
2518 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2519 error: row
2520 .try_get("error")
2521 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2522 input_tokens: row
2523 .try_get("input_tokens")
2524 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2525 output_tokens: row
2526 .try_get("output_tokens")
2527 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2528 total_cost_usd: row
2529 .try_get("total_cost_usd")
2530 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2531 duration_secs: row
2532 .try_get("duration_secs")
2533 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2534 started_at: row
2535 .try_get("started_at")
2536 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2537 completed_at: row.try_get("completed_at").unwrap_or(None),
2538 })
2539 }
2540
2541 fn row_to_activity(row: &SqliteRow) -> anyhow::Result<TaskActivity> {
2542 let details_str: String = row
2543 .try_get("details")
2544 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2545 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2546 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2547 )?;
2548 Ok(TaskActivity {
2549 id: row
2550 .try_get("id")
2551 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2552 task_id: row
2553 .try_get("task_id")
2554 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2555 timestamp: row
2556 .try_get("timestamp")
2557 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2558 event_type: row
2559 .try_get("event_type")
2560 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2561 from_status: row
2562 .try_get("from_status")
2563 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2564 to_status: row
2565 .try_get("to_status")
2566 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2567 agent: row
2568 .try_get("agent")
2569 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2570 model: row
2571 .try_get("model")
2572 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2573 details,
2574 })
2575 }
2576}
2577
2578const LOCAL_TASK_COLUMNS: &[&str] = &[
2580 "worktree",
2581 "input_tokens",
2582 "output_tokens",
2583 "input_cost_usd",
2584 "output_cost_usd",
2585 "total_cost_usd",
2586];
2587
2588pub(crate) fn is_local_column(col: &str) -> bool {
2589 LOCAL_TASK_COLUMNS.contains(&col)
2590}
2591
2592#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2594pub struct UpsertExternalCmd {
2595 pub repo: String,
2596 pub ext_id: String,
2597 pub title: String,
2598 pub body: String,
2599 pub author: String,
2600 pub url: String,
2601 pub labels: Vec<String>,
2602 pub origin: String,
2603 pub parent_id: Option<i64>,
2604}
2605
2606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2608pub struct StoreRouteCmd {
2609 pub id: i64,
2610 pub agent: String,
2611 pub model: Option<String>,
2612 pub complexity: String,
2613 pub estimate: u8,
2614 pub reason: String,
2615 pub profile: String,
2616 pub skills: String,
2617}
2618
2619const TOMBSTONE_COLUMNS: &[&str] = &[
2621 "id",
2622 "external_id",
2623 "repo",
2624 "origin",
2625 "title",
2626 "status",
2627 "pr_number",
2628 "parent_id",
2629 "created_at",
2630 "updated_at",
2631];
2632
2633async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2634 let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2635 .fetch_all(conn)
2636 .await?;
2637 Ok(cols
2638 .into_iter()
2639 .map(|(c,)| c)
2640 .filter(|c| !is_local_column(c))
2641 .collect())
2642}
2643
2644pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2646 let mut conn = pool.acquire().await?;
2647 let cols = replicated_columns(&mut conn).await?;
2648 let full: Vec<&String> = cols.iter().collect();
2649 let tomb: Vec<&String> = cols
2650 .iter()
2651 .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2652 .collect();
2653 let sql = format!(
2654 "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2655 super::replica::json_row_sql(&tomb),
2656 super::replica::json_row_sql(&full)
2657 );
2658 let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
2659 .fetch_all(&mut *conn)
2660 .await?;
2661 rows.into_iter()
2662 .map(|(r,)| Ok(serde_json::from_str(&r)?))
2663 .collect()
2664}
2665
2666pub(crate) async fn replace_replicated(
2669 conn: &mut sqlx::SqliteConnection,
2670 rows: &[serde_json::Value],
2671) -> anyhow::Result<()> {
2672 let allowed = replicated_columns(conn).await?;
2673 sqlx::query("PRAGMA defer_foreign_keys = ON")
2674 .execute(&mut *conn)
2675 .await?;
2676 sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2677 .execute(&mut *conn)
2678 .await?;
2679 sqlx::query("DELETE FROM snapshot_ids")
2680 .execute(&mut *conn)
2681 .await?;
2682 for row in rows {
2683 let id = row["id"]
2684 .as_i64()
2685 .context("snapshot task row without an id")?;
2686 sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2687 .bind(id)
2688 .execute(&mut *conn)
2689 .await?;
2690 }
2691
2692 for table in ["task_runs", "task_activity"] {
2693 sqlx::query(sqlx::AssertSqlSafe(format!(
2694 "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2695 )))
2696 .execute(&mut *conn)
2697 .await?;
2698 }
2699
2700 sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2701 .execute(&mut *conn)
2702 .await?;
2703
2704 for row in rows {
2705 let obj = row
2706 .as_object()
2707 .context("snapshot task row is not an object")?;
2708 let cols: Vec<&String> = obj.keys().collect();
2709 for c in &cols {
2710 anyhow::ensure!(
2711 allowed.contains(c),
2712 "snapshot names unknown task column {c}"
2713 );
2714 }
2715
2716 let values: Vec<String> = cols
2717 .iter()
2718 .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2719 .collect();
2720 let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2721 let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2722 let sql = format!(
2723 "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2724 names.join(", "),
2725 values.join(", "),
2726 updates.join(", ")
2727 );
2728 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2729 .bind(serde_json::to_string(row)?)
2730 .execute(&mut *conn)
2731 .await?;
2732 }
2733
2734 sqlx::query("DROP TABLE snapshot_ids")
2735 .execute(&mut *conn)
2736 .await?;
2737 Ok(())
2738}
2739
2740#[cfg(test)]
2741mod row_to_task_tests {
2742 use super::*;
2743 use crate::store;
2744 use crate::store::TaskStatus;
2745
2746 async fn store_with_task() -> (store::TaskStore, i64) {
2747 let store = store::TaskStore::open_memory().await.unwrap();
2748 let id = store
2749 .create(&NewTask {
2750 external_id: None,
2751 repo: "owner/repo".to_string(),
2752 origin: "internal".to_string(),
2753 title: "Test task".to_string(),
2754 body: "Test body".to_string(),
2755 source: "cron".to_string(),
2756 source_id: "test-1".to_string(),
2757 author: "tester".to_string(),
2758 url: "https://example.com/1".to_string(),
2759 labels: vec![],
2760 parent_id: None,
2761 })
2762 .await
2763 .unwrap();
2764 (store, id)
2765 }
2766
2767 macro_rules! missing_column_test {
2770 ($name:ident, $col:literal, $select:literal) => {
2771 #[tokio::test]
2772 async fn $name() {
2773 let (store, id) = store_with_task().await;
2774 let sql = format!("{} WHERE id = {id}", $select);
2775 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2776 .fetch_one(store.pool())
2777 .await
2778 .unwrap();
2779 let err = TaskStore::row_to_task(&row).unwrap_err();
2780 assert!(
2781 err.to_string().contains($col),
2782 "expected '{}' in error message, got: {err}",
2783 $col
2784 );
2785 }
2786 };
2787 }
2788
2789 missing_column_test!(
2790 row_to_task_fails_on_missing_repo,
2791 "repo",
2792 "SELECT id, status FROM tasks"
2793 );
2794 missing_column_test!(
2795 row_to_task_fails_on_missing_origin,
2796 "origin",
2797 "SELECT id, repo, status FROM tasks"
2798 );
2799 missing_column_test!(
2800 row_to_task_fails_on_missing_title,
2801 "title",
2802 "SELECT id, repo, origin, status FROM tasks"
2803 );
2804 missing_column_test!(
2805 row_to_task_fails_on_missing_body,
2806 "body",
2807 "SELECT id, repo, origin, title, status FROM tasks"
2808 );
2809 missing_column_test!(
2810 row_to_task_fails_on_missing_source,
2811 "source",
2812 "SELECT id, repo, origin, title, body, status FROM tasks"
2813 );
2814 missing_column_test!(
2815 row_to_task_fails_on_missing_source_id,
2816 "source_id",
2817 "SELECT id, repo, origin, title, body, source, status FROM tasks"
2818 );
2819 missing_column_test!(
2820 row_to_task_fails_on_missing_author,
2821 "author",
2822 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2823 );
2824 missing_column_test!(
2825 row_to_task_fails_on_missing_url,
2826 "url",
2827 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2828 );
2829
2830 #[tokio::test]
2837 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2838 let (store, id) = store_with_task().await;
2839 let sql = format!(
2842 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2843 source, source_id, author, url FROM tasks WHERE id = {id}"
2844 );
2845 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2846 .fetch_one(store.pool())
2847 .await
2848 .unwrap();
2849 let err = TaskStore::row_to_task(&row).unwrap_err();
2850 assert!(
2851 err.to_string().contains("repo"),
2852 "expected 'repo' in error message, got: {err}"
2853 );
2854 }
2855
2856 #[tokio::test]
2859 async fn distinct_repos_fails_on_decode_error() {
2860 let store = store::TaskStore::open_memory().await.unwrap();
2861 let id = store
2862 .create(&NewTask {
2863 external_id: None,
2864 repo: "owner/repo".to_string(),
2865 origin: "internal".to_string(),
2866 title: "Test task".to_string(),
2867 body: "Test body".to_string(),
2868 source: "cron".to_string(),
2869 source_id: "test-1".to_string(),
2870 author: "tester".to_string(),
2871 url: "https://example.com/1".to_string(),
2872 labels: vec![],
2873 parent_id: None,
2874 })
2875 .await
2876 .unwrap();
2877
2878 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2880 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2881 .execute(store.pool())
2882 .await
2883 .unwrap();
2884
2885 let err = store.distinct_repos().await.unwrap_err();
2886 assert!(
2887 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2888 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2889 );
2890 }
2891
2892 async fn store_with_run() -> (store::TaskStore, i64, i64) {
2895 let store = store::TaskStore::open_memory().await.unwrap();
2896 let task_id = store
2897 .create(&NewTask {
2898 external_id: None,
2899 repo: "owner/repo".to_string(),
2900 origin: "internal".to_string(),
2901 title: "Test task".to_string(),
2902 body: "Test body".to_string(),
2903 source: "cron".to_string(),
2904 source_id: "test-1".to_string(),
2905 author: "tester".to_string(),
2906 url: "https://example.com/1".to_string(),
2907 labels: vec![],
2908 parent_id: None,
2909 })
2910 .await
2911 .unwrap();
2912 let run_id = store
2913 .start_run(&crate::store::StartRun {
2914 task_id,
2915 attempt: 1,
2916 run_type: "agent",
2917 agent: "claude",
2918 model: "sonnet",
2919 command: "claude -p ...",
2920 prompt: "system prompt",
2921 })
2922 .await
2923 .unwrap();
2924 (store, task_id, run_id)
2925 }
2926
2927 #[tokio::test]
2928 async fn start_run_upsert_clears_stale_completion_fields() {
2929 let (store, task_id, run_id) = store_with_run().await;
2930 store
2931 .complete_run(&CompleteRun {
2932 run_id,
2933 exit_code: Some(0),
2934 stdout: "",
2935 stderr: "",
2936 parsed: "",
2937 outcome: "success",
2938 error: "boom",
2939 tokens: RunTokenUsage::default(),
2940 })
2941 .await
2942 .unwrap();
2943
2944 let same_run_id = store
2946 .start_run(&crate::store::StartRun {
2947 task_id,
2948 attempt: 1,
2949 run_type: "agent",
2950 agent: "claude",
2951 model: "sonnet",
2952 command: "claude -p ...",
2953 prompt: "system prompt",
2954 })
2955 .await
2956 .unwrap();
2957 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
2958
2959 let row = sqlx::query(
2960 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
2961 )
2962 .bind(run_id)
2963 .fetch_one(store.pool())
2964 .await
2965 .unwrap();
2966 let outcome: Option<String> = row.try_get("outcome").unwrap();
2967 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
2968 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
2969 let error: Option<String> = row.try_get("error").unwrap();
2970 assert_eq!(outcome, None);
2971 assert_eq!(
2972 completed_at, None,
2973 "stale completion timestamp must be cleared"
2974 );
2975 assert_eq!(exit_code, None);
2976 assert_eq!(error, None);
2977 }
2978
2979 macro_rules! run_blob_decode_test {
2986 ($name:ident, $col:literal, $select:literal) => {
2987 #[tokio::test]
2988 async fn $name() {
2989 let (store, _task_id, run_id) = store_with_run().await;
2990 let sql = format!($select, run_id = run_id);
2991 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2992 .fetch_one(store.pool())
2993 .await
2994 .unwrap();
2995 let err = TaskStore::row_to_run(&row).unwrap_err();
2996 assert!(
2997 err.to_string().contains($col),
2998 "expected '{0}' in error message, got: {err}",
2999 $col
3000 );
3001 }
3002 };
3003 }
3004
3005 run_blob_decode_test!(
3006 row_to_run_fails_on_decode_error_invalid_utf8_id,
3007 "id",
3008 "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}"
3009 );
3010 run_blob_decode_test!(
3011 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3012 "task_id",
3013 "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}"
3014 );
3015 run_blob_decode_test!(
3016 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3017 "started_at",
3018 "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}"
3019 );
3020
3021 async fn store_with_activity() -> (store::TaskStore, i64, i64) {
3024 let store = store::TaskStore::open_memory().await.unwrap();
3025 let task_id = store
3026 .create(&NewTask {
3027 external_id: None,
3028 repo: "owner/repo".to_string(),
3029 origin: "internal".to_string(),
3030 title: "Test task".to_string(),
3031 body: "Test body".to_string(),
3032 source: "cron".to_string(),
3033 source_id: "test-1".to_string(),
3034 author: "tester".to_string(),
3035 url: "https://example.com/1".to_string(),
3036 labels: vec![],
3037 parent_id: None,
3038 })
3039 .await
3040 .unwrap();
3041 store
3042 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3043 .await
3044 .unwrap();
3045 (store, task_id, 1)
3046 }
3047
3048 #[tokio::test]
3051 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3052 let (store, _task_id, activity_id) = store_with_activity().await;
3053 let sql = format!(
3054 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3055 );
3056 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3057 .fetch_one(store.pool())
3058 .await
3059 .unwrap();
3060 let err = TaskStore::row_to_activity(&row).unwrap_err();
3061 assert!(
3062 err.to_string().contains("id"),
3063 "expected 'id' in error message, got: {err}"
3064 );
3065 }
3066
3067 #[tokio::test]
3068 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3069 let (store, _task_id, activity_id) = store_with_activity().await;
3070 let sql = format!(
3071 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3072 );
3073 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3074 .fetch_one(store.pool())
3075 .await
3076 .unwrap();
3077 let err = TaskStore::row_to_activity(&row).unwrap_err();
3078 assert!(
3079 err.to_string().contains("task_id"),
3080 "expected 'task_id' in error message, got: {err}"
3081 );
3082 }
3083
3084 #[tokio::test]
3085 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3086 let (store, _task_id, activity_id) = store_with_activity().await;
3087 let sql = format!(
3088 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3089 );
3090 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3091 .fetch_one(store.pool())
3092 .await
3093 .unwrap();
3094 let err = TaskStore::row_to_activity(&row).unwrap_err();
3095 assert!(
3096 err.to_string().contains("timestamp"),
3097 "expected 'timestamp' in error message, got: {err}"
3098 );
3099 }
3100
3101 #[tokio::test]
3102 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3103 let (store, _task_id, activity_id) = store_with_activity().await;
3104 let sql = format!(
3105 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3106 );
3107 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3108 .fetch_one(store.pool())
3109 .await
3110 .unwrap();
3111 let err = TaskStore::row_to_activity(&row).unwrap_err();
3112 assert!(
3113 err.to_string().contains("event_type"),
3114 "expected 'event_type' in error message, got: {err}"
3115 );
3116 }
3117
3118 #[tokio::test]
3119 async fn row_to_activity_fails_on_corrupt_details_json() {
3120 let (store, task_id, _activity_id) = store_with_activity().await;
3121 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3123 .bind(task_id)
3124 .bind("test")
3125 .bind("not valid json {{{")
3126 .execute(store.pool())
3127 .await
3128 .unwrap();
3129 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
3130 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3131 )))
3132 .fetch_one(store.pool())
3133 .await
3134 .unwrap();
3135 let err = TaskStore::row_to_activity(&row).unwrap_err();
3136 assert!(
3137 err.to_string().contains("details")
3138 || err.to_string().contains("json")
3139 || err.to_string().contains("expected"),
3140 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3141 );
3142 }
3143
3144 run_blob_decode_test!(
3147 row_to_run_fails_on_decode_error_invalid_utf8_model,
3148 "model",
3149 "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}"
3150 );
3151 run_blob_decode_test!(
3152 row_to_run_fails_on_decode_error_invalid_utf8_command,
3153 "command",
3154 "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}"
3155 );
3156 run_blob_decode_test!(
3157 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3158 "prompt",
3159 "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}"
3160 );
3161 run_blob_decode_test!(
3162 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3163 "env_vars",
3164 "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}"
3165 );
3166 run_blob_decode_test!(
3167 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3168 "stdout",
3169 "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}"
3170 );
3171 run_blob_decode_test!(
3172 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3173 "stderr",
3174 "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}"
3175 );
3176 run_blob_decode_test!(
3177 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3178 "parsed_response",
3179 "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}"
3180 );
3181 run_blob_decode_test!(
3182 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3183 "outcome",
3184 "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}"
3185 );
3186 run_blob_decode_test!(
3187 row_to_run_fails_on_decode_error_invalid_utf8_error,
3188 "error",
3189 "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}"
3190 );
3191 run_blob_decode_test!(
3192 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3193 "input_tokens",
3194 "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}"
3195 );
3196 run_blob_decode_test!(
3197 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3198 "output_tokens",
3199 "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}"
3200 );
3201 run_blob_decode_test!(
3202 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3203 "total_cost_usd",
3204 "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}"
3205 );
3206 run_blob_decode_test!(
3207 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3208 "duration_secs",
3209 "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}"
3210 );
3211 run_blob_decode_test!(
3212 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3213 "exit_code",
3214 "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}"
3215 );
3216
3217 #[tokio::test]
3224 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3225 let (store, _task_id, activity_id) = store_with_activity().await;
3226 let sql = format!(
3227 "SELECT id, task_id, timestamp, event_type, \
3228 X'DeadbEEF' as from_status, NULL as to_status, \
3229 NULL as agent, NULL as model, details \
3230 FROM task_activity WHERE id = {activity_id}"
3231 );
3232 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3233 .fetch_one(store.pool())
3234 .await
3235 .unwrap();
3236 let err = TaskStore::row_to_activity(&row).unwrap_err();
3237 assert!(
3238 err.to_string().contains("from_status"),
3239 "expected 'from_status' in error message, got: {err}"
3240 );
3241 }
3242
3243 #[tokio::test]
3244 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3245 let (store, _task_id, activity_id) = store_with_activity().await;
3246 let sql = format!(
3247 "SELECT id, task_id, timestamp, event_type, \
3248 NULL as from_status, X'DeadbEEF' as to_status, \
3249 NULL as agent, NULL as model, details \
3250 FROM task_activity WHERE id = {activity_id}"
3251 );
3252 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3253 .fetch_one(store.pool())
3254 .await
3255 .unwrap();
3256 let err = TaskStore::row_to_activity(&row).unwrap_err();
3257 assert!(
3258 err.to_string().contains("to_status"),
3259 "expected 'to_status' in error message, got: {err}"
3260 );
3261 }
3262
3263 #[tokio::test]
3264 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3265 let (store, _task_id, activity_id) = store_with_activity().await;
3266 let sql = format!(
3267 "SELECT id, task_id, timestamp, event_type, \
3268 NULL as from_status, NULL as to_status, \
3269 X'DeadbEEF' as agent, NULL as model, details \
3270 FROM task_activity WHERE id = {activity_id}"
3271 );
3272 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3273 .fetch_one(store.pool())
3274 .await
3275 .unwrap();
3276 let err = TaskStore::row_to_activity(&row).unwrap_err();
3277 assert!(
3278 err.to_string().contains("agent"),
3279 "expected 'agent' in error message, got: {err}"
3280 );
3281 }
3282
3283 #[tokio::test]
3284 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3285 let (store, _task_id, activity_id) = store_with_activity().await;
3286 let sql = format!(
3287 "SELECT id, task_id, timestamp, event_type, \
3288 NULL as from_status, NULL as to_status, \
3289 NULL as agent, X'DeadbEEF' as model, details \
3290 FROM task_activity WHERE id = {activity_id}"
3291 );
3292 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3293 .fetch_one(store.pool())
3294 .await
3295 .unwrap();
3296 let err = TaskStore::row_to_activity(&row).unwrap_err();
3297 assert!(
3298 err.to_string().contains("model"),
3299 "expected 'model' in error message, got: {err}"
3300 );
3301 }
3302
3303 #[tokio::test]
3306 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3307 let store = store::TaskStore::open_memory().await.unwrap();
3312 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3313 .execute(store.pool())
3314 .await
3315 .unwrap();
3316 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3318 .execute(store.pool())
3319 .await
3320 .unwrap();
3321
3322 let result = store
3323 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3324 .await
3325 .unwrap();
3326 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3327 assert_eq!(result.get("valid-id"), Some(&TaskStatus::Done));
3328 }
3329}