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