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