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