1use super::*;
2use crate::security;
3use serde::{Deserialize, Serialize};
4use sqlx::Row;
5
6pub(crate) const TASK_COLS: &str = "id, external_id, repo, origin, title, body, status, \
14 source, source_id, author, url, labels, agent, model, complexity, estimate, \
15 route_reason, agent_profile, selected_skills, route_attempts, attempts, \
16 branch, worktree, worktree_cleaned, summary, last_error, parent_id, \
17 block_reason, pr_number, pr_review_context, last_review_ts, review_ts_map, \
18 last_comment_review_ts, merge_conflict_retries, ci_merge_failures, \
19 pr_create_failures, review_agent_failures, review_cycles, input_tokens, \
20 output_tokens, input_cost_usd, output_cost_usd, total_cost_usd, \
21 model_reroute_chain, limit_reroute_chain, \
22 memory, delegations, created_at, updated_at, review_session_expected, \
23 review_invocations, needs_review_refires, push_failures, auto_unblock_count, auto_unblock_last_at, \
24 ci_recovery_count, auto_unblock_last_reason, no_code_reroutes, network_retries, no_code_last_agent";
25
26pub(crate) const TASK_COLS_COUNT: usize = 60;
28
29const TASK_RUN_COLS: &str =
31 "id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, \
32 exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, \
33 total_cost_usd, duration_secs, started_at, completed_at";
34
35const TASK_ACTIVITY_COLS: &str =
37 "id, task_id, timestamp, event_type, from_status, to_status, agent, model, details";
38
39const ALLOWED_FIELDS: &[&str] = &[
41 "agent",
42 "model",
43 "complexity",
44 "route_reason",
45 "agent_profile",
46 "selected_skills",
47 "route_attempts",
48 "attempts",
49 "branch",
50 "worktree",
51 "worktree_cleaned",
52 "summary",
53 "last_error",
54 "parent_id",
55 "block_reason",
56 "pr_number",
57 "pr_review_context",
58 "last_review_ts",
59 "review_ts_map",
60 "last_comment_review_ts",
61 "merge_conflict_retries",
62 "ci_merge_failures",
63 "pr_create_failures",
64 "push_failures",
65 "review_agent_failures",
66 "review_cycles",
67 "review_invocations",
68 "needs_review_refires",
69 "review_session_expected",
70 "input_tokens",
71 "output_tokens",
72 "input_cost_usd",
73 "output_cost_usd",
74 "total_cost_usd",
75 "model_reroute_chain",
76 "limit_reroute_chain",
77 "memory",
78 "delegations",
79 "source",
80 "source_id",
81 "labels",
82 "auto_unblock_count",
83 "auto_unblock_last_at",
84 "auto_unblock_last_reason",
85 "ci_recovery_count",
86 "no_code_reroutes",
87 "network_retries",
88 "no_code_last_agent",
89];
90
91const INCREMENTABLE_FIELDS: &[&str] = &[
93 "attempts",
94 "route_attempts",
95 "merge_conflict_retries",
96 "ci_merge_failures",
97 "pr_create_failures",
98 "push_failures",
99 "review_agent_failures",
100 "review_cycles",
101 "review_invocations",
102 "needs_review_refires",
103 "auto_unblock_count",
104 "ci_recovery_count",
105 "no_code_reroutes",
106 "network_retries",
107];
108
109fn sanitize_activity_details(value: &serde_json::Value) -> serde_json::Value {
110 match value {
111 serde_json::Value::String(text) => serde_json::Value::String(security::redact(text)),
112 serde_json::Value::Array(items) => serde_json::Value::Array(
113 items
114 .iter()
115 .map(sanitize_activity_details)
116 .collect::<Vec<_>>(),
117 ),
118 serde_json::Value::Object(map) => serde_json::Value::Object(
119 map.iter()
120 .map(|(key, value)| (key.clone(), sanitize_activity_details(value)))
121 .collect(),
122 ),
123 other => other.clone(),
124 }
125}
126
127fn sanitize_task_field_value(col: &str, value: &serde_json::Value) -> serde_json::Value {
128 match (col, value) {
129 ("last_error" | "block_reason", serde_json::Value::String(text)) => {
130 serde_json::Value::String(security::redact(text))
131 }
132 _ => value.clone(),
133 }
134}
135
136fn sql_value_for_task_field(
137 col: &str,
138 value: &serde_json::Value,
139) -> anyhow::Result<Option<String>> {
140 let sanitized = sanitize_task_field_value(col, value);
141 match sanitized {
142 serde_json::Value::String(text) => Ok(Some(text)),
143 serde_json::Value::Number(number) => Ok(Some(number.to_string())),
144 serde_json::Value::Bool(boolean) => Ok(Some(if boolean { "1" } else { "0" }.to_string())),
145 serde_json::Value::Null => Ok(None),
146 other => Ok(Some(serde_json::to_string(&other)?)),
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TaskStatus {
153 New,
154 Routed,
155 InProgress,
156 Done,
157 Blocked,
158 InReview,
159 NeedsReview,
160}
161
162impl TaskStatus {
163 pub fn as_str(&self) -> &'static str {
164 match self {
165 Self::New => "new",
166 Self::Routed => "routed",
167 Self::InProgress => "in_progress",
168 Self::Done => "done",
169 Self::Blocked => "blocked",
170 Self::InReview => "in_review",
171 Self::NeedsReview => "needs_review",
172 }
173 }
174
175 pub fn from_str(s: &str) -> Option<Self> {
176 match s {
177 "new" => Some(Self::New),
178 "routed" => Some(Self::Routed),
179 "in_progress" => Some(Self::InProgress),
180 "done" => Some(Self::Done),
181 "blocked" => Some(Self::Blocked),
182 "in_review" => Some(Self::InReview),
183 "needs_review" => Some(Self::NeedsReview),
184 _ => None,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct Task {
192 pub id: i64,
193 pub external_id: Option<String>,
194 pub repo: String,
195 pub origin: String,
196
197 pub title: String,
198 pub body: String,
199 pub status: TaskStatus,
200 pub source: String,
201 pub source_id: String,
202 pub author: String,
203 pub url: String,
204 pub labels: Vec<String>,
205
206 pub agent: Option<String>,
208 pub model: Option<String>,
209 pub complexity: String,
210 pub estimate: u8,
212 pub route_reason: String,
213 pub agent_profile: String,
214 pub selected_skills: String,
215 pub route_attempts: i32,
216
217 pub attempts: i32,
219 pub branch: String,
220 pub worktree: String,
221 pub worktree_cleaned: bool,
222 pub summary: String,
223 pub last_error: String,
224 pub parent_id: Option<i64>,
225 pub block_reason: Option<String>,
226
227 pub pr_number: Option<i32>,
229 pub pr_review_context: String,
230 pub last_review_ts: String,
231 pub review_ts_map: String,
232 pub last_comment_review_ts: String,
233 pub merge_conflict_retries: i32,
234 pub ci_merge_failures: i32,
235 pub pr_create_failures: i32,
236 pub push_failures: i32,
237 pub network_retries: i32,
238 pub review_agent_failures: i32,
239 pub review_cycles: i32,
240 pub review_invocations: i32,
241 pub review_session_expected: bool,
242 pub needs_review_refires: i32,
244
245 pub input_tokens: i64,
247 pub output_tokens: i64,
248 pub input_cost_usd: f64,
249 pub output_cost_usd: f64,
250 pub total_cost_usd: f64,
251
252 pub model_reroute_chain: String,
254 pub limit_reroute_chain: String,
255
256 pub memory: Vec<MemoryEntry>,
258 pub delegations: Vec<serde_json::Value>,
259
260 pub auto_unblock_count: i32,
262 pub auto_unblock_last_at: String,
263 pub auto_unblock_last_reason: String,
264
265 pub ci_recovery_count: i32,
267
268 pub no_code_reroutes: i32,
270 pub no_code_last_agent: String,
272
273 pub created_at: String,
275 pub updated_at: String,
276}
277
278#[derive(Debug, Clone, Default)]
280pub struct NewTask {
281 pub external_id: Option<String>,
282 pub repo: String,
283 pub origin: String,
284 pub title: String,
285 pub body: String,
286 pub source: String,
287 pub source_id: String,
288 pub author: String,
289 pub url: String,
290 pub labels: Vec<String>,
291 pub parent_id: Option<i64>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct TaskRun {
298 pub id: i64,
299 pub task_id: i64,
300 pub attempt: i32,
301 pub run_type: String,
302 pub agent: String,
303 pub model: String,
304 pub command: String,
305 pub prompt: String,
306 pub env_vars: String,
307 pub exit_code: Option<i32>,
308 pub stdout: String,
309 pub stderr: String,
310 pub parsed_response: String,
311 pub outcome: String,
312 pub error: String,
313 pub input_tokens: i64,
314 pub output_tokens: i64,
315 pub total_cost_usd: f64,
316 pub duration_secs: f64,
317 pub started_at: String,
318 pub completed_at: Option<String>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct TaskActivity {
324 pub id: i64,
325 pub task_id: i64,
326 pub timestamp: String,
327 pub event_type: String,
328 pub from_status: Option<String>,
329 pub to_status: Option<String>,
330 pub agent: Option<String>,
331 pub model: Option<String>,
332 pub details: serde_json::Value,
333}
334
335#[derive(Debug, Clone, Copy, Default)]
337pub struct RunTokenUsage {
338 pub input_tokens: u64,
339 pub output_tokens: u64,
340 pub total_cost_usd: f64,
341 pub duration_secs: f64,
342}
343
344#[derive(Debug, Clone)]
346pub struct StartRun<'a> {
347 pub task_id: i64,
348 pub attempt: i32,
349 pub run_type: &'a str,
350 pub agent: &'a str,
351 pub model: &'a str,
352 pub command: &'a str,
353 pub prompt: &'a str,
354}
355
356#[derive(Debug, Clone)]
358pub struct CompleteRun<'a> {
359 pub run_id: i64,
360 pub exit_code: Option<i32>,
361 pub stdout: &'a str,
362 pub stderr: &'a str,
363 pub parsed: &'a str,
364 pub outcome: &'a str,
365 pub error: &'a str,
366 pub tokens: RunTokenUsage,
367}
368
369#[derive(Debug, Clone)]
371pub struct StoreRoute<'a> {
372 pub id: i64,
373 pub agent: &'a str,
374 pub model: Option<&'a str>,
375 pub complexity: &'a str,
376 pub estimate: u8,
378 pub reason: &'a str,
379 pub profile: &'a str,
380 pub skills: &'a str,
381}
382
383#[derive(Debug, Clone)]
385pub struct UpsertExternal<'a> {
386 pub repo: &'a str,
387 pub ext_id: &'a str,
388 pub title: &'a str,
389 pub body: &'a str,
390 pub author: &'a str,
391 pub url: &'a str,
392 pub labels: &'a [String],
393 pub origin: &'a str,
394}
395
396fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
397 labels
398 .iter()
399 .find_map(|label| label.strip_prefix("parent:"))
400 .filter(|parent| !parent.is_empty())
401}
402
403impl TaskStore {
404 #[allow(clippy::too_many_arguments)]
406 pub async fn append_activity(
407 &self,
408 task_id: i64,
409 event_type: &str,
410 from_status: Option<&str>,
411 to_status: Option<&str>,
412 agent: Option<&str>,
413 model: Option<&str>,
414 details: Option<&serde_json::Value>,
415 ) -> anyhow::Result<()> {
416 let details_json = match details {
417 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
418 None => "{}".to_string(),
419 };
420 sqlx::query(
421 "INSERT INTO task_activity
422 (task_id, event_type, from_status, to_status, agent, model, details)
423 VALUES (?, ?, ?, ?, ?, ?, ?)",
424 )
425 .bind(task_id)
426 .bind(event_type)
427 .bind(from_status)
428 .bind(to_status)
429 .bind(agent)
430 .bind(model)
431 .bind(details_json)
432 .execute(&self.pool)
433 .await?;
434 Ok(())
435 }
436
437 #[doc(hidden)]
439 #[allow(clippy::too_many_arguments)]
440 pub async fn append_activity_tx(
441 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
442 task_id: i64,
443 event_type: &str,
444 from_status: Option<&str>,
445 to_status: Option<&str>,
446 agent: Option<&str>,
447 model: Option<&str>,
448 details: Option<&serde_json::Value>,
449 ) -> anyhow::Result<()> {
450 let details_json = match details {
451 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
452 None => "{}".to_string(),
453 };
454 sqlx::query(
455 "INSERT INTO task_activity
456 (task_id, event_type, from_status, to_status, agent, model, details)
457 VALUES (?, ?, ?, ?, ?, ?, ?)",
458 )
459 .bind(task_id)
460 .bind(event_type)
461 .bind(from_status)
462 .bind(to_status)
463 .bind(agent)
464 .bind(model)
465 .bind(details_json)
466 .execute(&mut **tx)
467 .await?;
468 Ok(())
469 }
470
471 pub async fn get_activity(
473 &self,
474 task_id: i64,
475 limit: Option<usize>,
476 ) -> anyhow::Result<Vec<TaskActivity>> {
477 let rows = if let Some(limit) = limit {
478 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
479 sqlx::query(&format!(
480 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
481 ))
482 .bind(task_id)
483 .bind(limit_i64)
484 .fetch_all(&self.pool)
485 .await?
486 } else {
487 sqlx::query(&format!(
488 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
489 ))
490 .bind(task_id)
491 .fetch_all(&self.pool)
492 .await?
493 };
494
495 rows.iter().map(Self::row_to_activity).collect()
496 }
497
498 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
499 let labels_json = serde_json::to_string(&new.labels)?;
500 let row = sqlx::query(
501 "INSERT INTO tasks (external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id)
502 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
503 RETURNING id",
504 )
505 .bind(&new.external_id)
506 .bind(&new.repo)
507 .bind(&new.origin)
508 .bind(&new.title)
509 .bind(&new.body)
510 .bind(&new.source)
511 .bind(&new.source_id)
512 .bind(&new.author)
513 .bind(&new.url)
514 .bind(&labels_json)
515 .bind(new.parent_id)
516 .fetch_one(&self.pool)
517 .await?;
518
519 row.try_get("id")
520 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
521 }
522
523 pub async fn create_internal(
528 &self,
529 repo: &str,
530 title: &str,
531 body: &str,
532 source: &str,
533 source_id: &str,
534 parent_id: Option<i64>,
535 ) -> anyhow::Result<i64> {
536 let id = self
537 .create(&NewTask {
538 external_id: None,
539 repo: repo.to_string(),
540 origin: "internal".to_string(),
541 title: title.to_string(),
542 body: body.to_string(),
543 source: source.to_string(),
544 source_id: source_id.to_string(),
545 author: String::new(),
546 url: String::new(),
547 labels: vec![],
548 parent_id,
549 })
550 .await?;
551 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
553 .bind(format!("internal:{id}"))
554 .bind(id)
555 .execute(&self.pool)
556 .await?;
557 Ok(id)
558 }
559
560 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
562 let row = sqlx::query(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?"))
563 .bind(id)
564 .fetch_optional(&self.pool)
565 .await?
566 .with_context(|| format!("task {id} not found"))?;
567
568 Self::row_to_task(&row)
569 }
570
571 pub async fn get_by_external_id(
573 &self,
574 repo: &str,
575 ext_id: &str,
576 ) -> anyhow::Result<Option<Task>> {
577 let row = sqlx::query(&format!(
578 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
579 ))
580 .bind(repo)
581 .bind(ext_id)
582 .fetch_optional(&self.pool)
583 .await?;
584
585 match row {
586 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
587 None => Ok(None),
588 }
589 }
590
591 pub async fn resolve_id_by_external(
597 &self,
598 repo: &str,
599 ext_id: &str,
600 ) -> anyhow::Result<Option<i64>> {
601 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
602 .bind(repo)
603 .bind(ext_id)
604 .fetch_optional(&self.pool)
605 .await?;
606 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
607 Ok(id)
608 }
609
610 pub async fn resolve_id_by_pr_number(
616 &self,
617 repo: &str,
618 pr_number: i32,
619 ) -> anyhow::Result<Option<i64>> {
620 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
621 .bind(repo)
622 .bind(pr_number)
623 .fetch_optional(&self.pool)
624 .await?;
625 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
626 Ok(id)
627 }
628
629 pub async fn get_agent_by_external_id(
632 &self,
633 repo: &str,
634 ext_id: &str,
635 ) -> anyhow::Result<Option<String>> {
636 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
637 .bind(repo)
638 .bind(ext_id)
639 .fetch_optional(&self.pool)
640 .await?;
641 let agent = row
642 .map(|r| r.try_get::<Option<String>, _>("agent"))
643 .transpose()?
644 .flatten();
645 Ok(agent)
646 }
647
648 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
650 let labels_json = serde_json::to_string(ext.labels)?;
651 let parent_id = match parent_external_id_from_labels(ext.labels) {
652 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
653 None => None,
654 };
655 let row = sqlx::query(
656 "INSERT INTO tasks (external_id, repo, origin, title, body, author, url, labels, parent_id)
657 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
658 ON CONFLICT(repo, external_id) DO UPDATE SET
659 title = excluded.title,
660 body = excluded.body,
661 labels = excluded.labels,
662 url = excluded.url,
663 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
664 RETURNING id",
665 )
666 .bind(ext.ext_id)
667 .bind(ext.repo)
668 .bind(ext.origin)
669 .bind(ext.title)
670 .bind(ext.body)
671 .bind(ext.author)
672 .bind(ext.url)
673 .bind(&labels_json)
674 .bind(parent_id)
675 .fetch_one(&self.pool)
676 .await?;
677
678 row.try_get("id")
679 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
680 }
681
682 pub async fn update_status_and_fields(
687 &self,
688 id: i64,
689 status: TaskStatus,
690 updates: &[(&str, serde_json::Value)],
691 ) -> anyhow::Result<()> {
692 for (col, _) in updates {
693 anyhow::ensure!(
694 ALLOWED_FIELDS.contains(col),
695 "column {col} is not in the update allowlist"
696 );
697 }
698
699 let mut tx = self.pool.begin().await?;
700 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
701 .bind(id)
702 .fetch_one(&mut *tx)
703 .await?;
704 let from_status: String = previous
705 .try_get("status")
706 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
707 let agent: Option<String> = previous
708 .try_get("agent")
709 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
710 let model: Option<String> = previous
711 .try_get("model")
712 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
713
714 let mut set_parts = Vec::new();
715 let mut values: Vec<Option<String>> = Vec::new();
716
717 set_parts.push("status = ?".to_string());
718 values.push(Some(status.as_str().to_string()));
719
720 let mut block_reason_in_updates = false;
721 for (col, val) in updates {
722 if *col == "block_reason" {
723 block_reason_in_updates = true;
724 }
725 set_parts.push(format!("{col} = ?"));
726 match val {
727 serde_json::Value::String(s) => values.push(Some(s.clone())),
728 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
729 serde_json::Value::Bool(b) => {
730 values.push(Some(if *b { "1" } else { "0" }.to_string()));
731 }
732 serde_json::Value::Null => values.push(None),
733 other => values.push(Some(serde_json::to_string(other)?)),
734 }
735 }
736
737 if !block_reason_in_updates && status != TaskStatus::Blocked {
738 set_parts.push("block_reason = NULL".to_string());
739 }
740
741 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
742 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
743
744 let mut query = sqlx::query(&sql);
745 for v in &values {
746 query = query.bind(v.as_deref());
747 }
748 query = query.bind(id);
749 query.execute(&mut *tx).await?;
750
751 Self::append_activity_tx(
752 &mut tx,
753 id,
754 "status_change",
755 Some(from_status.as_str()),
756 Some(status.as_str()),
757 agent.as_deref(),
758 model.as_deref(),
759 None,
760 )
761 .await?;
762 tx.commit().await?;
763 Ok(())
764 }
765
766 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
768 let mut tx = self.pool.begin().await?;
769 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
770 .bind(id)
771 .fetch_one(&mut *tx)
772 .await?;
773 let from_status: String = previous
774 .try_get("status")
775 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
776 let agent: Option<String> = previous
777 .try_get("agent")
778 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
779 let model: Option<String> = previous
780 .try_get("model")
781 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
782 let sql = if status == TaskStatus::Blocked {
783 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
784 } else {
785 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
786 };
787 sqlx::query(sql)
788 .bind(status.as_str())
789 .bind(id)
790 .execute(&mut *tx)
791 .await?;
792
793 Self::append_activity_tx(
794 &mut tx,
795 id,
796 "status_change",
797 Some(from_status.as_str()),
798 Some(status.as_str()),
799 agent.as_deref(),
800 model.as_deref(),
801 None,
802 )
803 .await?;
804 tx.commit().await?;
805 Ok(())
806 }
807
808 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
810 sqlx::query(
811 "UPDATE tasks SET external_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
812 )
813 .bind(external_id)
814 .bind(id)
815 .execute(&self.pool)
816 .await?;
817 Ok(())
818 }
819
820 pub async fn update_status_if(
825 &self,
826 id: i64,
827 status: TaskStatus,
828 expected: TaskStatus,
829 ) -> anyhow::Result<bool> {
830 let mut tx = self.pool.begin().await?;
831 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
832 .bind(id)
833 .fetch_one(&mut *tx)
834 .await?;
835 let from_status: String = previous
836 .try_get("status")
837 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
838 let agent: Option<String> = previous
839 .try_get("agent")
840 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
841 let model: Option<String> = previous
842 .try_get("model")
843 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
844 let sql = if status == TaskStatus::Blocked {
845 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
846 } else {
847 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
848 };
849 let result = sqlx::query(sql)
850 .bind(status.as_str())
851 .bind(id)
852 .bind(expected.as_str())
853 .execute(&mut *tx)
854 .await?;
855 if result.rows_affected() == 0 {
856 return Ok(false);
857 }
858 Self::append_activity_tx(
859 &mut tx,
860 id,
861 "status_change",
862 Some(from_status.as_str()),
863 Some(status.as_str()),
864 agent.as_deref(),
865 model.as_deref(),
866 None,
867 )
868 .await?;
869 tx.commit().await?;
870 Ok(true)
871 }
872
873 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
881 sqlx::query(
882 "UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
883 )
884 .bind(id)
885 .execute(&self.pool)
886 .await?;
887 Ok(())
888 }
889
890 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
892 let mut tx = self.pool.begin().await?;
893 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
894 .bind(id)
895 .fetch_one(&mut *tx)
896 .await?;
897 let from_status: String = previous
898 .try_get("status")
899 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
900 let agent: Option<String> = previous
901 .try_get("agent")
902 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
903 let model: Option<String> = previous
904 .try_get("model")
905 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
906 sqlx::query(
907 "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
908 )
909 .bind(id)
910 .execute(&mut *tx)
911 .await?;
912 let details = serde_json::json!({ "op": "reset_to_new" });
913 Self::append_activity_tx(
914 &mut tx,
915 id,
916 "status_change",
917 Some(from_status.as_str()),
918 Some(TaskStatus::New.as_str()),
919 agent.as_deref(),
920 model.as_deref(),
921 Some(&details),
922 )
923 .await?;
924 tx.commit().await?;
925 Ok(())
926 }
927
928 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
930 let value = reason
931 .map(|r| serde_json::Value::String(r.to_string()))
932 .unwrap_or(serde_json::Value::Null);
933 self.set_fields(id, &[("block_reason", value)]).await
934 }
935
936 pub async fn list_by_status(
938 &self,
939 repo: &str,
940 status: TaskStatus,
941 ) -> anyhow::Result<Vec<Task>> {
942 let rows = sqlx::query(&format!(
943 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
944 ))
945 .bind(repo)
946 .bind(status.as_str())
947 .fetch_all(&self.pool)
948 .await?;
949
950 rows.iter().map(Self::row_to_task).collect()
951 }
952
953 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
955 let rows = sqlx::query(&format!(
956 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
957 ))
958 .bind(repo)
959 .bind(parent_id)
960 .fetch_all(&self.pool)
961 .await?;
962
963 rows.iter().map(Self::row_to_task).collect()
964 }
965
966 pub async fn list_external_by_status(
968 &self,
969 repo: &str,
970 status: TaskStatus,
971 ) -> anyhow::Result<Vec<Task>> {
972 let rows = sqlx::query(
973 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC"),
974 )
975 .bind(repo)
976 .bind(status.as_str())
977 .fetch_all(&self.pool)
978 .await?;
979
980 rows.iter().map(Self::row_to_task).collect()
981 }
982
983 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
985 self.list_by_status(repo, TaskStatus::New).await
986 }
987
988 pub async fn list_internal_by_status(
990 &self,
991 repo: &str,
992 status: TaskStatus,
993 ) -> anyhow::Result<Vec<Task>> {
994 let rows = sqlx::query(
995 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC"),
996 )
997 .bind(repo)
998 .bind(status.as_str())
999 .fetch_all(&self.pool)
1000 .await?;
1001
1002 rows.iter().map(Self::row_to_task).collect()
1003 }
1004
1005 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1007 let rows = sqlx::query(
1008 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC"),
1009 )
1010 .bind(repo)
1011 .fetch_all(&self.pool)
1012 .await?;
1013
1014 rows.iter().map(Self::row_to_task).collect()
1015 }
1016
1017 pub async fn list_internal_by_source(
1020 &self,
1021 repo: &str,
1022 source: &str,
1023 ) -> anyhow::Result<Vec<Task>> {
1024 let rows = sqlx::query(
1025 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC"),
1026 )
1027 .bind(repo)
1028 .bind(source)
1029 .fetch_all(&self.pool)
1030 .await?;
1031
1032 rows.iter().map(Self::row_to_task).collect()
1033 }
1034
1035 pub async fn list_source_ids_by_source(
1040 &self,
1041 repo: &str,
1042 source: &str,
1043 since_days: i64,
1044 ) -> anyhow::Result<Vec<String>> {
1045 let cutoff = format!("-{since_days} days");
1046 let rows = sqlx::query(
1047 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1048 )
1049 .bind(repo)
1050 .bind(source)
1051 .bind(&cutoff)
1052 .fetch_all(&self.pool)
1053 .await?;
1054
1055 let mut ids = Vec::with_capacity(rows.len());
1056 for row in &rows {
1057 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1058 anyhow::anyhow!(
1059 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1060 )
1061 })?);
1062 }
1063 Ok(ids)
1064 }
1065
1066 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1068 let rows = sqlx::query(
1069 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC"),
1070 )
1071 .bind(repo)
1072 .fetch_all(&self.pool)
1073 .await?;
1074
1075 rows.iter().map(Self::row_to_task).collect()
1076 }
1077
1078 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1080 let rows = sqlx::query(
1081 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC"),
1082 )
1083 .bind(repo)
1084 .fetch_all(&self.pool)
1085 .await?;
1086
1087 rows.iter().map(Self::row_to_task).collect()
1088 }
1089
1090 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1092 let has_external = sqlx::query_scalar::<_, i32>(
1093 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1094 )
1095 .bind(repo)
1096 .fetch_optional(&self.pool)
1097 .await?
1098 .is_some();
1099 Ok(has_external)
1100 }
1101
1102 pub async fn existing_external_ids(
1106 &self,
1107 repo: &str,
1108 ) -> anyhow::Result<std::collections::HashSet<String>> {
1109 let rows: Vec<(String,)> = sqlx::query_as(
1110 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1111 )
1112 .bind(repo)
1113 .fetch_all(&self.pool)
1114 .await?;
1115
1116 Ok(rows
1117 .into_iter()
1118 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1119 .collect())
1120 }
1121
1122 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1125 let rows = sqlx::query(
1126 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC"),
1127 )
1128 .bind(repo)
1129 .bind(cutoff_str)
1130 .fetch_all(&self.pool)
1131 .await?;
1132 rows.iter().map(Self::row_to_task).collect()
1133 }
1134
1135 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1137 let rows = sqlx::query(&format!(
1138 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1139 ))
1140 .bind(repo)
1141 .fetch_all(&self.pool)
1142 .await?;
1143 rows.iter().map(Self::row_to_task).collect()
1144 }
1145
1146 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1148 let rows =
1149 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1150 .fetch_all(&self.pool)
1151 .await?;
1152 let mut repos = Vec::new();
1153 for row in &rows {
1154 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1155 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1156 })?);
1157 }
1158 Ok(repos)
1159 }
1160
1161 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1166 let rows = sqlx::query(&format!(
1167 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1168 ))
1169 .fetch_all(&self.pool)
1170 .await?;
1171 rows.iter().map(Self::row_to_task).collect()
1172 }
1173
1174 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1178 let rows = sqlx::query(&format!(
1179 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1180 ))
1181 .fetch_all(&self.pool)
1182 .await?;
1183 rows.iter().map(Self::row_to_task).collect()
1184 }
1185
1186 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1190 let rows = sqlx::query(&format!(
1191 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1192 ))
1193 .bind(status.as_str())
1194 .fetch_all(&self.pool)
1195 .await?;
1196 rows.iter().map(Self::row_to_task).collect()
1197 }
1198
1199 pub async fn set_fields(
1202 &self,
1203 id: i64,
1204 updates: &[(&str, serde_json::Value)],
1205 ) -> anyhow::Result<()> {
1206 if updates.is_empty() {
1207 return Ok(());
1208 }
1209
1210 for (col, _) in updates {
1211 anyhow::ensure!(
1212 ALLOWED_FIELDS.contains(col),
1213 "column {col} is not in the update allowlist"
1214 );
1215 }
1216
1217 let mut set_parts = Vec::new();
1219 let mut values: Vec<Option<String>> = Vec::new();
1220
1221 for (col, val) in updates {
1222 set_parts.push(format!("{col} = ?"));
1223 values.push(sql_value_for_task_field(col, val)?);
1224 }
1225
1226 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1227 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1228
1229 let mut query = sqlx::query(&sql);
1230 for v in &values {
1231 query = query.bind(v.as_deref());
1232 }
1233 query = query.bind(id);
1234 query.execute(&self.pool).await?;
1235
1236 Ok(())
1237 }
1238
1239 pub async fn set_fields_silent(
1244 &self,
1245 id: i64,
1246 updates: &[(&str, serde_json::Value)],
1247 ) -> anyhow::Result<()> {
1248 if updates.is_empty() {
1249 return Ok(());
1250 }
1251
1252 for (col, _) in updates {
1253 anyhow::ensure!(
1254 ALLOWED_FIELDS.contains(col),
1255 "column {col} is not in the update allowlist"
1256 );
1257 }
1258
1259 let mut set_parts = Vec::new();
1260 let mut values: Vec<Option<String>> = Vec::new();
1261
1262 for (col, val) in updates {
1263 set_parts.push(format!("{col} = ?"));
1264 values.push(sql_value_for_task_field(col, val)?);
1265 }
1266
1267 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1268
1269 let mut query = sqlx::query(&sql);
1270 for v in &values {
1271 query = query.bind(v.as_deref());
1272 }
1273 query = query.bind(id);
1274 query.execute(&self.pool).await?;
1275
1276 Ok(())
1277 }
1278
1279 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1281 anyhow::ensure!(
1282 INCREMENTABLE_FIELDS.contains(&field),
1283 "field {field} is not incrementable"
1284 );
1285
1286 let sql = format!(
1287 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? RETURNING {field} AS val"
1288 );
1289
1290 let row = sqlx::query(&sql).bind(id).fetch_one(&self.pool).await?;
1291
1292 Ok(row.try_get("val")?)
1293 }
1294
1295 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1297 sqlx::query(
1298 "UPDATE tasks SET
1299 attempts = 0,
1300 route_attempts = 0,
1301 review_agent_failures = 0,
1302 merge_conflict_retries = 0,
1303 pr_create_failures = 0,
1304 push_failures = 0,
1305 network_retries = 0,
1306 ci_merge_failures = 0,
1307 review_cycles = 0,
1308 review_invocations = 0,
1309 review_session_expected = 0,
1310 auto_unblock_count = 0,
1311 auto_unblock_last_at = '',
1312 auto_unblock_last_reason = '',
1313 ci_recovery_count = 0,
1314 no_code_reroutes = 0,
1315 no_code_last_agent = '',
1316 needs_review_refires = 0,
1317 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1318 WHERE id = ?",
1319 )
1320 .bind(id)
1321 .execute(&self.pool)
1322 .await?;
1323 Ok(())
1324 }
1325
1326 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1341 sqlx::query(
1342 "UPDATE tasks SET
1343 route_attempts = 0,
1344 review_agent_failures = 0,
1345 pr_create_failures = 0,
1346 push_failures = 0,
1347 network_retries = 0,
1348 review_session_expected = 0,
1349 auto_unblock_count = 0,
1350 auto_unblock_last_at = '',
1351 auto_unblock_last_reason = '',
1352 ci_recovery_count = 0,
1353 no_code_reroutes = 0,
1354 no_code_last_agent = '',
1355 needs_review_refires = 0,
1356 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1357 WHERE id = ?",
1358 )
1359 .bind(id)
1360 .execute(&self.pool)
1361 .await?;
1362 Ok(())
1363 }
1364
1365 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1371 sqlx::query(
1372 "UPDATE tasks SET
1373 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
1374 agent_profile = ?, selected_skills = ?,
1375 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1376 WHERE id = ?",
1377 )
1378 .bind(route.agent)
1379 .bind(route.model)
1380 .bind(route.complexity)
1381 .bind(route.estimate as i64)
1382 .bind(route.reason)
1383 .bind(route.profile)
1384 .bind(route.skills)
1385 .bind(route.id)
1386 .execute(&self.pool)
1387 .await?;
1388 let details = serde_json::json!({
1389 "complexity": route.complexity,
1390 "estimate": route.estimate,
1391 "reason": route.reason,
1392 "skills": route.skills,
1393 });
1394 self.append_activity(
1395 route.id,
1396 "dispatch",
1397 None,
1398 None,
1399 Some(route.agent),
1400 route.model,
1401 Some(&details),
1402 )
1403 .await?;
1404 Ok(())
1405 }
1406
1407 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1415 let rows = sqlx::query(
1416 "SELECT external_id, estimate FROM tasks \
1417 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1418 AND status NOT IN ('done', 'blocked')",
1419 )
1420 .fetch_all(&self.pool)
1421 .await?;
1422
1423 let mut result = Vec::new();
1424 for row in &rows {
1425 let external_id: String = row.try_get("external_id")?;
1426 let estimate_raw: i64 = row.try_get("estimate")?;
1427 if (1..=255).contains(&estimate_raw) {
1428 result.push((external_id, estimate_raw as u8));
1429 }
1430 }
1431 Ok(result)
1432 }
1433
1434 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1440 let result = sqlx::query("UPDATE tasks SET estimate = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND estimate = 0")
1441 .bind(estimate as i64)
1442 .bind(id)
1443 .execute(&self.pool)
1444 .await?;
1445
1446 Ok(result.rows_affected() > 0)
1447 }
1448
1449 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1458 let mut tx = self.pool.begin().await?;
1459
1460 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1461 .bind(id)
1462 .fetch_one(&mut *tx)
1463 .await?;
1464
1465 let memory_opt: Option<String> = row
1470 .try_get::<Option<String>, _>("memory")
1471 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1472 let memory_str = memory_opt.unwrap_or_default();
1473
1474 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1475 Vec::new()
1476 } else {
1477 serde_json::from_str(&memory_str)
1478 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1479 };
1480 memory.push(entry.clone());
1481 let new_json = serde_json::to_string(&memory)?;
1482
1483 sqlx::query(
1484 "UPDATE tasks SET memory = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1485 )
1486 .bind(&new_json)
1487 .bind(id)
1488 .execute(&mut *tx)
1489 .await?;
1490
1491 tx.commit().await?;
1492 Ok(())
1493 }
1494
1495 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1497 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1498 .bind(id)
1499 .fetch_one(&self.pool)
1500 .await?;
1501
1502 let memory_opt: Option<String> = row
1503 .try_get::<Option<String>, _>("memory")
1504 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1505 let memory_str = memory_opt.unwrap_or_default();
1506
1507 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1508 Vec::new()
1509 } else {
1510 serde_json::from_str(&memory_str)
1511 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1512 };
1513
1514 memory.sort_by_key(|m| m.attempt);
1515 if memory.len() > max {
1516 memory = memory.split_off(memory.len() - max);
1517 }
1518 Ok(memory)
1519 }
1520
1521 pub async fn store_tokens(
1527 &self,
1528 id: i64,
1529 input: u64,
1530 output: u64,
1531 model: &str,
1532 ) -> anyhow::Result<()> {
1533 let pricing = pricing_for_model(model);
1534 let usage = TokenUsage {
1535 input_tokens: input,
1536 output_tokens: output,
1537 };
1538 let cost = pricing.estimate_cost_usd(usage);
1539
1540 sqlx::query(
1541 "UPDATE tasks SET
1542 input_tokens = COALESCE(input_tokens, 0) + ?,
1543 output_tokens = COALESCE(output_tokens, 0) + ?,
1544 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1545 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1546 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1547 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1548 WHERE id = ?",
1549 )
1550 .bind(i64::try_from(input).unwrap_or_else(|_| {
1551 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1552 i64::MAX
1553 }))
1554 .bind(i64::try_from(output).unwrap_or_else(|_| {
1555 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1556 i64::MAX
1557 }))
1558 .bind(cost.input_cost_usd)
1559 .bind(cost.output_cost_usd)
1560 .bind(cost.total_cost_usd)
1561 .bind(id)
1562 .execute(&self.pool)
1563 .await?;
1564
1565 Ok(())
1566 }
1567
1568 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1574 sqlx::query(
1575 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1576 )
1577 .bind(id)
1578 .execute(&self.pool)
1579 .await?;
1580 self.append_activity(
1581 id,
1582 "branch_delete",
1583 None,
1584 None,
1585 None,
1586 None,
1587 Some(&serde_json::json!({ "worktree_cleaned": true })),
1588 )
1589 .await?;
1590 Ok(())
1591 }
1592
1593 #[cfg(test)]
1599 pub async fn batch_set_fields(
1600 &self,
1601 updates: &[(i64, &[(&str, serde_json::Value)])],
1602 ) -> anyhow::Result<()> {
1603 if updates.is_empty() {
1604 return Ok(());
1605 }
1606
1607 for (_, entry_updates) in updates {
1608 for (col, _) in *entry_updates {
1609 anyhow::ensure!(
1610 ALLOWED_FIELDS.contains(col),
1611 "column {col} is not in the update allowlist"
1612 );
1613 }
1614 }
1615
1616 let mut tx = self.pool.begin().await?;
1617 for (id, entry_updates) in updates {
1618 if entry_updates.is_empty() {
1619 continue;
1620 }
1621 let mut set_parts = Vec::new();
1622 let mut values: Vec<Option<String>> = Vec::new();
1623 for (col, val) in *entry_updates {
1624 set_parts.push(format!("{col} = ?"));
1625 values.push(sql_value_for_task_field(col, val)?);
1626 }
1627 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1628 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1629 let mut query = sqlx::query(&sql);
1630 for v in &values {
1631 query = query.bind(v.as_deref());
1632 }
1633 query = query.bind(*id);
1634 query.execute(&mut *tx).await?;
1635 }
1636 tx.commit().await?;
1637 Ok(())
1638 }
1639
1640 #[cfg(test)]
1645 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1646 if entries.is_empty() {
1647 return Ok(());
1648 }
1649
1650 for (_, field) in entries {
1651 anyhow::ensure!(
1652 INCREMENTABLE_FIELDS.contains(field),
1653 "field {field} is not incrementable"
1654 );
1655 }
1656
1657 let mut tx = self.pool.begin().await?;
1658 for (id, field) in entries {
1659 let sql = format!(
1660 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1661 );
1662 sqlx::query(&sql).bind(*id).execute(&mut *tx).await?;
1663 }
1664 tx.commit().await?;
1665 Ok(())
1666 }
1667
1668 #[cfg(test)]
1675 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1676 if ids.is_empty() {
1677 return Ok(());
1678 }
1679 const CHUNK_SIZE: usize = 500;
1682
1683 let mut tx = self.pool.begin().await?;
1684
1685 for chunk in ids.chunks(CHUNK_SIZE) {
1686 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1688 let sql = format!(
1689 "UPDATE tasks SET
1690 route_attempts = 0,
1691 review_agent_failures = 0,
1692 review_invocations = 0,
1693 pr_create_failures = 0,
1694 push_failures = 0,
1695 network_retries = 0,
1696 review_session_expected = 0,
1697 auto_unblock_count = 0,
1698 auto_unblock_last_at = '',
1699 auto_unblock_last_reason = '',
1700 ci_recovery_count = 0,
1701 no_code_reroutes = 0,
1702 needs_review_refires = 0,
1703 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1704 WHERE id IN ({placeholders})",
1705 );
1706 let mut query = sqlx::query(&sql);
1707 for id in chunk {
1708 query = query.bind(*id);
1709 }
1710 query.execute(&mut *tx).await?;
1711 }
1712
1713 tx.commit().await?;
1714 Ok(())
1715 }
1716
1717 #[cfg(test)]
1722 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1723 if ids.is_empty() {
1724 return Ok(());
1725 }
1726
1727 const CHUNK_SIZE: usize = 500;
1729
1730 let mut tx = self.pool.begin().await?;
1731
1732 for chunk in ids.chunks(CHUNK_SIZE) {
1733 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1734 let sql = format!(
1735 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1736 );
1737 let mut query = sqlx::query(&sql);
1738 for id in chunk {
1739 query = query.bind(*id);
1740 }
1741 query.execute(&mut *tx).await?;
1742 }
1743
1744 let details = serde_json::json!({ "worktree_cleaned": true });
1748 let details_json = serde_json::to_string(&details)?;
1749 for id in ids {
1750 sqlx::query(
1751 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1752 )
1753 .bind(*id)
1754 .bind("branch_delete")
1755 .bind(None::<&str>)
1756 .bind(None::<&str>)
1757 .bind(None::<&str>)
1758 .bind(None::<&str>)
1759 .bind(details_json.as_str())
1760 .execute(&mut *tx)
1761 .await?;
1762 }
1763
1764 tx.commit().await?;
1765 Ok(())
1766 }
1767
1768 #[cfg(test)]
1771 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1772 let rows = sqlx::query(&format!(
1773 "SELECT {TASK_COLS} FROM tasks
1774 WHERE repo = ?
1775 AND worktree != ''
1776 AND worktree_cleaned = 0
1777 AND status IN ('done', 'blocked')
1778 ORDER BY updated_at ASC"
1779 ))
1780 .bind(repo)
1781 .fetch_all(&self.pool)
1782 .await?;
1783
1784 rows.iter().map(Self::row_to_task).collect()
1785 }
1786
1787 pub async fn cleaned_external_ids(
1792 &self,
1793 repo: &str,
1794 ) -> anyhow::Result<std::collections::HashSet<String>> {
1795 let rows: Vec<(String,)> =
1796 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1797 .bind(repo)
1798 .fetch_all(&self.pool)
1799 .await?;
1800
1801 Ok(rows.into_iter().map(|(id,)| id).collect())
1802 }
1803
1804 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1810 let orphaned: Vec<(i64,)> = sqlx::query_as(
1822 "SELECT id FROM task_runs
1823 WHERE task_id = ? AND run_type = ? AND attempt != ?
1824 AND outcome IS NULL AND completed_at IS NULL",
1825 )
1826 .bind(run.task_id)
1827 .bind(run.run_type)
1828 .bind(run.attempt)
1829 .fetch_all(&self.pool)
1830 .await?;
1831
1832 for (run_id,) in orphaned {
1833 self.complete_run(&CompleteRun {
1834 run_id,
1835 exit_code: Some(-1),
1836 stdout: "",
1837 stderr: "",
1838 parsed: "",
1839 outcome: "aborted",
1840 error: "superseded by a new run start before this one completed",
1841 tokens: RunTokenUsage::default(),
1842 })
1843 .await?;
1844 }
1845
1846 let row = sqlx::query(
1847 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
1848 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
1849 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
1850 agent = excluded.agent, model = excluded.model,
1851 command = excluded.command, prompt = excluded.prompt,
1852 outcome = NULL,
1853 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
1854 completed_at = NULL, error = NULL, exit_code = NULL,
1855 stdout = NULL, stderr = NULL, parsed_response = NULL,
1856 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
1857 total_cost_usd = NULL
1858 RETURNING id",
1859 )
1860 .bind(run.task_id)
1861 .bind(run.attempt)
1862 .bind(run.run_type)
1863 .bind(run.agent)
1864 .bind(run.model)
1865 .bind(run.command)
1866 .bind(run.prompt)
1867 .fetch_one(&self.pool)
1868 .await?;
1869
1870 let run_id: i64 = row
1871 .try_get("id")
1872 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
1873 let event_type = match run.run_type {
1874 "review" => "review_start",
1875 _ => "dispatch",
1876 };
1877 let details = serde_json::json!({
1878 "run_type": run.run_type,
1879 "attempt": run.attempt,
1880 "command": run.command,
1881 });
1882 self.append_activity(
1883 run.task_id,
1884 event_type,
1885 None,
1886 None,
1887 Some(run.agent),
1888 Some(run.model),
1889 Some(&details),
1890 )
1891 .await?;
1892
1893 Ok(run_id)
1894 }
1895
1896 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
1898 let run_ctx =
1899 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
1900 .bind(run.run_id)
1901 .fetch_optional(&self.pool)
1902 .await?;
1903 sqlx::query(
1904 "UPDATE task_runs SET
1905 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
1906 outcome = ?, error = NULLIF(?, ''),
1907 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
1908 duration_secs = ?,
1909 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1910 WHERE id = ?",
1911 )
1912 .bind(run.exit_code)
1913 .bind(run.stdout)
1914 .bind(security::redact(run.stderr))
1915 .bind(run.parsed)
1916 .bind(run.outcome)
1917 .bind(security::redact(run.error))
1918 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1919 tracing::warn!(
1920 run.tokens.input_tokens,
1921 "input_tokens overflows i64, clamping to MAX"
1922 );
1923 i64::MAX
1924 }))
1925 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1926 tracing::warn!(
1927 run.tokens.output_tokens,
1928 "output_tokens overflows i64, clamping to MAX"
1929 );
1930 i64::MAX
1931 }))
1932 .bind(run.tokens.total_cost_usd)
1933 .bind(run.tokens.duration_secs)
1934 .bind(run.run_id)
1935 .execute(&self.pool)
1936 .await?;
1937 if let Some(run_ctx) = run_ctx {
1938 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1939 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1940 })?;
1941 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1942 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1943 })?;
1944 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1945 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1946 })?;
1947 let model: String = run_ctx.try_get("model").map_err(|e| {
1948 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1949 })?;
1950 if run.outcome == "timeout" {
1951 self.append_activity(
1952 task_id,
1953 "timeout",
1954 None,
1955 None,
1956 Some(&agent),
1957 Some(&model),
1958 Some(&serde_json::json!({
1959 "run_type": run_type,
1960 "error": run.error,
1961 "stderr": run.stderr,
1962 })),
1963 )
1964 .await?;
1965 } else if run.outcome != "success" {
1966 self.append_activity(
1967 task_id,
1968 "error",
1969 None,
1970 None,
1971 Some(&agent),
1972 Some(&model),
1973 Some(&serde_json::json!({
1974 "run_type": run_type,
1975 "outcome": run.outcome,
1976 "error": run.error,
1977 })),
1978 )
1979 .await?;
1980 }
1981 } else if run.outcome != "success" {
1982 tracing::warn!(
1983 run_id = run.run_id,
1984 outcome = run.outcome,
1985 "run_id not found in task_runs; skipping timeout/error activity log"
1986 );
1987 }
1988 Ok(())
1989 }
1990
1991 pub async fn finalize_incomplete_runs(
1996 &self,
1997 task_id: i64,
1998 outcome: &str,
1999 error: &str,
2000 ) -> anyhow::Result<u64> {
2001 let rows: Vec<(i64,)> = sqlx::query_as(
2002 "SELECT id FROM task_runs
2003 WHERE task_id = ?
2004 AND outcome IS NULL
2005 AND completed_at IS NULL
2006 ORDER BY id ASC",
2007 )
2008 .bind(task_id)
2009 .fetch_all(&self.pool)
2010 .await?;
2011
2012 for (run_id,) in &rows {
2013 self.complete_run(&CompleteRun {
2014 run_id: *run_id,
2015 exit_code: Some(-1),
2016 stdout: "",
2017 stderr: "",
2018 parsed: "",
2019 outcome,
2020 error,
2021 tokens: RunTokenUsage::default(),
2022 })
2023 .await?;
2024 }
2025
2026 Ok(rows.len() as u64)
2027 }
2028
2029 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
2031 let rows = sqlx::query(&format!(
2032 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
2033 ))
2034 .bind(task_id)
2035 .fetch_all(&self.pool)
2036 .await?;
2037
2038 rows.iter().map(Self::row_to_run).collect()
2039 }
2040
2041 pub async fn get_runs_batch(
2044 &self,
2045 task_ids: &[i64],
2046 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
2047 if task_ids.is_empty() {
2048 return Ok(std::collections::HashMap::new());
2049 }
2050 const CHUNK_SIZE: usize = 500;
2053
2054 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
2055 std::collections::HashMap::new();
2056
2057 for chunk in task_ids.chunks(CHUNK_SIZE) {
2058 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2059 let sql = format!(
2060 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
2061 );
2062 let mut query = sqlx::query(&sql);
2063 for id in chunk {
2064 query = query.bind(*id);
2065 }
2066 let rows = query.fetch_all(&self.pool).await?;
2067 for row in &rows {
2068 let run = Self::row_to_run(row)?;
2069 result.entry(run.task_id).or_default().push(run);
2070 }
2071 }
2072 Ok(result)
2073 }
2074
2075 pub async fn get_batch(
2078 &self,
2079 ids: &[i64],
2080 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2081 if ids.is_empty() {
2082 return Ok(std::collections::HashMap::new());
2083 }
2084 const CHUNK_SIZE: usize = 500;
2087
2088 let mut result = std::collections::HashMap::new();
2089
2090 for chunk in ids.chunks(CHUNK_SIZE) {
2091 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2092 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2093 let mut query = sqlx::query(&sql);
2094 for id in chunk {
2095 query = query.bind(*id);
2096 }
2097 let rows = query.fetch_all(&self.pool).await?;
2098 for row in &rows {
2099 let task = Self::row_to_task(row)?;
2100 result.insert(task.id, task);
2101 }
2102 }
2103 Ok(result)
2104 }
2105
2106 pub async fn get_statuses_by_external_ids(
2111 &self,
2112 repo: &str,
2113 ext_ids: &[&str],
2114 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2115 let mut result = std::collections::HashMap::new();
2116 if ext_ids.is_empty() {
2117 return Ok(result);
2118 }
2119
2120 const CHUNK_SIZE: usize = 500;
2121 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2122 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2123 let sql = format!(
2124 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2125 );
2126 let mut query = sqlx::query(&sql);
2127 query = query.bind(repo);
2128 for id in chunk {
2129 query = query.bind(id);
2130 }
2131 let rows = query.fetch_all(&self.pool).await?;
2132 for row in &rows {
2133 let external_id: Result<String, _> = row.try_get("external_id");
2134 let status_str: Result<String, _> = row.try_get("status");
2135 match (external_id, status_str) {
2136 (Ok(ext_id), Ok(sts)) => {
2137 if ext_id.is_empty() {
2138 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2139 continue;
2140 }
2141 if let Some(status) = TaskStatus::from_str(&sts) {
2142 result.insert(ext_id, status);
2143 } else {
2144 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2145 }
2146 }
2147 (Err(e), _) => {
2148 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2149 }
2150 (_, Err(e)) => {
2151 tracing::warn!(error = %e, "skipping task row with decode error for status");
2152 }
2153 }
2154 }
2155 }
2156
2157 Ok(result)
2158 }
2159
2160 #[cfg(test)]
2163 pub async fn get_last_run(
2164 &self,
2165 task_id: i64,
2166 run_type: &str,
2167 ) -> anyhow::Result<Option<TaskRun>> {
2168 let row = sqlx::query(&format!(
2169 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2170 ))
2171 .bind(task_id)
2172 .bind(run_type)
2173 .fetch_optional(&self.pool)
2174 .await?;
2175
2176 match row {
2177 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2178 None => Ok(None),
2179 }
2180 }
2181
2182 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2186 let rows: Vec<(String,)> = sqlx::query_as(
2187 "SELECT DISTINCT agent FROM task_runs \
2188 WHERE task_id = ? AND run_type = 'review' \
2189 ORDER BY agent",
2190 )
2191 .bind(task_id)
2192 .fetch_all(&self.pool)
2193 .await?;
2194 Ok(rows.into_iter().map(|(a,)| a).collect())
2195 }
2196
2197 #[cfg(test)]
2200 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2201 let result = sqlx::query(
2202 "DELETE FROM task_runs WHERE task_id IN (
2203 SELECT id FROM tasks
2204 WHERE status IN ('done', 'blocked')
2205 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2206 )",
2207 )
2208 .bind(format!("-{days}"))
2209 .execute(&self.pool)
2210 .await?;
2211
2212 Ok(result.rows_affected())
2213 }
2214
2215 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2220 let col_count = row.len();
2223 if col_count != TASK_COLS_COUNT {
2224 tracing::warn!(
2225 expected = TASK_COLS_COUNT,
2226 actual = col_count,
2227 "TASK_COLS column count mismatch - schema may have changed"
2228 );
2229 }
2230
2231 let status_str: String = row
2232 .try_get("status")
2233 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2234 let labels_str: String = row.try_get("labels").unwrap_or_default();
2235 let memory_str: String = row.try_get("memory").unwrap_or_default();
2236 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2237
2238 Ok(Task {
2239 id: row
2240 .try_get("id")
2241 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2242 external_id: row.try_get("external_id").unwrap_or(None),
2243 repo: row
2244 .try_get("repo")
2245 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2246 origin: row
2247 .try_get("origin")
2248 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2249 title: row
2250 .try_get("title")
2251 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2252 body: row
2253 .try_get("body")
2254 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2255 status: TaskStatus::from_str(&status_str)
2256 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2257 source: row
2258 .try_get("source")
2259 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2260 source_id: row
2261 .try_get("source_id")
2262 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2263 author: row
2264 .try_get("author")
2265 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2266 url: row
2267 .try_get("url")
2268 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2269 labels: serde_json::from_str(&labels_str)
2270 .inspect_err(
2271 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2272 )
2273 .unwrap_or_default(),
2274 agent: row.try_get("agent").unwrap_or(None),
2275 model: row.try_get("model").unwrap_or(None),
2276 complexity: row.try_get("complexity").unwrap_or_default(),
2277 estimate: {
2278 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2279 u8::try_from(raw).unwrap_or_else(|_| {
2280 tracing::warn!(
2281 raw_estimate = raw,
2282 "estimate value out of u8 range; defaulting to 0"
2283 );
2284 0
2285 })
2286 },
2287 route_reason: row.try_get("route_reason").unwrap_or_default(),
2288 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2289 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2290 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2291 attempts: row.try_get("attempts").unwrap_or(0),
2292 branch: row.try_get("branch").unwrap_or_default(),
2293 worktree: row.try_get("worktree").unwrap_or_default(),
2294 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2295 summary: row.try_get("summary").unwrap_or_default(),
2296 last_error: row.try_get("last_error").unwrap_or_default(),
2297 parent_id: row.try_get("parent_id").unwrap_or(None),
2298 block_reason: row.try_get("block_reason").unwrap_or(None),
2299 pr_number: row.try_get("pr_number").unwrap_or(None),
2300 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2301 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2302 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2303 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2304 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2305 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2306 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2307 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2308 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2309 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2310 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2311 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2312 review_session_expected: row
2313 .try_get::<i32, _>("review_session_expected")
2314 .unwrap_or(0)
2315 != 0,
2316 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2317 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2318 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2319 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2320 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2321 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2322 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2323 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2324 memory: serde_json::from_str(&memory_str)
2325 .inspect_err(
2326 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2327 )
2328 .unwrap_or_default(),
2329 delegations: serde_json::from_str(&delegations_str)
2330 .inspect_err(
2331 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2332 )
2333 .unwrap_or_default(),
2334 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2339 auto_unblock_last_at: row
2340 .try_get::<String, _>("auto_unblock_last_at")
2341 .unwrap_or_default(),
2342 auto_unblock_last_reason: row
2343 .try_get::<String, _>("auto_unblock_last_reason")
2344 .unwrap_or_default(),
2345 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2346 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2347 no_code_last_agent: row
2348 .try_get::<String, _>("no_code_last_agent")
2349 .unwrap_or_default(),
2350 created_at: row
2351 .try_get::<String, _>("created_at")
2352 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2353 updated_at: row
2354 .try_get::<String, _>("updated_at")
2355 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2356 })
2357 }
2358 pub async fn ensure_external_task(
2359 &self,
2360 repo: &str,
2361 ext: &crate::backends::ExternalTask,
2362 ) -> anyhow::Result<i64> {
2363 self.upsert_external(&UpsertExternal {
2364 repo,
2365 ext_id: &ext.id.0,
2366 title: &ext.title,
2367 body: &ext.body,
2368 author: &ext.author,
2369 url: &ext.url,
2370 labels: &ext.labels,
2371 origin: "github",
2372 })
2373 .await
2374 }
2375
2376 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2379 if let Some(suffix) = task_id.strip_prefix("internal:") {
2380 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2382 return Ok(Some(id));
2383 }
2384 if let Ok(id) = suffix.parse::<i64>() {
2388 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2389 .bind(id)
2390 .bind(repo)
2391 .fetch_optional(&self.pool)
2392 .await?;
2393 if exists.is_some() {
2394 return Ok(Some(id));
2395 }
2396 }
2397 return Ok(None);
2398 }
2399 self.resolve_id_by_external(repo, task_id).await
2402 }
2403
2404 pub async fn resolve_task_id_globally(
2410 &self,
2411 task_id: &str,
2412 ) -> anyhow::Result<Option<(i64, String)>> {
2413 if let Some(suffix) = task_id.strip_prefix("internal:") {
2414 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2416 .bind(task_id)
2417 .fetch_optional(&self.pool)
2418 .await?;
2419 if let Some(r) = row {
2420 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2421 }
2422 if let Ok(id) = suffix.parse::<i64>() {
2423 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2424 .bind(id)
2425 .fetch_optional(&self.pool)
2426 .await?;
2427 if let Some(r) = row {
2428 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2429 }
2430 }
2431 return Ok(None);
2432 }
2433 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2435 .bind(task_id)
2436 .fetch_all(&self.pool)
2437 .await?;
2438 match rows.len() {
2439 0 => Ok(None),
2440 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2441 _ => {
2442 let repos: Vec<String> = rows
2443 .iter()
2444 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2445 .collect();
2446 anyhow::bail!(
2447 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2448 Run from the repo worktree so the current-repo lookup resolves it.",
2449 repos.join(", ")
2450 )
2451 }
2452 }
2453 }
2454
2455 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2456 Ok(TaskRun {
2457 id: row
2458 .try_get("id")
2459 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2460 task_id: row
2461 .try_get("task_id")
2462 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2463 attempt: row
2464 .try_get("attempt")
2465 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2466 run_type: row
2467 .try_get("run_type")
2468 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2469 agent: row
2470 .try_get("agent")
2471 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2472 model: row
2473 .try_get("model")
2474 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2475 command: row
2476 .try_get("command")
2477 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2478 prompt: row
2479 .try_get("prompt")
2480 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2481 env_vars: row
2482 .try_get("env_vars")
2483 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2484 exit_code: row
2485 .try_get("exit_code")
2486 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2487 stdout: row
2488 .try_get("stdout")
2489 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2490 stderr: row
2491 .try_get("stderr")
2492 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2493 parsed_response: row
2494 .try_get("parsed_response")
2495 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2496 outcome: row
2497 .try_get("outcome")
2498 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2499 error: row
2500 .try_get("error")
2501 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2502 input_tokens: row
2503 .try_get("input_tokens")
2504 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2505 output_tokens: row
2506 .try_get("output_tokens")
2507 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2508 total_cost_usd: row
2509 .try_get("total_cost_usd")
2510 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2511 duration_secs: row
2512 .try_get("duration_secs")
2513 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2514 started_at: row
2515 .try_get("started_at")
2516 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2517 completed_at: row.try_get("completed_at").unwrap_or(None),
2518 })
2519 }
2520
2521 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2522 let details_str: String = row
2523 .try_get("details")
2524 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2525 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2526 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2527 )?;
2528 Ok(TaskActivity {
2529 id: row
2530 .try_get("id")
2531 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2532 task_id: row
2533 .try_get("task_id")
2534 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2535 timestamp: row
2536 .try_get("timestamp")
2537 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2538 event_type: row
2539 .try_get("event_type")
2540 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2541 from_status: row
2542 .try_get("from_status")
2543 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2544 to_status: row
2545 .try_get("to_status")
2546 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2547 agent: row
2548 .try_get("agent")
2549 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2550 model: row
2551 .try_get("model")
2552 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2553 details,
2554 })
2555 }
2556}
2557
2558#[cfg(test)]
2559mod row_to_task_tests {
2560 use super::*;
2561
2562 async fn store_with_task() -> (crate::store::TaskStore, i64) {
2563 let store = crate::store::TaskStore::open_memory().await.unwrap();
2564 let id = store
2565 .create(&NewTask {
2566 external_id: None,
2567 repo: "owner/repo".to_string(),
2568 origin: "internal".to_string(),
2569 title: "Test task".to_string(),
2570 body: "Test body".to_string(),
2571 source: "cron".to_string(),
2572 source_id: "test-1".to_string(),
2573 author: "tester".to_string(),
2574 url: "https://example.com/1".to_string(),
2575 labels: vec![],
2576 parent_id: None,
2577 })
2578 .await
2579 .unwrap();
2580 (store, id)
2581 }
2582
2583 macro_rules! missing_column_test {
2586 ($name:ident, $col:literal, $select:literal) => {
2587 #[tokio::test]
2588 async fn $name() {
2589 let (store, id) = store_with_task().await;
2590 let sql = format!("{} WHERE id = {id}", $select);
2591 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2592 let err = TaskStore::row_to_task(&row).unwrap_err();
2593 assert!(
2594 err.to_string().contains($col),
2595 "expected '{}' in error message, got: {err}",
2596 $col
2597 );
2598 }
2599 };
2600 }
2601
2602 missing_column_test!(
2603 row_to_task_fails_on_missing_repo,
2604 "repo",
2605 "SELECT id, status FROM tasks"
2606 );
2607 missing_column_test!(
2608 row_to_task_fails_on_missing_origin,
2609 "origin",
2610 "SELECT id, repo, status FROM tasks"
2611 );
2612 missing_column_test!(
2613 row_to_task_fails_on_missing_title,
2614 "title",
2615 "SELECT id, repo, origin, status FROM tasks"
2616 );
2617 missing_column_test!(
2618 row_to_task_fails_on_missing_body,
2619 "body",
2620 "SELECT id, repo, origin, title, status FROM tasks"
2621 );
2622 missing_column_test!(
2623 row_to_task_fails_on_missing_source,
2624 "source",
2625 "SELECT id, repo, origin, title, body, status FROM tasks"
2626 );
2627 missing_column_test!(
2628 row_to_task_fails_on_missing_source_id,
2629 "source_id",
2630 "SELECT id, repo, origin, title, body, source, status FROM tasks"
2631 );
2632 missing_column_test!(
2633 row_to_task_fails_on_missing_author,
2634 "author",
2635 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2636 );
2637 missing_column_test!(
2638 row_to_task_fails_on_missing_url,
2639 "url",
2640 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2641 );
2642
2643 #[tokio::test]
2650 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2651 let (store, id) = store_with_task().await;
2652 let sql = format!(
2655 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2656 source, source_id, author, url FROM tasks WHERE id = {id}"
2657 );
2658 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2659 let err = TaskStore::row_to_task(&row).unwrap_err();
2660 assert!(
2661 err.to_string().contains("repo"),
2662 "expected 'repo' in error message, got: {err}"
2663 );
2664 }
2665
2666 #[tokio::test]
2669 async fn distinct_repos_fails_on_decode_error() {
2670 let store = crate::store::TaskStore::open_memory().await.unwrap();
2671 let id = store
2672 .create(&NewTask {
2673 external_id: None,
2674 repo: "owner/repo".to_string(),
2675 origin: "internal".to_string(),
2676 title: "Test task".to_string(),
2677 body: "Test body".to_string(),
2678 source: "cron".to_string(),
2679 source_id: "test-1".to_string(),
2680 author: "tester".to_string(),
2681 url: "https://example.com/1".to_string(),
2682 labels: vec![],
2683 parent_id: None,
2684 })
2685 .await
2686 .unwrap();
2687
2688 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2690 sqlx::query(&sql).execute(store.pool()).await.unwrap();
2691
2692 let err = store.distinct_repos().await.unwrap_err();
2693 assert!(
2694 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2695 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2696 );
2697 }
2698
2699 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
2702 let store = crate::store::TaskStore::open_memory().await.unwrap();
2703 let task_id = store
2704 .create(&NewTask {
2705 external_id: None,
2706 repo: "owner/repo".to_string(),
2707 origin: "internal".to_string(),
2708 title: "Test task".to_string(),
2709 body: "Test body".to_string(),
2710 source: "cron".to_string(),
2711 source_id: "test-1".to_string(),
2712 author: "tester".to_string(),
2713 url: "https://example.com/1".to_string(),
2714 labels: vec![],
2715 parent_id: None,
2716 })
2717 .await
2718 .unwrap();
2719 let run_id = store
2720 .start_run(&crate::store::StartRun {
2721 task_id,
2722 attempt: 1,
2723 run_type: "agent",
2724 agent: "claude",
2725 model: "sonnet",
2726 command: "claude -p ...",
2727 prompt: "system prompt",
2728 })
2729 .await
2730 .unwrap();
2731 (store, task_id, run_id)
2732 }
2733
2734 #[tokio::test]
2735 async fn start_run_upsert_clears_stale_completion_fields() {
2736 let (store, task_id, run_id) = store_with_run().await;
2737 store
2738 .complete_run(&CompleteRun {
2739 run_id,
2740 exit_code: Some(0),
2741 stdout: "",
2742 stderr: "",
2743 parsed: "",
2744 outcome: "success",
2745 error: "boom",
2746 tokens: RunTokenUsage::default(),
2747 })
2748 .await
2749 .unwrap();
2750
2751 let same_run_id = store
2753 .start_run(&crate::store::StartRun {
2754 task_id,
2755 attempt: 1,
2756 run_type: "agent",
2757 agent: "claude",
2758 model: "sonnet",
2759 command: "claude -p ...",
2760 prompt: "system prompt",
2761 })
2762 .await
2763 .unwrap();
2764 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
2765
2766 let row = sqlx::query(
2767 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
2768 )
2769 .bind(run_id)
2770 .fetch_one(store.pool())
2771 .await
2772 .unwrap();
2773 let outcome: Option<String> = row.try_get("outcome").unwrap();
2774 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
2775 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
2776 let error: Option<String> = row.try_get("error").unwrap();
2777 assert_eq!(outcome, None);
2778 assert_eq!(
2779 completed_at, None,
2780 "stale completion timestamp must be cleared"
2781 );
2782 assert_eq!(exit_code, None);
2783 assert_eq!(error, None);
2784 }
2785
2786 macro_rules! run_blob_decode_test {
2793 ($name:ident, $col:literal, $select:literal) => {
2794 #[tokio::test]
2795 async fn $name() {
2796 let (store, _task_id, run_id) = store_with_run().await;
2797 let sql = format!($select, run_id = run_id);
2798 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2799 let err = TaskStore::row_to_run(&row).unwrap_err();
2800 assert!(
2801 err.to_string().contains($col),
2802 "expected '{0}' in error message, got: {err}",
2803 $col
2804 );
2805 }
2806 };
2807 }
2808
2809 run_blob_decode_test!(
2810 row_to_run_fails_on_decode_error_invalid_utf8_id,
2811 "id",
2812 "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}"
2813 );
2814 run_blob_decode_test!(
2815 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
2816 "task_id",
2817 "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}"
2818 );
2819 run_blob_decode_test!(
2820 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
2821 "started_at",
2822 "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}"
2823 );
2824
2825 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
2828 let store = crate::store::TaskStore::open_memory().await.unwrap();
2829 let task_id = store
2830 .create(&NewTask {
2831 external_id: None,
2832 repo: "owner/repo".to_string(),
2833 origin: "internal".to_string(),
2834 title: "Test task".to_string(),
2835 body: "Test body".to_string(),
2836 source: "cron".to_string(),
2837 source_id: "test-1".to_string(),
2838 author: "tester".to_string(),
2839 url: "https://example.com/1".to_string(),
2840 labels: vec![],
2841 parent_id: None,
2842 })
2843 .await
2844 .unwrap();
2845 store
2846 .append_activity(task_id, "created", None, Some("new"), None, None, None)
2847 .await
2848 .unwrap();
2849 (store, task_id, 1)
2850 }
2851
2852 #[tokio::test]
2855 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
2856 let (store, _task_id, activity_id) = store_with_activity().await;
2857 let sql = format!(
2858 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2859 );
2860 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2861 let err = TaskStore::row_to_activity(&row).unwrap_err();
2862 assert!(
2863 err.to_string().contains("id"),
2864 "expected 'id' in error message, got: {err}"
2865 );
2866 }
2867
2868 #[tokio::test]
2869 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
2870 let (store, _task_id, activity_id) = store_with_activity().await;
2871 let sql = format!(
2872 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2873 );
2874 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2875 let err = TaskStore::row_to_activity(&row).unwrap_err();
2876 assert!(
2877 err.to_string().contains("task_id"),
2878 "expected 'task_id' in error message, got: {err}"
2879 );
2880 }
2881
2882 #[tokio::test]
2883 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
2884 let (store, _task_id, activity_id) = store_with_activity().await;
2885 let sql = format!(
2886 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2887 );
2888 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2889 let err = TaskStore::row_to_activity(&row).unwrap_err();
2890 assert!(
2891 err.to_string().contains("timestamp"),
2892 "expected 'timestamp' in error message, got: {err}"
2893 );
2894 }
2895
2896 #[tokio::test]
2897 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
2898 let (store, _task_id, activity_id) = store_with_activity().await;
2899 let sql = format!(
2900 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2901 );
2902 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2903 let err = TaskStore::row_to_activity(&row).unwrap_err();
2904 assert!(
2905 err.to_string().contains("event_type"),
2906 "expected 'event_type' in error message, got: {err}"
2907 );
2908 }
2909
2910 #[tokio::test]
2911 async fn row_to_activity_fails_on_corrupt_details_json() {
2912 let (store, task_id, _activity_id) = store_with_activity().await;
2913 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
2915 .bind(task_id)
2916 .bind("test")
2917 .bind("not valid json {{{")
2918 .execute(store.pool())
2919 .await
2920 .unwrap();
2921 let row = sqlx::query(&format!(
2922 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
2923 ))
2924 .fetch_one(store.pool())
2925 .await
2926 .unwrap();
2927 let err = TaskStore::row_to_activity(&row).unwrap_err();
2928 assert!(
2929 err.to_string().contains("details")
2930 || err.to_string().contains("json")
2931 || err.to_string().contains("expected"),
2932 "expected 'details', 'json', or 'expected' in error message, got: {err}"
2933 );
2934 }
2935
2936 run_blob_decode_test!(
2939 row_to_run_fails_on_decode_error_invalid_utf8_model,
2940 "model",
2941 "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}"
2942 );
2943 run_blob_decode_test!(
2944 row_to_run_fails_on_decode_error_invalid_utf8_command,
2945 "command",
2946 "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}"
2947 );
2948 run_blob_decode_test!(
2949 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
2950 "prompt",
2951 "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}"
2952 );
2953 run_blob_decode_test!(
2954 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
2955 "env_vars",
2956 "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}"
2957 );
2958 run_blob_decode_test!(
2959 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
2960 "stdout",
2961 "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}"
2962 );
2963 run_blob_decode_test!(
2964 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
2965 "stderr",
2966 "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}"
2967 );
2968 run_blob_decode_test!(
2969 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
2970 "parsed_response",
2971 "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}"
2972 );
2973 run_blob_decode_test!(
2974 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
2975 "outcome",
2976 "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}"
2977 );
2978 run_blob_decode_test!(
2979 row_to_run_fails_on_decode_error_invalid_utf8_error,
2980 "error",
2981 "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}"
2982 );
2983 run_blob_decode_test!(
2984 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
2985 "input_tokens",
2986 "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}"
2987 );
2988 run_blob_decode_test!(
2989 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
2990 "output_tokens",
2991 "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}"
2992 );
2993 run_blob_decode_test!(
2994 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
2995 "total_cost_usd",
2996 "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}"
2997 );
2998 run_blob_decode_test!(
2999 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3000 "duration_secs",
3001 "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}"
3002 );
3003 run_blob_decode_test!(
3004 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3005 "exit_code",
3006 "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}"
3007 );
3008
3009 #[tokio::test]
3016 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3017 let (store, _task_id, activity_id) = store_with_activity().await;
3018 let sql = format!(
3019 "SELECT id, task_id, timestamp, event_type, \
3020 X'DeadbEEF' as from_status, NULL as to_status, \
3021 NULL as agent, NULL as model, details \
3022 FROM task_activity WHERE id = {activity_id}"
3023 );
3024 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3025 let err = TaskStore::row_to_activity(&row).unwrap_err();
3026 assert!(
3027 err.to_string().contains("from_status"),
3028 "expected 'from_status' in error message, got: {err}"
3029 );
3030 }
3031
3032 #[tokio::test]
3033 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3034 let (store, _task_id, activity_id) = store_with_activity().await;
3035 let sql = format!(
3036 "SELECT id, task_id, timestamp, event_type, \
3037 NULL as from_status, X'DeadbEEF' as to_status, \
3038 NULL as agent, NULL as model, details \
3039 FROM task_activity WHERE id = {activity_id}"
3040 );
3041 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3042 let err = TaskStore::row_to_activity(&row).unwrap_err();
3043 assert!(
3044 err.to_string().contains("to_status"),
3045 "expected 'to_status' in error message, got: {err}"
3046 );
3047 }
3048
3049 #[tokio::test]
3050 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3051 let (store, _task_id, activity_id) = store_with_activity().await;
3052 let sql = format!(
3053 "SELECT id, task_id, timestamp, event_type, \
3054 NULL as from_status, NULL as to_status, \
3055 X'DeadbEEF' as agent, NULL as model, details \
3056 FROM task_activity WHERE id = {activity_id}"
3057 );
3058 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3059 let err = TaskStore::row_to_activity(&row).unwrap_err();
3060 assert!(
3061 err.to_string().contains("agent"),
3062 "expected 'agent' in error message, got: {err}"
3063 );
3064 }
3065
3066 #[tokio::test]
3067 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3068 let (store, _task_id, activity_id) = store_with_activity().await;
3069 let sql = format!(
3070 "SELECT id, task_id, timestamp, event_type, \
3071 NULL as from_status, NULL as to_status, \
3072 NULL as agent, X'DeadbEEF' as model, details \
3073 FROM task_activity WHERE id = {activity_id}"
3074 );
3075 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3076 let err = TaskStore::row_to_activity(&row).unwrap_err();
3077 assert!(
3078 err.to_string().contains("model"),
3079 "expected 'model' in error message, got: {err}"
3080 );
3081 }
3082
3083 #[tokio::test]
3086 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3087 let store = crate::store::TaskStore::open_memory().await.unwrap();
3092 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3093 .execute(store.pool())
3094 .await
3095 .unwrap();
3096 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3098 .execute(store.pool())
3099 .await
3100 .unwrap();
3101
3102 let result = store
3103 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3104 .await
3105 .unwrap();
3106 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3107 assert_eq!(
3108 result.get("valid-id"),
3109 Some(&crate::store::TaskStatus::Done)
3110 );
3111 }
3112}