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 status_entered_at(
652 &self,
653 task_id: i64,
654 status: TaskStatus,
655 ) -> anyhow::Result<Option<String>> {
656 let row: Option<(String,)> = sqlx::query_as(
657 "SELECT timestamp FROM task_activity
658 WHERE task_id = ? AND event_type = 'status_change' AND to_status = ?
659 ORDER BY id DESC LIMIT 1",
660 )
661 .bind(task_id)
662 .bind(status.as_str())
663 .fetch_optional(&self.pool)
664 .await?;
665 Ok(row.map(|(ts,)| ts))
666 }
667
668 pub async fn in_review_entry(&self, task_id: i64) -> anyhow::Result<Option<(String, bool)>> {
670 let row: Option<(String, bool)> = sqlx::query_as(
671 "SELECT a.timestamp, EXISTS (
672 SELECT 1 FROM task_activity d
673 WHERE d.task_id = a.task_id AND d.event_type = 'review_decision' AND d.id > a.id
674 )
675 FROM task_activity a
676 WHERE a.task_id = ? AND a.event_type = 'status_change' AND a.to_status = 'in_review'
677 ORDER BY a.id DESC LIMIT 1",
678 )
679 .bind(task_id)
680 .fetch_optional(&self.pool)
681 .await?;
682 Ok(row)
683 }
684
685 pub async fn get_activity(
687 &self,
688 task_id: i64,
689 limit: Option<usize>,
690 ) -> anyhow::Result<Vec<TaskActivity>> {
691 let rows = if let Some(limit) = limit {
692 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
693 sqlx::query(sqlx::AssertSqlSafe(format!(
694 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
695 )))
696 .bind(task_id)
697 .bind(limit_i64)
698 .fetch_all(&self.pool)
699 .await?
700 } else {
701 sqlx::query(sqlx::AssertSqlSafe(format!(
702 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
703 )))
704 .bind(task_id)
705 .fetch_all(&self.pool)
706 .await?
707 };
708
709 rows.iter().map(Self::row_to_activity).collect()
710 }
711
712 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
713 self.write_int(Command::TaskCreate {
714 new: new.clone(),
715 internal: false,
716 })
717 .await
718 }
719
720 pub async fn create_internal(
725 &self,
726 repo: &str,
727 title: &str,
728 body: &str,
729 source: &str,
730 source_id: &str,
731 parent_id: Option<i64>,
732 ) -> anyhow::Result<i64> {
733 let new = NewTask {
734 external_id: None,
735 repo: repo.to_string(),
736 origin: "internal".to_string(),
737 title: title.to_string(),
738 body: body.to_string(),
739 source: source.to_string(),
740 source_id: source_id.to_string(),
741 author: String::new(),
742 url: String::new(),
743 labels: vec![],
744 parent_id,
745 };
746 self.write_int(Command::TaskCreate {
747 new,
748 internal: true,
749 })
750 .await
751 }
752
753 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
755 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
756 "SELECT {TASK_COLS} FROM tasks WHERE id = ?"
757 )))
758 .bind(id)
759 .fetch_optional(&self.pool)
760 .await?
761 .with_context(|| format!("task {id} not found"))?;
762
763 Self::row_to_task(&row)
764 }
765
766 pub async fn get_by_external_id(
768 &self,
769 repo: &str,
770 ext_id: &str,
771 ) -> anyhow::Result<Option<Task>> {
772 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
773 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
774 )))
775 .bind(repo)
776 .bind(ext_id)
777 .fetch_optional(&self.pool)
778 .await?;
779
780 match row {
781 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
782 None => Ok(None),
783 }
784 }
785
786 pub async fn resolve_id_by_external(
792 &self,
793 repo: &str,
794 ext_id: &str,
795 ) -> anyhow::Result<Option<i64>> {
796 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
797 .bind(repo)
798 .bind(ext_id)
799 .fetch_optional(&self.pool)
800 .await?;
801 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
802 Ok(id)
803 }
804
805 pub async fn resolve_id_by_worktree(&self, path: &str) -> anyhow::Result<Option<i64>> {
807 if path.is_empty() {
808 return Ok(None);
809 }
810
811 let row = sqlx::query("SELECT id FROM tasks WHERE worktree = ? LIMIT 1")
812 .bind(path)
813 .fetch_optional(&self.pool)
814 .await?;
815 Ok(row.map(|r| r.try_get::<i64, _>("id")).transpose()?)
816 }
817
818 pub async fn resolve_id_by_pr_number(
824 &self,
825 repo: &str,
826 pr_number: i32,
827 ) -> anyhow::Result<Option<i64>> {
828 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
829 .bind(repo)
830 .bind(pr_number)
831 .fetch_optional(&self.pool)
832 .await?;
833 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
834 Ok(id)
835 }
836
837 pub async fn get_agent_by_external_id(
840 &self,
841 repo: &str,
842 ext_id: &str,
843 ) -> anyhow::Result<Option<String>> {
844 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
845 .bind(repo)
846 .bind(ext_id)
847 .fetch_optional(&self.pool)
848 .await?;
849 let agent = row
850 .map(|r| r.try_get::<Option<String>, _>("agent"))
851 .transpose()?
852 .flatten();
853 Ok(agent)
854 }
855
856 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
858 let parent_id = match parent_external_id_from_labels(ext.labels) {
859 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
860 None => None,
861 };
862 self.write_int(Command::TaskUpsertExternal(UpsertExternalCmd {
863 repo: ext.repo.to_string(),
864 ext_id: ext.ext_id.to_string(),
865 title: ext.title.to_string(),
866 body: ext.body.to_string(),
867 author: ext.author.to_string(),
868 url: ext.url.to_string(),
869 labels: ext.labels.to_vec(),
870 origin: ext.origin.to_string(),
871 parent_id,
872 }))
873 .await
874 }
875
876 pub async fn update_status_and_fields(
881 &self,
882 id: i64,
883 status: TaskStatus,
884 updates: &[(&str, serde_json::Value)],
885 ) -> anyhow::Result<()> {
886 let mut updates = owned_updates(updates)?;
887 if self.replicates() {
889 let local: Vec<_> = updates
890 .iter()
891 .filter(|(col, _)| is_local_column(col))
892 .cloned()
893 .collect();
894 updates.retain(|(col, _)| !is_local_column(col));
895
896 if !local.is_empty() {
897 let mut tx = self.begin_write().await?;
898 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &local, true).await?;
899 tx.commit().await?;
900 }
901 }
902
903 self.write(Command::TaskUpdateStatusAndFields {
904 id,
905 status,
906 updates,
907 })
908 .await
909 .map(|_| ())
910 }
911
912 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
914 self.write_bool(Command::TaskUpdateStatus {
915 id,
916 status,
917 expected: None,
918 })
919 .await
920 .map(|_| ())
921 }
922
923 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
925 self.write(Command::TaskUpdateExternalId {
926 id,
927 external_id: external_id.to_string(),
928 })
929 .await
930 .map(|_| ())
931 }
932
933 pub async fn update_status_if(
938 &self,
939 id: i64,
940 status: TaskStatus,
941 expected: TaskStatus,
942 ) -> anyhow::Result<bool> {
943 self.write_bool(Command::TaskUpdateStatus {
944 id,
945 status,
946 expected: Some(expected),
947 })
948 .await
949 }
950
951 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
959 self.write(Command::TaskTouch { id }).await.map(|_| ())
960 }
961
962 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
964 self.write(Command::TaskResetToNew { id }).await.map(|_| ())
965 }
966
967 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
969 let value = reason.map_or(serde_json::Value::Null, |r| {
970 serde_json::Value::String(r.to_string())
971 });
972 self.set_fields(id, &[("block_reason", value)]).await
973 }
974
975 pub async fn list_by_status(
977 &self,
978 repo: &str,
979 status: TaskStatus,
980 ) -> anyhow::Result<Vec<Task>> {
981 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
982 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
983 )))
984 .bind(repo)
985 .bind(status.as_str())
986 .fetch_all(&self.pool)
987 .await?;
988
989 rows.iter().map(Self::row_to_task).collect()
990 }
991
992 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
994 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
995 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
996 )))
997 .bind(repo)
998 .bind(parent_id)
999 .fetch_all(&self.pool)
1000 .await?;
1001
1002 rows.iter().map(Self::row_to_task).collect()
1003 }
1004
1005 pub async fn list_external_by_status(
1007 &self,
1008 repo: &str,
1009 status: TaskStatus,
1010 ) -> anyhow::Result<Vec<Task>> {
1011 let rows = sqlx::query(
1012 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC")),
1013 )
1014 .bind(repo)
1015 .bind(status.as_str())
1016 .fetch_all(&self.pool)
1017 .await?;
1018
1019 rows.iter().map(Self::row_to_task).collect()
1020 }
1021
1022 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1024 self.list_by_status(repo, TaskStatus::New).await
1025 }
1026
1027 pub async fn list_internal_by_status(
1029 &self,
1030 repo: &str,
1031 status: TaskStatus,
1032 ) -> anyhow::Result<Vec<Task>> {
1033 let rows = sqlx::query(
1034 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC")),
1035 )
1036 .bind(repo)
1037 .bind(status.as_str())
1038 .fetch_all(&self.pool)
1039 .await?;
1040
1041 rows.iter().map(Self::row_to_task).collect()
1042 }
1043
1044 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1046 let rows = sqlx::query(
1047 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC")),
1048 )
1049 .bind(repo)
1050 .fetch_all(&self.pool)
1051 .await?;
1052
1053 rows.iter().map(Self::row_to_task).collect()
1054 }
1055
1056 pub async fn list_internal_by_source(
1059 &self,
1060 repo: &str,
1061 source: &str,
1062 ) -> anyhow::Result<Vec<Task>> {
1063 let rows = sqlx::query(
1064 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC")),
1065 )
1066 .bind(repo)
1067 .bind(source)
1068 .fetch_all(&self.pool)
1069 .await?;
1070
1071 rows.iter().map(Self::row_to_task).collect()
1072 }
1073
1074 pub async fn list_source_ids_by_source(
1079 &self,
1080 repo: &str,
1081 source: &str,
1082 since_days: i64,
1083 ) -> anyhow::Result<Vec<String>> {
1084 let cutoff = format!("-{since_days} days");
1085 let rows = sqlx::query(
1086 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1087 )
1088 .bind(repo)
1089 .bind(source)
1090 .bind(&cutoff)
1091 .fetch_all(&self.pool)
1092 .await?;
1093
1094 let mut ids = Vec::with_capacity(rows.len());
1095 for row in &rows {
1096 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1097 anyhow::anyhow!(
1098 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1099 )
1100 })?);
1101 }
1102 Ok(ids)
1103 }
1104
1105 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1107 let rows = sqlx::query(
1108 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC")),
1109 )
1110 .bind(repo)
1111 .fetch_all(&self.pool)
1112 .await?;
1113
1114 rows.iter().map(Self::row_to_task).collect()
1115 }
1116
1117 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1119 let rows = sqlx::query(
1120 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC")),
1121 )
1122 .bind(repo)
1123 .fetch_all(&self.pool)
1124 .await?;
1125
1126 rows.iter().map(Self::row_to_task).collect()
1127 }
1128
1129 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1131 let has_external = sqlx::query_scalar::<_, i32>(
1132 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1133 )
1134 .bind(repo)
1135 .fetch_optional(&self.pool)
1136 .await?
1137 .is_some();
1138 Ok(has_external)
1139 }
1140
1141 pub async fn existing_external_ids(
1145 &self,
1146 repo: &str,
1147 ) -> anyhow::Result<std::collections::HashSet<String>> {
1148 let rows: Vec<(String,)> = sqlx::query_as(
1149 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1150 )
1151 .bind(repo)
1152 .fetch_all(&self.pool)
1153 .await?;
1154
1155 Ok(rows
1156 .into_iter()
1157 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1158 .collect())
1159 }
1160
1161 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1164 let rows = sqlx::query(
1165 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC")),
1166 )
1167 .bind(repo)
1168 .bind(cutoff_str)
1169 .fetch_all(&self.pool)
1170 .await?;
1171 rows.iter().map(Self::row_to_task).collect()
1172 }
1173
1174 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1176 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1177 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1178 )))
1179 .bind(repo)
1180 .fetch_all(&self.pool)
1181 .await?;
1182 rows.iter().map(Self::row_to_task).collect()
1183 }
1184
1185 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1187 let rows =
1188 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1189 .fetch_all(&self.pool)
1190 .await?;
1191 let mut repos = Vec::new();
1192 for row in &rows {
1193 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1194 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1195 })?);
1196 }
1197 Ok(repos)
1198 }
1199
1200 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1205 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1206 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1207 )))
1208 .fetch_all(&self.pool)
1209 .await?;
1210 rows.iter().map(Self::row_to_task).collect()
1211 }
1212
1213 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1217 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1218 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1219 )))
1220 .fetch_all(&self.pool)
1221 .await?;
1222 rows.iter().map(Self::row_to_task).collect()
1223 }
1224
1225 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1229 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1230 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1231 )))
1232 .bind(status.as_str())
1233 .fetch_all(&self.pool)
1234 .await?;
1235 rows.iter().map(Self::row_to_task).collect()
1236 }
1237
1238 pub async fn set_fields(
1241 &self,
1242 id: i64,
1243 updates: &[(&str, serde_json::Value)],
1244 ) -> anyhow::Result<()> {
1245 self.set_fields_split(id, updates, false).await
1246 }
1247
1248 pub async fn set_fields_silent(
1253 &self,
1254 id: i64,
1255 updates: &[(&str, serde_json::Value)],
1256 ) -> anyhow::Result<()> {
1257 self.set_fields_split(id, updates, true).await
1258 }
1259
1260 async fn set_fields_split(
1262 &self,
1263 id: i64,
1264 updates: &[(&str, serde_json::Value)],
1265 silent: bool,
1266 ) -> anyhow::Result<()> {
1267 let all = owned_updates(updates)?;
1268 if !self.replicates() {
1270 let mut tx = self.begin_write().await?;
1271 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &all, silent).await?;
1272 tx.commit().await?;
1273 return Ok(());
1274 }
1275
1276 let (local, replicated): (Vec<_>, Vec<_>) =
1277 all.into_iter().partition(|(col, _)| is_local_column(col));
1278 if !local.is_empty() {
1279 let mut tx = self.begin_write().await?;
1280 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &local, silent).await?;
1281 tx.commit().await?;
1282 }
1283
1284 if replicated.is_empty() {
1285 return Ok(());
1286 }
1287
1288 self.write(Command::TaskSetFields {
1289 id,
1290 updates: replicated,
1291 silent,
1292 })
1293 .await
1294 .map(|_| ())
1295 }
1296
1297 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1299 anyhow::ensure!(
1300 INCREMENTABLE_FIELDS.contains(&field),
1301 "field {field} is not incrementable"
1302 );
1303 let n = self
1304 .write_int(Command::TaskIncrement {
1305 id,
1306 field: field.to_string(),
1307 })
1308 .await?;
1309 Ok(i32::try_from(n)?)
1310 }
1311
1312 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1314 self.write(Command::TaskResetCounters {
1315 id,
1316 failure_only: false,
1317 })
1318 .await
1319 .map(|_| ())
1320 }
1321
1322 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1337 self.write(Command::TaskResetCounters {
1338 id,
1339 failure_only: true,
1340 })
1341 .await
1342 .map(|_| ())
1343 }
1344
1345 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1351 self.write(Command::TaskStoreRoute(StoreRouteCmd {
1352 id: route.id,
1353 agent: route.agent.to_string(),
1354 model: route.model.map(str::to_string),
1355 complexity: route.complexity.to_string(),
1356 estimate: route.estimate,
1357 reason: route.reason.to_string(),
1358 profile: route.profile.to_string(),
1359 skills: route.skills.to_string(),
1360 }))
1361 .await
1362 .map(|_| ())
1363 }
1364
1365 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1373 let rows = sqlx::query(
1374 "SELECT external_id, estimate FROM tasks \
1375 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1376 AND status NOT IN ('done', 'blocked')",
1377 )
1378 .fetch_all(&self.pool)
1379 .await?;
1380
1381 let mut result = Vec::new();
1382 for row in &rows {
1383 let external_id: String = row.try_get("external_id")?;
1384 let estimate_raw: i64 = row.try_get("estimate")?;
1385 if let Ok(estimate @ 1..) = u8::try_from(estimate_raw) {
1386 result.push((external_id, estimate));
1387 }
1388 }
1389 Ok(result)
1390 }
1391
1392 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1398 self.write_bool(Command::TaskSetEstimateIfZero { id, estimate })
1399 .await
1400 }
1401
1402 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1411 self.write(Command::TaskAppendMemory {
1412 id,
1413 entry: entry.clone(),
1414 })
1415 .await
1416 .map(|_| ())
1417 }
1418
1419 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1421 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1422 .bind(id)
1423 .fetch_one(&self.pool)
1424 .await?;
1425
1426 let memory_opt: Option<String> = row
1427 .try_get::<Option<String>, _>("memory")
1428 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1429 let memory_str = memory_opt.unwrap_or_default();
1430
1431 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1432 Vec::new()
1433 } else {
1434 serde_json::from_str(&memory_str)
1435 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1436 };
1437
1438 memory.sort_by_key(|m| m.attempt);
1439 if memory.len() > max {
1440 memory = memory.split_off(memory.len() - max);
1441 }
1442 Ok(memory)
1443 }
1444
1445 pub async fn store_tokens(
1451 &self,
1452 id: i64,
1453 input: u64,
1454 output: u64,
1455 model: &str,
1456 ) -> anyhow::Result<()> {
1457 let pricing = pricing_for_model(model);
1458 let usage = TokenUsage {
1459 input_tokens: input,
1460 output_tokens: output,
1461 };
1462 let cost = pricing.estimate_cost_usd(usage);
1463
1464 sqlx::query(
1465 "UPDATE tasks SET
1466 input_tokens = COALESCE(input_tokens, 0) + ?,
1467 output_tokens = COALESCE(output_tokens, 0) + ?,
1468 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1469 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1470 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1471 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1472 WHERE id = ?",
1473 )
1474 .bind(i64::try_from(input).unwrap_or_else(|_| {
1475 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1476 i64::MAX
1477 }))
1478 .bind(i64::try_from(output).unwrap_or_else(|_| {
1479 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1480 i64::MAX
1481 }))
1482 .bind(cost.input_cost_usd)
1483 .bind(cost.output_cost_usd)
1484 .bind(cost.total_cost_usd)
1485 .bind(id)
1486 .execute(&self.pool)
1487 .await?;
1488
1489 Ok(())
1490 }
1491
1492 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1498 self.write(Command::TaskMarkCleaned { id })
1499 .await
1500 .map(|_| ())
1501 }
1502
1503 #[cfg(test)]
1509 pub async fn batch_set_fields(
1510 &self,
1511 updates: &[(i64, &[(&str, serde_json::Value)])],
1512 ) -> anyhow::Result<()> {
1513 if updates.is_empty() {
1514 return Ok(());
1515 }
1516
1517 for (_, entry_updates) in updates {
1518 for (col, _) in *entry_updates {
1519 anyhow::ensure!(
1520 ALLOWED_FIELDS.contains(col),
1521 "column {col} is not in the update allowlist"
1522 );
1523 }
1524 }
1525
1526 let mut tx = self.begin_write().await?;
1527 for (id, entry_updates) in updates {
1528 if entry_updates.is_empty() {
1529 continue;
1530 }
1531 let mut set_parts = Vec::new();
1532 let mut values: Vec<Option<String>> = Vec::new();
1533 for (col, val) in *entry_updates {
1534 set_parts.push(format!("{col} = ?"));
1535 values.push(sql_value_for_task_field(col, val)?);
1536 }
1537 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1538 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1539 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1540 for v in &values {
1541 query = query.bind(v.as_deref());
1542 }
1543 query = query.bind(*id);
1544 query.execute(&mut *tx).await?;
1545 }
1546 tx.commit().await?;
1547 Ok(())
1548 }
1549
1550 #[cfg(test)]
1555 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1556 if entries.is_empty() {
1557 return Ok(());
1558 }
1559
1560 for (_, field) in entries {
1561 anyhow::ensure!(
1562 INCREMENTABLE_FIELDS.contains(field),
1563 "field {field} is not incrementable"
1564 );
1565 }
1566
1567 let mut tx = self.begin_write().await?;
1568 for (id, field) in entries {
1569 let sql = format!(
1570 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1571 );
1572 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
1573 .bind(*id)
1574 .execute(&mut *tx)
1575 .await?;
1576 }
1577 tx.commit().await?;
1578 Ok(())
1579 }
1580
1581 #[cfg(test)]
1588 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1589 const CHUNK_SIZE: usize = 500;
1592 if ids.is_empty() {
1593 return Ok(());
1594 }
1595
1596 let mut tx = self.begin_write().await?;
1597
1598 for chunk in ids.chunks(CHUNK_SIZE) {
1599 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1601 let sql = format!(
1602 "UPDATE tasks SET
1603 route_attempts = 0,
1604 review_agent_failures = 0,
1605 review_resets = 0,
1606 review_invocations = 0,
1607 pr_create_failures = 0,
1608 push_failures = 0,
1609 network_retries = 0,
1610 auto_unblock_count = 0,
1611 auto_unblock_last_at = '',
1612 auto_unblock_last_reason = '',
1613 ci_recovery_count = 0,
1614 no_code_reroutes = 0,
1615 needs_review_refires = 0,
1616 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1617 WHERE id IN ({placeholders})",
1618 );
1619 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1620 for id in chunk {
1621 query = query.bind(*id);
1622 }
1623 query.execute(&mut *tx).await?;
1624 }
1625
1626 tx.commit().await?;
1627 Ok(())
1628 }
1629
1630 #[cfg(test)]
1635 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1636 const CHUNK_SIZE: usize = 500;
1638 if ids.is_empty() {
1639 return Ok(());
1640 }
1641
1642 let mut tx = self.begin_write().await?;
1643
1644 for chunk in ids.chunks(CHUNK_SIZE) {
1645 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1646 let sql = format!(
1647 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1648 );
1649 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1650 for id in chunk {
1651 query = query.bind(*id);
1652 }
1653 query.execute(&mut *tx).await?;
1654 }
1655
1656 let details = serde_json::json!({ "worktree_cleaned": true });
1660 let details_json = serde_json::to_string(&details)?;
1661 for id in ids {
1662 sqlx::query(
1663 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1664 )
1665 .bind(*id)
1666 .bind("branch_delete")
1667 .bind(None::<&str>)
1668 .bind(None::<&str>)
1669 .bind(None::<&str>)
1670 .bind(None::<&str>)
1671 .bind(details_json.as_str())
1672 .execute(&mut *tx)
1673 .await?;
1674 }
1675
1676 tx.commit().await?;
1677 Ok(())
1678 }
1679
1680 #[cfg(test)]
1683 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1684 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1685 "SELECT {TASK_COLS} FROM tasks
1686 WHERE repo = ?
1687 AND worktree != ''
1688 AND worktree_cleaned = 0
1689 AND status IN ('done', 'blocked')
1690 ORDER BY updated_at ASC"
1691 )))
1692 .bind(repo)
1693 .fetch_all(&self.pool)
1694 .await?;
1695
1696 rows.iter().map(Self::row_to_task).collect()
1697 }
1698
1699 pub async fn cleaned_external_ids(
1704 &self,
1705 repo: &str,
1706 ) -> anyhow::Result<std::collections::HashSet<String>> {
1707 let rows: Vec<(String,)> =
1708 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1709 .bind(repo)
1710 .fetch_all(&self.pool)
1711 .await?;
1712
1713 Ok(rows.into_iter().map(|(id,)| id).collect())
1714 }
1715
1716 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1722 let orphaned: Vec<(i64,)> = sqlx::query_as(
1734 "SELECT id FROM task_runs
1735 WHERE task_id = ? AND run_type = ? AND attempt != ?
1736 AND outcome IS NULL AND completed_at IS NULL",
1737 )
1738 .bind(run.task_id)
1739 .bind(run.run_type)
1740 .bind(run.attempt)
1741 .fetch_all(&self.pool)
1742 .await?;
1743
1744 for (run_id,) in orphaned {
1745 self.complete_run(&CompleteRun {
1746 run_id,
1747 exit_code: Some(-1),
1748 stdout: "",
1749 stderr: "",
1750 parsed: "",
1751 outcome: "aborted",
1752 error: "superseded by a new run start before this one completed",
1753 tokens: RunTokenUsage::default(),
1754 })
1755 .await?;
1756 }
1757
1758 let row = sqlx::query(
1759 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
1760 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
1761 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
1762 agent = excluded.agent, model = excluded.model,
1763 command = excluded.command, prompt = excluded.prompt,
1764 outcome = NULL,
1765 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
1766 completed_at = NULL, error = NULL, exit_code = NULL,
1767 stdout = NULL, stderr = NULL, parsed_response = NULL,
1768 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
1769 total_cost_usd = NULL
1770 RETURNING id",
1771 )
1772 .bind(run.task_id)
1773 .bind(run.attempt)
1774 .bind(run.run_type)
1775 .bind(run.agent)
1776 .bind(run.model)
1777 .bind(run.command)
1778 .bind(run.prompt)
1779 .fetch_one(&self.pool)
1780 .await?;
1781
1782 let run_id: i64 = row
1783 .try_get("id")
1784 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
1785 let event_type = match run.run_type {
1786 "review" => "review_start",
1787 _ => "dispatch",
1788 };
1789 let details = serde_json::json!({
1790 "run_type": run.run_type,
1791 "attempt": run.attempt,
1792 "command": run.command,
1793 });
1794 self.append_activity(
1795 run.task_id,
1796 event_type,
1797 None,
1798 None,
1799 Some(run.agent),
1800 Some(run.model),
1801 Some(&details),
1802 )
1803 .await?;
1804
1805 Ok(run_id)
1806 }
1807
1808 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
1810 let run_ctx =
1811 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
1812 .bind(run.run_id)
1813 .fetch_optional(&self.pool)
1814 .await?;
1815 sqlx::query(
1816 "UPDATE task_runs SET
1817 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
1818 outcome = ?, error = NULLIF(?, ''),
1819 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
1820 duration_secs = CASE WHEN ? > 0 THEN ?
1821 ELSE MAX(0, (julianday('now') - julianday(started_at)) * 86400.0) END,
1822 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1823 WHERE id = ?",
1824 )
1825 .bind(run.exit_code)
1826 .bind(run.stdout)
1827 .bind(security::redact(run.stderr))
1828 .bind(run.parsed)
1829 .bind(run.outcome)
1830 .bind(security::redact(run.error))
1831 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1832 tracing::warn!(
1833 run.tokens.input_tokens,
1834 "input_tokens overflows i64, clamping to MAX"
1835 );
1836 i64::MAX
1837 }))
1838 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1839 tracing::warn!(
1840 run.tokens.output_tokens,
1841 "output_tokens overflows i64, clamping to MAX"
1842 );
1843 i64::MAX
1844 }))
1845 .bind(run.tokens.total_cost_usd)
1846 .bind(run.tokens.duration_secs)
1847 .bind(run.tokens.duration_secs)
1848 .bind(run.run_id)
1849 .execute(&self.pool)
1850 .await?;
1851 if let Some(run_ctx) = run_ctx {
1852 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1853 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1854 })?;
1855 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1856 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1857 })?;
1858 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1859 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1860 })?;
1861 let model: String = run_ctx.try_get("model").map_err(|e| {
1862 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1863 })?;
1864 if run.outcome == "timeout" {
1865 self.append_activity(
1866 task_id,
1867 "timeout",
1868 None,
1869 None,
1870 Some(&agent),
1871 Some(&model),
1872 Some(&serde_json::json!({
1873 "run_type": run_type,
1874 "error": run.error,
1875 "stderr": run.stderr,
1876 })),
1877 )
1878 .await?;
1879 } else if run.outcome != "success" {
1880 self.append_activity(
1881 task_id,
1882 "error",
1883 None,
1884 None,
1885 Some(&agent),
1886 Some(&model),
1887 Some(&serde_json::json!({
1888 "run_type": run_type,
1889 "outcome": run.outcome,
1890 "error": run.error,
1891 })),
1892 )
1893 .await?;
1894 }
1895 } else if run.outcome != "success" {
1896 tracing::warn!(
1897 run_id = run.run_id,
1898 outcome = run.outcome,
1899 "run_id not found in task_runs; skipping timeout/error activity log"
1900 );
1901 }
1902 Ok(())
1903 }
1904
1905 pub async fn finalize_incomplete_runs(
1910 &self,
1911 task_id: i64,
1912 outcome: &str,
1913 error: &str,
1914 ) -> anyhow::Result<u64> {
1915 let rows: Vec<(i64,)> = sqlx::query_as(
1916 "SELECT id FROM task_runs
1917 WHERE task_id = ?
1918 AND outcome IS NULL
1919 AND completed_at IS NULL
1920 ORDER BY id ASC",
1921 )
1922 .bind(task_id)
1923 .fetch_all(&self.pool)
1924 .await?;
1925
1926 for (run_id,) in &rows {
1927 self.complete_run(&CompleteRun {
1928 run_id: *run_id,
1929 exit_code: Some(-1),
1930 stdout: "",
1931 stderr: "",
1932 parsed: "",
1933 outcome,
1934 error,
1935 tokens: RunTokenUsage::default(),
1936 })
1937 .await?;
1938 }
1939
1940 Ok(rows.len() as u64)
1941 }
1942
1943 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
1945 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1946 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
1947 )))
1948 .bind(task_id)
1949 .fetch_all(&self.pool)
1950 .await?;
1951
1952 rows.iter().map(Self::row_to_run).collect()
1953 }
1954
1955 pub async fn get_runs_batch(
1958 &self,
1959 task_ids: &[i64],
1960 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
1961 const CHUNK_SIZE: usize = 500;
1964 if task_ids.is_empty() {
1965 return Ok(std::collections::HashMap::new());
1966 }
1967
1968 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
1969 std::collections::HashMap::new();
1970
1971 for chunk in task_ids.chunks(CHUNK_SIZE) {
1972 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1973 let sql = format!(
1974 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
1975 );
1976 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1977 for id in chunk {
1978 query = query.bind(*id);
1979 }
1980 let rows = query.fetch_all(&self.pool).await?;
1981 for row in &rows {
1982 let run = Self::row_to_run(row)?;
1983 result.entry(run.task_id).or_default().push(run);
1984 }
1985 }
1986 Ok(result)
1987 }
1988
1989 pub async fn get_batch(
1992 &self,
1993 ids: &[i64],
1994 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
1995 const CHUNK_SIZE: usize = 500;
1998 if ids.is_empty() {
1999 return Ok(std::collections::HashMap::new());
2000 }
2001
2002 let mut result = std::collections::HashMap::new();
2003
2004 for chunk in ids.chunks(CHUNK_SIZE) {
2005 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2006 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2007 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2008 for id in chunk {
2009 query = query.bind(*id);
2010 }
2011 let rows = query.fetch_all(&self.pool).await?;
2012 for row in &rows {
2013 let task = Self::row_to_task(row)?;
2014 result.insert(task.id, task);
2015 }
2016 }
2017 Ok(result)
2018 }
2019
2020 pub async fn get_statuses_by_external_ids(
2025 &self,
2026 repo: &str,
2027 ext_ids: &[&str],
2028 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2029 const CHUNK_SIZE: usize = 500;
2030 let mut result = std::collections::HashMap::new();
2031 if ext_ids.is_empty() {
2032 return Ok(result);
2033 }
2034
2035 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2036 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2037 let sql = format!(
2038 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2039 );
2040 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2041 query = query.bind(repo);
2042 for id in chunk {
2043 query = query.bind(id);
2044 }
2045 let rows = query.fetch_all(&self.pool).await?;
2046 for row in &rows {
2047 let external_id: Result<String, _> = row.try_get("external_id");
2048 let status_str: Result<String, _> = row.try_get("status");
2049 match (external_id, status_str) {
2050 (Ok(ext_id), Ok(sts)) => {
2051 if ext_id.is_empty() {
2052 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2053 continue;
2054 }
2055 if let Some(status) = TaskStatus::from_str(&sts) {
2056 result.insert(ext_id, status);
2057 } else {
2058 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2059 }
2060 }
2061 (Err(e), _) => {
2062 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2063 }
2064 (_, Err(e)) => {
2065 tracing::warn!(error = %e, "skipping task row with decode error for status");
2066 }
2067 }
2068 }
2069 }
2070
2071 Ok(result)
2072 }
2073
2074 #[cfg(test)]
2077 pub async fn get_last_run(
2078 &self,
2079 task_id: i64,
2080 run_type: &str,
2081 ) -> anyhow::Result<Option<TaskRun>> {
2082 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
2083 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2084 )))
2085 .bind(task_id)
2086 .bind(run_type)
2087 .fetch_optional(&self.pool)
2088 .await?;
2089
2090 match row {
2091 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2092 None => Ok(None),
2093 }
2094 }
2095
2096 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2100 let rows: Vec<(String,)> = sqlx::query_as(
2101 "SELECT DISTINCT agent FROM task_runs \
2102 WHERE task_id = ? AND run_type = 'review' \
2103 ORDER BY agent",
2104 )
2105 .bind(task_id)
2106 .fetch_all(&self.pool)
2107 .await?;
2108 Ok(rows.into_iter().map(|(a,)| a).collect())
2109 }
2110
2111 #[cfg(test)]
2114 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2115 let result = sqlx::query(
2116 "DELETE FROM task_runs WHERE task_id IN (
2117 SELECT id FROM tasks
2118 WHERE status IN ('done', 'blocked')
2119 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2120 )",
2121 )
2122 .bind(format!("-{days}"))
2123 .execute(&self.pool)
2124 .await?;
2125
2126 Ok(result.rows_affected())
2127 }
2128
2129 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2134 let col_count = row.len();
2137 if col_count != TASK_COLS_COUNT {
2138 tracing::warn!(
2139 expected = TASK_COLS_COUNT,
2140 actual = col_count,
2141 "TASK_COLS column count mismatch - schema may have changed"
2142 );
2143 }
2144
2145 let status_str: String = row
2146 .try_get("status")
2147 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2148 let labels_str: String = row.try_get("labels").unwrap_or_default();
2149 let memory_str: String = row.try_get("memory").unwrap_or_default();
2150 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2151
2152 Ok(Task {
2153 id: row
2154 .try_get("id")
2155 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2156 external_id: row.try_get("external_id").unwrap_or(None),
2157 repo: row
2158 .try_get("repo")
2159 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2160 origin: row
2161 .try_get("origin")
2162 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2163 title: row
2164 .try_get("title")
2165 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2166 body: row
2167 .try_get("body")
2168 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2169 status: TaskStatus::from_str(&status_str)
2170 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2171 source: row
2172 .try_get("source")
2173 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2174 source_id: row
2175 .try_get("source_id")
2176 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2177 author: row
2178 .try_get("author")
2179 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2180 url: row
2181 .try_get("url")
2182 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2183 labels: serde_json::from_str(&labels_str)
2184 .inspect_err(
2185 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2186 )
2187 .unwrap_or_default(),
2188 agent: row.try_get("agent").unwrap_or(None),
2189 model: row.try_get("model").unwrap_or(None),
2190 complexity: row.try_get("complexity").unwrap_or_default(),
2191 estimate: {
2192 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2193 u8::try_from(raw).unwrap_or_else(|_| {
2194 tracing::warn!(
2195 raw_estimate = raw,
2196 "estimate value out of u8 range; defaulting to 0"
2197 );
2198 0
2199 })
2200 },
2201 route_reason: row.try_get("route_reason").unwrap_or_default(),
2202 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2203 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2204 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2205 attempts: row.try_get("attempts").unwrap_or(0),
2206 branch: row.try_get("branch").unwrap_or_default(),
2207 worktree: row.try_get("worktree").unwrap_or_default(),
2208 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2209 owner_node: row.try_get("owner_node").unwrap_or(None),
2210 summary: row.try_get("summary").unwrap_or_default(),
2211 last_error: row.try_get("last_error").unwrap_or_default(),
2212 parent_id: row.try_get("parent_id").unwrap_or(None),
2213 block_reason: row.try_get("block_reason").unwrap_or(None),
2214 pr_number: row.try_get("pr_number").unwrap_or(None),
2215 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2216 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2217 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2218 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2219 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2220 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2221 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2222 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2223 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2224 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2225 review_resets: row.try_get::<i32, _>("review_resets").unwrap_or(0),
2226 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2227 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2228 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2229 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2230 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2231 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2232 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2233 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2234 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2235 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2236 memory: serde_json::from_str(&memory_str)
2237 .inspect_err(
2238 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2239 )
2240 .unwrap_or_default(),
2241 delegations: serde_json::from_str(&delegations_str)
2242 .inspect_err(
2243 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2244 )
2245 .unwrap_or_default(),
2246 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2251 auto_unblock_last_at: row
2252 .try_get::<String, _>("auto_unblock_last_at")
2253 .unwrap_or_default(),
2254 auto_unblock_last_reason: row
2255 .try_get::<String, _>("auto_unblock_last_reason")
2256 .unwrap_or_default(),
2257 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2258 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2259 no_code_last_agent: row
2260 .try_get::<String, _>("no_code_last_agent")
2261 .unwrap_or_default(),
2262 created_at: row
2263 .try_get::<String, _>("created_at")
2264 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2265 updated_at: row
2266 .try_get::<String, _>("updated_at")
2267 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2268 })
2269 }
2270 pub async fn ensure_external_task(
2271 &self,
2272 repo: &str,
2273 ext: &crate::backends::ExternalTask,
2274 ) -> anyhow::Result<i64> {
2275 self.upsert_external(&UpsertExternal {
2276 repo,
2277 ext_id: &ext.id.0,
2278 title: &ext.title,
2279 body: &ext.body,
2280 author: &ext.author,
2281 url: &ext.url,
2282 labels: &ext.labels,
2283 origin: "github",
2284 })
2285 .await
2286 }
2287
2288 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2291 if let Some(suffix) = task_id.strip_prefix("internal:") {
2292 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2294 return Ok(Some(id));
2295 }
2296 if let Ok(id) = suffix.parse::<i64>() {
2300 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2301 .bind(id)
2302 .bind(repo)
2303 .fetch_optional(&self.pool)
2304 .await?;
2305 if exists.is_some() {
2306 return Ok(Some(id));
2307 }
2308 }
2309 return Ok(None);
2310 }
2311 self.resolve_id_by_external(repo, task_id).await
2314 }
2315
2316 pub async fn resolve_task_id_globally(
2322 &self,
2323 task_id: &str,
2324 ) -> anyhow::Result<Option<(i64, String)>> {
2325 if let Some(suffix) = task_id.strip_prefix("internal:") {
2326 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2328 .bind(task_id)
2329 .fetch_optional(&self.pool)
2330 .await?;
2331 if let Some(r) = row {
2332 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2333 }
2334 if let Ok(id) = suffix.parse::<i64>() {
2335 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2336 .bind(id)
2337 .fetch_optional(&self.pool)
2338 .await?;
2339 if let Some(r) = row {
2340 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2341 }
2342 }
2343 return Ok(None);
2344 }
2345 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2347 .bind(task_id)
2348 .fetch_all(&self.pool)
2349 .await?;
2350 match rows.len() {
2351 0 => Ok(None),
2352 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2353 _ => {
2354 let repos: Vec<String> = rows
2355 .iter()
2356 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2357 .collect();
2358 anyhow::bail!(
2359 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2360 Run from the repo worktree so the current-repo lookup resolves it.",
2361 repos.join(", ")
2362 )
2363 }
2364 }
2365 }
2366
2367 pub async fn resolve_cli_id(
2369 &self,
2370 spec: &str,
2371 current_repo: Option<&str>,
2372 ) -> anyhow::Result<Option<CliTaskRef>> {
2373 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2374 match parse_cli_task_spec(spec)? {
2375 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2376 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2377 CliTaskSpec::Bare(number) => {
2378 if let Some(repo) = current_repo {
2379 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2380 return Ok(Some(task));
2381 }
2382 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2383 return Ok(Some(task));
2384 }
2385 return Ok(None);
2386 }
2387 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2388 match matches.len() {
2389 0 => Ok(None),
2390 1 => Ok(matches.pop()),
2391 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2392 }
2393 }
2394 }
2395 }
2396
2397 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2398 let ext = format!("internal:{number}");
2399 let rows = sqlx::query(
2400 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2401 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2402 ORDER BY id",
2403 )
2404 .bind(number)
2405 .bind(&ext)
2406 .fetch_all(&self.pool)
2407 .await?;
2408 let mut matches = dedup_cli_refs(rows)?;
2409 match matches.len() {
2410 0 => Ok(None),
2411 1 => Ok(matches.pop()),
2412 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2413 }
2414 }
2415
2416 async fn lookup_internal_in_repo(
2417 &self,
2418 number: i64,
2419 repo: &str,
2420 ) -> anyhow::Result<Option<CliTaskRef>> {
2421 let row = sqlx::query(
2422 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2423 WHERE origin = 'internal' AND id = ? AND repo = ?",
2424 )
2425 .bind(number)
2426 .bind(repo)
2427 .fetch_optional(&self.pool)
2428 .await?;
2429 match row {
2430 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2431 None => Ok(None),
2432 }
2433 }
2434
2435 async fn lookup_issue_cli_id(
2436 &self,
2437 repo: &str,
2438 number: i64,
2439 ) -> anyhow::Result<Option<CliTaskRef>> {
2440 let ext = number.to_string();
2441 let rows = sqlx::query(
2442 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2443 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2444 ORDER BY id",
2445 )
2446 .bind(repo)
2447 .bind(&ext)
2448 .fetch_all(&self.pool)
2449 .await?;
2450 let mut matches = dedup_cli_refs(rows)?;
2451 match matches.len() {
2452 0 => Ok(None),
2453 1 => Ok(matches.pop()),
2454 _ => Err(ambiguous_cli_id_error(
2455 &format!("{repo}#{number}"),
2456 &matches,
2457 )),
2458 }
2459 }
2460
2461 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2462 let ext = number.to_string();
2463 let rows = sqlx::query(
2464 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2465 WHERE (origin = 'internal' AND id = ?) \
2466 OR (origin != 'internal' AND external_id = ?) \
2467 ORDER BY repo, id",
2468 )
2469 .bind(number)
2470 .bind(&ext)
2471 .fetch_all(&self.pool)
2472 .await?;
2473 dedup_cli_refs(rows)
2474 }
2475
2476 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2477 Ok(TaskRun {
2478 id: row
2479 .try_get("id")
2480 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2481 task_id: row
2482 .try_get("task_id")
2483 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2484 attempt: row
2485 .try_get("attempt")
2486 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2487 run_type: row
2488 .try_get("run_type")
2489 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2490 agent: row
2491 .try_get("agent")
2492 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2493 model: row
2494 .try_get("model")
2495 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2496 command: row
2497 .try_get("command")
2498 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2499 prompt: row
2500 .try_get("prompt")
2501 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2502 env_vars: row
2503 .try_get("env_vars")
2504 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2505 exit_code: row
2506 .try_get("exit_code")
2507 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2508 stdout: row
2509 .try_get("stdout")
2510 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2511 stderr: row
2512 .try_get("stderr")
2513 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2514 parsed_response: row
2515 .try_get("parsed_response")
2516 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2517 outcome: row
2518 .try_get("outcome")
2519 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2520 error: row
2521 .try_get("error")
2522 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2523 input_tokens: row
2524 .try_get("input_tokens")
2525 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2526 output_tokens: row
2527 .try_get("output_tokens")
2528 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2529 total_cost_usd: row
2530 .try_get("total_cost_usd")
2531 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2532 duration_secs: row
2533 .try_get("duration_secs")
2534 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2535 started_at: row
2536 .try_get("started_at")
2537 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2538 completed_at: row.try_get("completed_at").unwrap_or(None),
2539 })
2540 }
2541
2542 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2543 let details_str: String = row
2544 .try_get("details")
2545 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2546 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2547 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2548 )?;
2549 Ok(TaskActivity {
2550 id: row
2551 .try_get("id")
2552 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2553 task_id: row
2554 .try_get("task_id")
2555 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2556 timestamp: row
2557 .try_get("timestamp")
2558 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2559 event_type: row
2560 .try_get("event_type")
2561 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2562 from_status: row
2563 .try_get("from_status")
2564 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2565 to_status: row
2566 .try_get("to_status")
2567 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2568 agent: row
2569 .try_get("agent")
2570 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2571 model: row
2572 .try_get("model")
2573 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2574 details,
2575 })
2576 }
2577}
2578
2579const LOCAL_TASK_COLUMNS: &[&str] = &[
2581 "worktree",
2582 "input_tokens",
2583 "output_tokens",
2584 "input_cost_usd",
2585 "output_cost_usd",
2586 "total_cost_usd",
2587];
2588
2589pub(crate) fn is_local_column(col: &str) -> bool {
2590 LOCAL_TASK_COLUMNS.contains(&col)
2591}
2592
2593#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2595pub struct UpsertExternalCmd {
2596 pub repo: String,
2597 pub ext_id: String,
2598 pub title: String,
2599 pub body: String,
2600 pub author: String,
2601 pub url: String,
2602 pub labels: Vec<String>,
2603 pub origin: String,
2604 pub parent_id: Option<i64>,
2605}
2606
2607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2609pub struct StoreRouteCmd {
2610 pub id: i64,
2611 pub agent: String,
2612 pub model: Option<String>,
2613 pub complexity: String,
2614 pub estimate: u8,
2615 pub reason: String,
2616 pub profile: String,
2617 pub skills: String,
2618}
2619
2620const TOMBSTONE_COLUMNS: &[&str] = &[
2622 "id",
2623 "external_id",
2624 "repo",
2625 "origin",
2626 "title",
2627 "status",
2628 "pr_number",
2629 "parent_id",
2630 "created_at",
2631 "updated_at",
2632];
2633
2634async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2635 let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2636 .fetch_all(conn)
2637 .await?;
2638 Ok(cols
2639 .into_iter()
2640 .map(|(c,)| c)
2641 .filter(|c| !is_local_column(c))
2642 .collect())
2643}
2644
2645pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2647 let mut conn = pool.acquire().await?;
2648 let cols = replicated_columns(&mut conn).await?;
2649 let full: Vec<&String> = cols.iter().collect();
2650 let tomb: Vec<&String> = cols
2651 .iter()
2652 .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2653 .collect();
2654 let sql = format!(
2655 "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2656 super::replica::json_row_sql(&tomb),
2657 super::replica::json_row_sql(&full)
2658 );
2659 let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
2660 .fetch_all(&mut *conn)
2661 .await?;
2662 rows.into_iter()
2663 .map(|(r,)| Ok(serde_json::from_str(&r)?))
2664 .collect()
2665}
2666
2667pub(crate) async fn replace_replicated(
2670 conn: &mut sqlx::SqliteConnection,
2671 rows: &[serde_json::Value],
2672) -> anyhow::Result<()> {
2673 let allowed = replicated_columns(conn).await?;
2674 sqlx::query("PRAGMA defer_foreign_keys = ON")
2675 .execute(&mut *conn)
2676 .await?;
2677 sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2678 .execute(&mut *conn)
2679 .await?;
2680 sqlx::query("DELETE FROM snapshot_ids")
2681 .execute(&mut *conn)
2682 .await?;
2683 for row in rows {
2684 let id = row["id"]
2685 .as_i64()
2686 .context("snapshot task row without an id")?;
2687 sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2688 .bind(id)
2689 .execute(&mut *conn)
2690 .await?;
2691 }
2692
2693 for table in ["task_runs", "task_activity"] {
2694 sqlx::query(sqlx::AssertSqlSafe(format!(
2695 "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2696 )))
2697 .execute(&mut *conn)
2698 .await?;
2699 }
2700
2701 sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2702 .execute(&mut *conn)
2703 .await?;
2704
2705 for row in rows {
2706 let obj = row
2707 .as_object()
2708 .context("snapshot task row is not an object")?;
2709 let cols: Vec<&String> = obj.keys().collect();
2710 for c in &cols {
2711 anyhow::ensure!(
2712 allowed.contains(c),
2713 "snapshot names unknown task column {c}"
2714 );
2715 }
2716
2717 let values: Vec<String> = cols
2718 .iter()
2719 .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2720 .collect();
2721 let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2722 let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2723 let sql = format!(
2724 "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2725 names.join(", "),
2726 values.join(", "),
2727 updates.join(", ")
2728 );
2729 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2730 .bind(serde_json::to_string(row)?)
2731 .execute(&mut *conn)
2732 .await?;
2733 }
2734
2735 sqlx::query("DROP TABLE snapshot_ids")
2736 .execute(&mut *conn)
2737 .await?;
2738 Ok(())
2739}
2740
2741pub(crate) mod apply {
2743 use super::{
2744 ALLOWED_FIELDS, INCREMENTABLE_FIELDS, MemoryEntry, NewTask, Row, StoreRouteCmd, TaskStatus,
2745 UpsertExternalCmd, sanitize_activity_details, sql_value_for_task_field,
2746 };
2747 use sqlx::SqliteConnection;
2748
2749 async fn previous(
2750 conn: &mut SqliteConnection,
2751 id: i64,
2752 ) -> anyhow::Result<(String, Option<String>, Option<String>)> {
2753 let row = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
2754 .bind(id)
2755 .fetch_one(&mut *conn)
2756 .await?;
2757 let status: String = row
2758 .try_get("status")
2759 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
2760 let agent: Option<String> = row
2761 .try_get("agent")
2762 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
2763 let model: Option<String> = row
2764 .try_get("model")
2765 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
2766 Ok((status, agent, model))
2767 }
2768
2769 #[expect(
2770 clippy::too_many_arguments,
2771 reason = "one argument per column of the activity row"
2772 )]
2773 pub(crate) async fn activity(
2774 conn: &mut SqliteConnection,
2775 task_id: i64,
2776 event_type: &str,
2777 from_status: Option<&str>,
2778 to_status: Option<&str>,
2779 agent: Option<&str>,
2780 model: Option<&str>,
2781 details: Option<&serde_json::Value>,
2782 ) -> anyhow::Result<()> {
2783 let details_json = match details {
2784 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
2785 None => "{}".to_string(),
2786 };
2787 sqlx::query(
2788 "INSERT INTO task_activity
2789 (task_id, event_type, from_status, to_status, agent, model, details)
2790 VALUES (?, ?, ?, ?, ?, ?, ?)",
2791 )
2792 .bind(task_id)
2793 .bind(event_type)
2794 .bind(from_status)
2795 .bind(to_status)
2796 .bind(agent)
2797 .bind(model)
2798 .bind(details_json)
2799 .execute(conn)
2800 .await?;
2801 Ok(())
2802 }
2803
2804 async fn status_change(
2805 conn: &mut SqliteConnection,
2806 id: i64,
2807 previous: &(String, Option<String>, Option<String>),
2808 to: TaskStatus,
2809 details: Option<&serde_json::Value>,
2810 ) -> anyhow::Result<()> {
2811 activity(
2812 conn,
2813 id,
2814 "status_change",
2815 Some(previous.0.as_str()),
2816 Some(to.as_str()),
2817 previous.1.as_deref(),
2818 previous.2.as_deref(),
2819 details,
2820 )
2821 .await
2822 }
2823
2824 pub(crate) async fn create(
2827 conn: &mut SqliteConnection,
2828 now: &str,
2829 new: &NewTask,
2830 internal: bool,
2831 replicated: bool,
2832 ) -> anyhow::Result<i64> {
2833 let labels_json = serde_json::to_string(&new.labels)?;
2834 let row = sqlx::query(
2835 "INSERT INTO tasks (id, external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id, created_at, updated_at)
2836 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2837 RETURNING id",
2838 )
2839 .bind(replicated)
2840 .bind(&new.external_id)
2841 .bind(&new.repo)
2842 .bind(&new.origin)
2843 .bind(&new.title)
2844 .bind(&new.body)
2845 .bind(&new.source)
2846 .bind(&new.source_id)
2847 .bind(&new.author)
2848 .bind(&new.url)
2849 .bind(&labels_json)
2850 .bind(new.parent_id)
2851 .bind(now)
2852 .bind(now)
2853 .fetch_one(&mut *conn)
2854 .await?;
2855 let id: i64 = row
2856 .try_get("id")
2857 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
2858 if internal {
2859 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
2861 .bind(format!("internal:{id}"))
2862 .bind(id)
2863 .execute(conn)
2864 .await?;
2865 }
2866
2867 Ok(id)
2868 }
2869
2870 pub(crate) async fn upsert_external(
2871 conn: &mut SqliteConnection,
2872 now: &str,
2873 ext: &UpsertExternalCmd,
2874 replicated: bool,
2875 ) -> anyhow::Result<i64> {
2876 let labels_json = serde_json::to_string(&ext.labels)?;
2877 let row = sqlx::query(
2878 "INSERT INTO tasks (id, external_id, repo, origin, title, body, author, url, labels, parent_id, created_at, updated_at)
2879 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2880 ON CONFLICT(repo, external_id) DO UPDATE SET
2881 title = excluded.title,
2882 body = excluded.body,
2883 labels = excluded.labels,
2884 url = excluded.url,
2885 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
2886 RETURNING id",
2887 )
2888 .bind(replicated)
2889 .bind(&ext.ext_id)
2890 .bind(&ext.repo)
2891 .bind(&ext.origin)
2892 .bind(&ext.title)
2893 .bind(&ext.body)
2894 .bind(&ext.author)
2895 .bind(&ext.url)
2896 .bind(&labels_json)
2897 .bind(ext.parent_id)
2898 .bind(now)
2899 .bind(now)
2900 .fetch_one(conn)
2901 .await?;
2902 row.try_get("id")
2903 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
2904 }
2905
2906 fn ensure_allowed(updates: &[(String, serde_json::Value)]) -> anyhow::Result<()> {
2907 for (col, _) in updates {
2908 anyhow::ensure!(
2909 ALLOWED_FIELDS.contains(&col.as_str()),
2910 "column {col} is not in the update allowlist"
2911 );
2912 }
2913
2914 Ok(())
2915 }
2916
2917 pub(crate) async fn update_status_and_fields(
2918 conn: &mut SqliteConnection,
2919 now: &str,
2920 id: i64,
2921 status: TaskStatus,
2922 updates: &[(String, serde_json::Value)],
2923 ) -> anyhow::Result<()> {
2924 ensure_allowed(updates)?;
2925 let prev = previous(conn, id).await?;
2926
2927 let mut set_parts = vec!["status = ?".to_string()];
2928 let mut values: Vec<Option<String>> = vec![Some(status.as_str().to_string())];
2929 let mut block_reason_in_updates = false;
2930 for (col, val) in updates {
2931 if col == "block_reason" {
2932 block_reason_in_updates = true;
2933 }
2934
2935 set_parts.push(format!("{col} = ?"));
2936 match val {
2937 serde_json::Value::String(s) => values.push(Some(s.clone())),
2938 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
2939 serde_json::Value::Bool(b) => {
2940 values.push(Some(if *b { "1" } else { "0" }.to_string()));
2941 }
2942 serde_json::Value::Null => values.push(None),
2943 other => values.push(Some(serde_json::to_string(other)?)),
2944 }
2945 }
2946
2947 if !block_reason_in_updates && status != TaskStatus::Blocked {
2948 set_parts.push("block_reason = NULL".to_string());
2949 }
2950
2951 set_parts.push("updated_at = ?".to_string());
2952 values.push(Some(now.to_string()));
2953 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
2954 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2955 for v in &values {
2956 query = query.bind(v.as_deref());
2957 }
2958 query.bind(id).execute(&mut *conn).await?;
2959 status_change(conn, id, &prev, status, None).await
2960 }
2961
2962 pub(crate) async fn update_status(
2964 conn: &mut SqliteConnection,
2965 now: &str,
2966 id: i64,
2967 status: TaskStatus,
2968 expected: Option<TaskStatus>,
2969 ) -> anyhow::Result<bool> {
2970 let prev = previous(conn, id).await?;
2971 let clear_block = if status == TaskStatus::Blocked {
2972 ""
2973 } else {
2974 ", block_reason = NULL"
2975 };
2976 let guard = if expected.is_some() {
2977 " AND status = ?"
2978 } else {
2979 ""
2980 };
2981 let sql =
2982 format!("UPDATE tasks SET status = ?{clear_block}, updated_at = ? WHERE id = ?{guard}");
2983 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2984 .bind(status.as_str())
2985 .bind(now)
2986 .bind(id);
2987 if let Some(expected) = expected {
2988 query = query.bind(expected.as_str());
2989 }
2990
2991 if query.execute(&mut *conn).await?.rows_affected() == 0 {
2992 return Ok(false);
2993 }
2994
2995 status_change(conn, id, &prev, status, None).await?;
2996 Ok(true)
2997 }
2998
2999 pub(crate) async fn update_external_id(
3000 conn: &mut SqliteConnection,
3001 now: &str,
3002 id: i64,
3003 external_id: &str,
3004 ) -> anyhow::Result<()> {
3005 sqlx::query("UPDATE tasks SET external_id = ?, updated_at = ? WHERE id = ?")
3006 .bind(external_id)
3007 .bind(now)
3008 .bind(id)
3009 .execute(conn)
3010 .await?;
3011 Ok(())
3012 }
3013
3014 pub(crate) async fn touch(
3015 conn: &mut SqliteConnection,
3016 now: &str,
3017 id: i64,
3018 ) -> anyhow::Result<()> {
3019 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
3020 .bind(now)
3021 .bind(id)
3022 .execute(conn)
3023 .await?;
3024 Ok(())
3025 }
3026
3027 pub(crate) async fn reset_to_new(
3028 conn: &mut SqliteConnection,
3029 now: &str,
3030 id: i64,
3031 ) -> anyhow::Result<()> {
3032 let prev = previous(conn, id).await?;
3033 sqlx::query(
3034 "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = ? WHERE id = ?",
3035 )
3036 .bind(now)
3037 .bind(id)
3038 .execute(&mut *conn)
3039 .await?;
3040 let details = serde_json::json!({ "op": "reset_to_new" });
3041 status_change(conn, id, &prev, TaskStatus::New, Some(&details)).await
3042 }
3043
3044 pub(crate) async fn set_fields(
3046 conn: &mut SqliteConnection,
3047 now: &str,
3048 id: i64,
3049 updates: &[(String, serde_json::Value)],
3050 silent: bool,
3051 ) -> anyhow::Result<()> {
3052 if updates.is_empty() {
3053 return Ok(());
3054 }
3055
3056 ensure_allowed(updates)?;
3057 let mut set_parts = Vec::new();
3058 let mut values: Vec<Option<String>> = Vec::new();
3059 for (col, val) in updates {
3060 set_parts.push(format!("{col} = ?"));
3061 values.push(sql_value_for_task_field(col, val)?);
3062 }
3063
3064 if !silent {
3065 set_parts.push("updated_at = ?".to_string());
3066 values.push(Some(now.to_string()));
3067 }
3068
3069 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
3070 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
3071 for v in &values {
3072 query = query.bind(v.as_deref());
3073 }
3074 query.bind(id).execute(conn).await?;
3075 Ok(())
3076 }
3077
3078 pub(crate) async fn increment(
3079 conn: &mut SqliteConnection,
3080 now: &str,
3081 id: i64,
3082 field: &str,
3083 ) -> anyhow::Result<i64> {
3084 anyhow::ensure!(
3085 INCREMENTABLE_FIELDS.contains(&field),
3086 "field {field} is not incrementable"
3087 );
3088 let sql = format!(
3089 "UPDATE tasks SET {field} = {field} + 1, updated_at = ? WHERE id = ? RETURNING {field} AS val"
3090 );
3091 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3092 .bind(now)
3093 .bind(id)
3094 .fetch_one(conn)
3095 .await?;
3096 let val: i32 = row.try_get("val")?;
3097 Ok(val.into())
3098 }
3099
3100 pub(crate) async fn reset_counters(
3103 conn: &mut SqliteConnection,
3104 now: &str,
3105 id: i64,
3106 failure_only: bool,
3107 ) -> anyhow::Result<()> {
3108 let all = if failure_only {
3109 ""
3110 } else {
3111 "attempts = 0, merge_conflict_retries = 0, ci_merge_failures = 0, review_cycles = 0, review_invocations = 0,"
3112 };
3113 let sql = format!(
3114 "UPDATE tasks SET {all}
3115 route_attempts = 0,
3116 review_agent_failures = 0,
3117 review_resets = 0,
3118 pr_create_failures = 0,
3119 push_failures = 0,
3120 network_retries = 0,
3121 auto_unblock_count = 0,
3122 auto_unblock_last_at = '',
3123 auto_unblock_last_reason = '',
3124 ci_recovery_count = 0,
3125 no_code_reroutes = 0,
3126 no_code_last_agent = '',
3127 needs_review_refires = 0,
3128 updated_at = ?
3129 WHERE id = ?"
3130 );
3131 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3132 .bind(now)
3133 .bind(id)
3134 .execute(conn)
3135 .await?;
3136 Ok(())
3137 }
3138
3139 pub(crate) async fn store_route(
3140 conn: &mut SqliteConnection,
3141 now: &str,
3142 route: &StoreRouteCmd,
3143 ) -> anyhow::Result<()> {
3144 sqlx::query(
3145 "UPDATE tasks SET
3146 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
3147 agent_profile = ?, selected_skills = ?,
3148 updated_at = ?
3149 WHERE id = ?",
3150 )
3151 .bind(&route.agent)
3152 .bind(&route.model)
3153 .bind(&route.complexity)
3154 .bind(i64::from(route.estimate))
3155 .bind(&route.reason)
3156 .bind(&route.profile)
3157 .bind(&route.skills)
3158 .bind(now)
3159 .bind(route.id)
3160 .execute(&mut *conn)
3161 .await?;
3162 let details = serde_json::json!({
3163 "complexity": route.complexity,
3164 "estimate": route.estimate,
3165 "reason": route.reason,
3166 "skills": route.skills,
3167 });
3168 activity(
3169 conn,
3170 route.id,
3171 "dispatch",
3172 None,
3173 None,
3174 Some(&route.agent),
3175 route.model.as_deref(),
3176 Some(&details),
3177 )
3178 .await
3179 }
3180
3181 pub(crate) async fn set_estimate_if_zero(
3182 conn: &mut SqliteConnection,
3183 now: &str,
3184 id: i64,
3185 estimate: u8,
3186 ) -> anyhow::Result<bool> {
3187 let result = sqlx::query(
3188 "UPDATE tasks SET estimate = ?, updated_at = ? WHERE id = ? AND estimate = 0",
3189 )
3190 .bind(i64::from(estimate))
3191 .bind(now)
3192 .bind(id)
3193 .execute(conn)
3194 .await?;
3195 Ok(result.rows_affected() > 0)
3196 }
3197
3198 pub(crate) async fn append_memory(
3199 conn: &mut SqliteConnection,
3200 now: &str,
3201 id: i64,
3202 entry: &MemoryEntry,
3203 ) -> anyhow::Result<()> {
3204 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
3205 .bind(id)
3206 .fetch_one(&mut *conn)
3207 .await?;
3208 let memory_opt: Option<String> = row
3210 .try_get::<Option<String>, _>("memory")
3211 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
3212 let memory_str = memory_opt.unwrap_or_default();
3213 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
3214 Vec::new()
3215 } else {
3216 serde_json::from_str(&memory_str)
3217 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
3218 };
3219 memory.push(entry.clone());
3220 sqlx::query("UPDATE tasks SET memory = ?, updated_at = ? WHERE id = ?")
3221 .bind(serde_json::to_string(&memory)?)
3222 .bind(now)
3223 .bind(id)
3224 .execute(conn)
3225 .await?;
3226 Ok(())
3227 }
3228
3229 pub(crate) async fn mark_cleaned(
3230 conn: &mut SqliteConnection,
3231 now: &str,
3232 id: i64,
3233 ) -> anyhow::Result<()> {
3234 sqlx::query("UPDATE tasks SET worktree_cleaned = 1, updated_at = ? WHERE id = ?")
3235 .bind(now)
3236 .bind(id)
3237 .execute(&mut *conn)
3238 .await?;
3239 activity(
3240 conn,
3241 id,
3242 "branch_delete",
3243 None,
3244 None,
3245 None,
3246 None,
3247 Some(&serde_json::json!({ "worktree_cleaned": true })),
3248 )
3249 .await
3250 }
3251}
3252
3253#[cfg(test)]
3254mod row_to_task_tests {
3255 use super::*;
3256
3257 async fn store_with_task() -> (crate::store::TaskStore, i64) {
3258 let store = crate::store::TaskStore::open_memory().await.unwrap();
3259 let id = store
3260 .create(&NewTask {
3261 external_id: None,
3262 repo: "owner/repo".to_string(),
3263 origin: "internal".to_string(),
3264 title: "Test task".to_string(),
3265 body: "Test body".to_string(),
3266 source: "cron".to_string(),
3267 source_id: "test-1".to_string(),
3268 author: "tester".to_string(),
3269 url: "https://example.com/1".to_string(),
3270 labels: vec![],
3271 parent_id: None,
3272 })
3273 .await
3274 .unwrap();
3275 (store, id)
3276 }
3277
3278 macro_rules! missing_column_test {
3281 ($name:ident, $col:literal, $select:literal) => {
3282 #[tokio::test]
3283 async fn $name() {
3284 let (store, id) = store_with_task().await;
3285 let sql = format!("{} WHERE id = {id}", $select);
3286 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3287 .fetch_one(store.pool())
3288 .await
3289 .unwrap();
3290 let err = TaskStore::row_to_task(&row).unwrap_err();
3291 assert!(
3292 err.to_string().contains($col),
3293 "expected '{}' in error message, got: {err}",
3294 $col
3295 );
3296 }
3297 };
3298 }
3299
3300 missing_column_test!(
3301 row_to_task_fails_on_missing_repo,
3302 "repo",
3303 "SELECT id, status FROM tasks"
3304 );
3305 missing_column_test!(
3306 row_to_task_fails_on_missing_origin,
3307 "origin",
3308 "SELECT id, repo, status FROM tasks"
3309 );
3310 missing_column_test!(
3311 row_to_task_fails_on_missing_title,
3312 "title",
3313 "SELECT id, repo, origin, status FROM tasks"
3314 );
3315 missing_column_test!(
3316 row_to_task_fails_on_missing_body,
3317 "body",
3318 "SELECT id, repo, origin, title, status FROM tasks"
3319 );
3320 missing_column_test!(
3321 row_to_task_fails_on_missing_source,
3322 "source",
3323 "SELECT id, repo, origin, title, body, status FROM tasks"
3324 );
3325 missing_column_test!(
3326 row_to_task_fails_on_missing_source_id,
3327 "source_id",
3328 "SELECT id, repo, origin, title, body, source, status FROM tasks"
3329 );
3330 missing_column_test!(
3331 row_to_task_fails_on_missing_author,
3332 "author",
3333 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
3334 );
3335 missing_column_test!(
3336 row_to_task_fails_on_missing_url,
3337 "url",
3338 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
3339 );
3340
3341 #[tokio::test]
3348 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
3349 let (store, id) = store_with_task().await;
3350 let sql = format!(
3353 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
3354 source, source_id, author, url FROM tasks WHERE id = {id}"
3355 );
3356 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3357 .fetch_one(store.pool())
3358 .await
3359 .unwrap();
3360 let err = TaskStore::row_to_task(&row).unwrap_err();
3361 assert!(
3362 err.to_string().contains("repo"),
3363 "expected 'repo' in error message, got: {err}"
3364 );
3365 }
3366
3367 #[tokio::test]
3370 async fn distinct_repos_fails_on_decode_error() {
3371 let store = crate::store::TaskStore::open_memory().await.unwrap();
3372 let id = store
3373 .create(&NewTask {
3374 external_id: None,
3375 repo: "owner/repo".to_string(),
3376 origin: "internal".to_string(),
3377 title: "Test task".to_string(),
3378 body: "Test body".to_string(),
3379 source: "cron".to_string(),
3380 source_id: "test-1".to_string(),
3381 author: "tester".to_string(),
3382 url: "https://example.com/1".to_string(),
3383 labels: vec![],
3384 parent_id: None,
3385 })
3386 .await
3387 .unwrap();
3388
3389 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
3391 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3392 .execute(store.pool())
3393 .await
3394 .unwrap();
3395
3396 let err = store.distinct_repos().await.unwrap_err();
3397 assert!(
3398 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
3399 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
3400 );
3401 }
3402
3403 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
3406 let store = crate::store::TaskStore::open_memory().await.unwrap();
3407 let task_id = store
3408 .create(&NewTask {
3409 external_id: None,
3410 repo: "owner/repo".to_string(),
3411 origin: "internal".to_string(),
3412 title: "Test task".to_string(),
3413 body: "Test body".to_string(),
3414 source: "cron".to_string(),
3415 source_id: "test-1".to_string(),
3416 author: "tester".to_string(),
3417 url: "https://example.com/1".to_string(),
3418 labels: vec![],
3419 parent_id: None,
3420 })
3421 .await
3422 .unwrap();
3423 let run_id = store
3424 .start_run(&crate::store::StartRun {
3425 task_id,
3426 attempt: 1,
3427 run_type: "agent",
3428 agent: "claude",
3429 model: "sonnet",
3430 command: "claude -p ...",
3431 prompt: "system prompt",
3432 })
3433 .await
3434 .unwrap();
3435 (store, task_id, run_id)
3436 }
3437
3438 #[tokio::test]
3439 async fn start_run_upsert_clears_stale_completion_fields() {
3440 let (store, task_id, run_id) = store_with_run().await;
3441 store
3442 .complete_run(&CompleteRun {
3443 run_id,
3444 exit_code: Some(0),
3445 stdout: "",
3446 stderr: "",
3447 parsed: "",
3448 outcome: "success",
3449 error: "boom",
3450 tokens: RunTokenUsage::default(),
3451 })
3452 .await
3453 .unwrap();
3454
3455 let same_run_id = store
3457 .start_run(&crate::store::StartRun {
3458 task_id,
3459 attempt: 1,
3460 run_type: "agent",
3461 agent: "claude",
3462 model: "sonnet",
3463 command: "claude -p ...",
3464 prompt: "system prompt",
3465 })
3466 .await
3467 .unwrap();
3468 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
3469
3470 let row = sqlx::query(
3471 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
3472 )
3473 .bind(run_id)
3474 .fetch_one(store.pool())
3475 .await
3476 .unwrap();
3477 let outcome: Option<String> = row.try_get("outcome").unwrap();
3478 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
3479 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
3480 let error: Option<String> = row.try_get("error").unwrap();
3481 assert_eq!(outcome, None);
3482 assert_eq!(
3483 completed_at, None,
3484 "stale completion timestamp must be cleared"
3485 );
3486 assert_eq!(exit_code, None);
3487 assert_eq!(error, None);
3488 }
3489
3490 macro_rules! run_blob_decode_test {
3497 ($name:ident, $col:literal, $select:literal) => {
3498 #[tokio::test]
3499 async fn $name() {
3500 let (store, _task_id, run_id) = store_with_run().await;
3501 let sql = format!($select, run_id = run_id);
3502 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3503 .fetch_one(store.pool())
3504 .await
3505 .unwrap();
3506 let err = TaskStore::row_to_run(&row).unwrap_err();
3507 assert!(
3508 err.to_string().contains($col),
3509 "expected '{0}' in error message, got: {err}",
3510 $col
3511 );
3512 }
3513 };
3514 }
3515
3516 run_blob_decode_test!(
3517 row_to_run_fails_on_decode_error_invalid_utf8_id,
3518 "id",
3519 "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}"
3520 );
3521 run_blob_decode_test!(
3522 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3523 "task_id",
3524 "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}"
3525 );
3526 run_blob_decode_test!(
3527 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3528 "started_at",
3529 "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}"
3530 );
3531
3532 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
3535 let store = crate::store::TaskStore::open_memory().await.unwrap();
3536 let task_id = store
3537 .create(&NewTask {
3538 external_id: None,
3539 repo: "owner/repo".to_string(),
3540 origin: "internal".to_string(),
3541 title: "Test task".to_string(),
3542 body: "Test body".to_string(),
3543 source: "cron".to_string(),
3544 source_id: "test-1".to_string(),
3545 author: "tester".to_string(),
3546 url: "https://example.com/1".to_string(),
3547 labels: vec![],
3548 parent_id: None,
3549 })
3550 .await
3551 .unwrap();
3552 store
3553 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3554 .await
3555 .unwrap();
3556 (store, task_id, 1)
3557 }
3558
3559 #[tokio::test]
3562 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3563 let (store, _task_id, activity_id) = store_with_activity().await;
3564 let sql = format!(
3565 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3566 );
3567 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3568 .fetch_one(store.pool())
3569 .await
3570 .unwrap();
3571 let err = TaskStore::row_to_activity(&row).unwrap_err();
3572 assert!(
3573 err.to_string().contains("id"),
3574 "expected 'id' in error message, got: {err}"
3575 );
3576 }
3577
3578 #[tokio::test]
3579 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3580 let (store, _task_id, activity_id) = store_with_activity().await;
3581 let sql = format!(
3582 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3583 );
3584 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3585 .fetch_one(store.pool())
3586 .await
3587 .unwrap();
3588 let err = TaskStore::row_to_activity(&row).unwrap_err();
3589 assert!(
3590 err.to_string().contains("task_id"),
3591 "expected 'task_id' in error message, got: {err}"
3592 );
3593 }
3594
3595 #[tokio::test]
3596 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3597 let (store, _task_id, activity_id) = store_with_activity().await;
3598 let sql = format!(
3599 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3600 );
3601 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3602 .fetch_one(store.pool())
3603 .await
3604 .unwrap();
3605 let err = TaskStore::row_to_activity(&row).unwrap_err();
3606 assert!(
3607 err.to_string().contains("timestamp"),
3608 "expected 'timestamp' in error message, got: {err}"
3609 );
3610 }
3611
3612 #[tokio::test]
3613 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3614 let (store, _task_id, activity_id) = store_with_activity().await;
3615 let sql = format!(
3616 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3617 );
3618 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3619 .fetch_one(store.pool())
3620 .await
3621 .unwrap();
3622 let err = TaskStore::row_to_activity(&row).unwrap_err();
3623 assert!(
3624 err.to_string().contains("event_type"),
3625 "expected 'event_type' in error message, got: {err}"
3626 );
3627 }
3628
3629 #[tokio::test]
3630 async fn row_to_activity_fails_on_corrupt_details_json() {
3631 let (store, task_id, _activity_id) = store_with_activity().await;
3632 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3634 .bind(task_id)
3635 .bind("test")
3636 .bind("not valid json {{{")
3637 .execute(store.pool())
3638 .await
3639 .unwrap();
3640 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
3641 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3642 )))
3643 .fetch_one(store.pool())
3644 .await
3645 .unwrap();
3646 let err = TaskStore::row_to_activity(&row).unwrap_err();
3647 assert!(
3648 err.to_string().contains("details")
3649 || err.to_string().contains("json")
3650 || err.to_string().contains("expected"),
3651 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3652 );
3653 }
3654
3655 run_blob_decode_test!(
3658 row_to_run_fails_on_decode_error_invalid_utf8_model,
3659 "model",
3660 "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}"
3661 );
3662 run_blob_decode_test!(
3663 row_to_run_fails_on_decode_error_invalid_utf8_command,
3664 "command",
3665 "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}"
3666 );
3667 run_blob_decode_test!(
3668 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3669 "prompt",
3670 "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}"
3671 );
3672 run_blob_decode_test!(
3673 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3674 "env_vars",
3675 "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}"
3676 );
3677 run_blob_decode_test!(
3678 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3679 "stdout",
3680 "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}"
3681 );
3682 run_blob_decode_test!(
3683 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3684 "stderr",
3685 "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}"
3686 );
3687 run_blob_decode_test!(
3688 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3689 "parsed_response",
3690 "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}"
3691 );
3692 run_blob_decode_test!(
3693 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3694 "outcome",
3695 "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}"
3696 );
3697 run_blob_decode_test!(
3698 row_to_run_fails_on_decode_error_invalid_utf8_error,
3699 "error",
3700 "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}"
3701 );
3702 run_blob_decode_test!(
3703 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3704 "input_tokens",
3705 "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}"
3706 );
3707 run_blob_decode_test!(
3708 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3709 "output_tokens",
3710 "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}"
3711 );
3712 run_blob_decode_test!(
3713 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3714 "total_cost_usd",
3715 "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}"
3716 );
3717 run_blob_decode_test!(
3718 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3719 "duration_secs",
3720 "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}"
3721 );
3722 run_blob_decode_test!(
3723 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3724 "exit_code",
3725 "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}"
3726 );
3727
3728 #[tokio::test]
3735 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3736 let (store, _task_id, activity_id) = store_with_activity().await;
3737 let sql = format!(
3738 "SELECT id, task_id, timestamp, event_type, \
3739 X'DeadbEEF' as from_status, NULL as to_status, \
3740 NULL as agent, NULL as model, details \
3741 FROM task_activity WHERE id = {activity_id}"
3742 );
3743 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3744 .fetch_one(store.pool())
3745 .await
3746 .unwrap();
3747 let err = TaskStore::row_to_activity(&row).unwrap_err();
3748 assert!(
3749 err.to_string().contains("from_status"),
3750 "expected 'from_status' in error message, got: {err}"
3751 );
3752 }
3753
3754 #[tokio::test]
3755 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3756 let (store, _task_id, activity_id) = store_with_activity().await;
3757 let sql = format!(
3758 "SELECT id, task_id, timestamp, event_type, \
3759 NULL as from_status, X'DeadbEEF' as to_status, \
3760 NULL as agent, NULL as model, details \
3761 FROM task_activity WHERE id = {activity_id}"
3762 );
3763 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3764 .fetch_one(store.pool())
3765 .await
3766 .unwrap();
3767 let err = TaskStore::row_to_activity(&row).unwrap_err();
3768 assert!(
3769 err.to_string().contains("to_status"),
3770 "expected 'to_status' in error message, got: {err}"
3771 );
3772 }
3773
3774 #[tokio::test]
3775 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3776 let (store, _task_id, activity_id) = store_with_activity().await;
3777 let sql = format!(
3778 "SELECT id, task_id, timestamp, event_type, \
3779 NULL as from_status, NULL as to_status, \
3780 X'DeadbEEF' as agent, NULL as model, details \
3781 FROM task_activity WHERE id = {activity_id}"
3782 );
3783 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3784 .fetch_one(store.pool())
3785 .await
3786 .unwrap();
3787 let err = TaskStore::row_to_activity(&row).unwrap_err();
3788 assert!(
3789 err.to_string().contains("agent"),
3790 "expected 'agent' in error message, got: {err}"
3791 );
3792 }
3793
3794 #[tokio::test]
3795 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3796 let (store, _task_id, activity_id) = store_with_activity().await;
3797 let sql = format!(
3798 "SELECT id, task_id, timestamp, event_type, \
3799 NULL as from_status, NULL as to_status, \
3800 NULL as agent, X'DeadbEEF' as model, details \
3801 FROM task_activity WHERE id = {activity_id}"
3802 );
3803 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3804 .fetch_one(store.pool())
3805 .await
3806 .unwrap();
3807 let err = TaskStore::row_to_activity(&row).unwrap_err();
3808 assert!(
3809 err.to_string().contains("model"),
3810 "expected 'model' in error message, got: {err}"
3811 );
3812 }
3813
3814 #[tokio::test]
3817 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3818 let store = crate::store::TaskStore::open_memory().await.unwrap();
3823 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3824 .execute(store.pool())
3825 .await
3826 .unwrap();
3827 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3829 .execute(store.pool())
3830 .await
3831 .unwrap();
3832
3833 let result = store
3834 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3835 .await
3836 .unwrap();
3837 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3838 assert_eq!(
3839 result.get("valid-id"),
3840 Some(&crate::store::TaskStatus::Done)
3841 );
3842 }
3843}