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