Skip to main content

orch/engine/
tasks.rs

1use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
2use crate::engine::dependencies;
3use crate::engine::events::TaskEvent;
4use crate::store::{self, TaskStatus, TaskStore};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::fmt::Debug;
8use std::fmt::Formatter;
9use std::sync::Arc;
10use tokio::sync::broadcast::Sender;
11
12/// Returns true if the given task ID refers to an internal (SQLite) task.
13pub fn is_internal_id(id: &str) -> bool {
14    id.starts_with("internal:")
15}
16
17/// Parse an internal task ID string (e.g. "internal:8") to its numeric SQLite id.
18pub fn parse_internal_id(id: &str) -> Option<i64> {
19    id.strip_prefix("internal:")?.parse().ok()
20}
21
22/// Map a backend `Status` to a DB `TaskStatus`.
23pub fn status_to_task_status(status: Status) -> TaskStatus {
24    match status {
25        Status::New => TaskStatus::New,
26        Status::Routed => TaskStatus::Routed,
27        Status::InProgress => TaskStatus::InProgress,
28        Status::Done => TaskStatus::Done,
29        Status::Blocked => TaskStatus::Blocked,
30        Status::InReview => TaskStatus::InReview,
31        Status::Approved => TaskStatus::Approved,
32        Status::NeedsReview => TaskStatus::NeedsReview,
33        Status::Waiting => TaskStatus::Waiting,
34    }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum TaskType {
40    External,
41    Internal,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CreateTaskRequest {
46    pub title: String,
47    pub body: String,
48    pub task_type: TaskType,
49    pub labels: Vec<String>,
50    pub source: String,
51    pub source_id: String,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct TaskFilter {
56    pub status: Option<String>,
57    pub source: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(untagged)]
62#[expect(
63    clippy::large_enum_variant,
64    reason = "tasks are few and boxing would touch every match"
65)]
66pub enum Task {
67    External(ExternalTask),
68    Internal(store::Task),
69}
70
71/// Pre-update snapshot of a task's state, used to enrich events with
72/// the old status and context fields (agent, model, branch, etc.).
73#[derive(Debug, Clone, Default)]
74struct TaskSnapshot {
75    old_status: Option<String>,
76    agent: Option<String>,
77    model: Option<String>,
78    branch: Option<String>,
79    pr_number: Option<String>,
80    error: Option<String>,
81    title: Option<String>,
82    summary: Option<String>,
83    duration_seconds: Option<f64>,
84}
85
86pub struct TaskManager {
87    backend: Arc<dyn ExternalBackend>,
88    /// Unified task store (Phase 2+). Optional for backward compat with tests.
89    store: Option<Arc<TaskStore>>,
90    /// Repo identifier for store operations (e.g. "owner/repo").
91    repo: String,
92    /// Event bus sender — publishes TaskEvent on every status change.
93    event_tx: Option<Sender<TaskEvent>>,
94}
95
96impl Debug for TaskManager {
97    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
98        f.debug_struct("TaskManager")
99            .field("repo", &self.repo)
100            .finish_non_exhaustive()
101    }
102}
103
104impl Clone for TaskManager {
105    fn clone(&self) -> Self {
106        Self {
107            backend: Arc::clone(&self.backend),
108            store: self.store.clone(),
109            repo: self.repo.clone(),
110            event_tx: self.event_tx.clone(),
111        }
112    }
113}
114
115impl TaskManager {
116    #[cfg(test)]
117    pub fn new(backend: Arc<dyn ExternalBackend>) -> Self {
118        Self {
119            backend,
120            store: None,
121            repo: String::new(),
122            event_tx: None,
123        }
124    }
125
126    /// Create a TaskManager with the unified store for dual-write support.
127    pub fn with_store(
128        backend: Arc<dyn ExternalBackend>,
129        store: Arc<TaskStore>,
130        repo: String,
131    ) -> Self {
132        Self {
133            backend,
134            store: Some(store),
135            repo,
136            event_tx: None,
137        }
138    }
139
140    /// Create a TaskManager with event bus support.
141    pub fn with_events(
142        backend: Arc<dyn ExternalBackend>,
143        store: Arc<TaskStore>,
144        repo: String,
145        event_tx: Sender<TaskEvent>,
146    ) -> Self {
147        Self {
148            backend,
149            store: Some(store),
150            repo,
151            event_tx: Some(event_tx),
152        }
153    }
154
155    pub async fn create_task(&self, req: CreateTaskRequest) -> anyhow::Result<Task> {
156        match req.task_type {
157            TaskType::Internal => {
158                let store = self
159                    .store
160                    .as_ref()
161                    .ok_or_else(|| anyhow::anyhow!("store required for internal tasks"))?;
162                let id = store
163                    .create_internal(
164                        &self.repo,
165                        &req.title,
166                        &req.body,
167                        &req.source,
168                        &req.source_id,
169                        None,
170                    )
171                    .await?;
172                let task = store.get(id).await?;
173                Ok(Task::Internal(task))
174            }
175            TaskType::External => {
176                let ext_id = self
177                    .backend
178                    .create_task(&req.title, &req.body, &req.labels)
179                    .await?;
180                let task = self.backend.get_task(&ext_id).await?;
181                Ok(Task::External(task))
182            }
183        }
184    }
185
186    pub async fn get_task(&self, id: i64) -> anyhow::Result<Task> {
187        // Try store first (covers both internal and external tasks)
188        if let Some(ref store) = self.store
189            && let Ok(task) = store.get(id).await
190            && task.origin == "internal"
191        {
192            return Ok(Task::Internal(task));
193        }
194        // External task found in store — still return as External from backend
195        // for compatibility with callers that expect ExternalTask fields
196        // Fall back to backend for external tasks
197        let ext_id = ExternalId(id.to_string());
198        match self.backend.get_task(&ext_id).await {
199            Ok(external) => Ok(Task::External(external)),
200            Err(external_err) => Err(anyhow::anyhow!(
201                "task {id} not found in store or externally (external: {external_err})"
202            )),
203        }
204    }
205
206    /// List tasks by status, source, or both.
207    /// Returns both internal (store) and external (GitHub) tasks.
208    pub async fn list_tasks(&self, filter: TaskFilter) -> anyhow::Result<Vec<Task>> {
209        let mut tasks = Vec::new();
210
211        let is_all = filter.status.as_deref() == Some("all");
212
213        if let Some(status_str) = &filter.status {
214            if !is_all {
215                let task_status = TaskStatus::from_str(status_str)
216                    .ok_or_else(|| anyhow::anyhow!(
217                        "invalid status: '{status_str}'. allowed: new, routed, in_progress, needs_review, in_review, approved, blocked, waiting, done, all"
218                    ))?;
219                let backend_status = match status_str.as_str() {
220                    "new" => Status::New,
221                    "routed" => Status::Routed,
222                    "in_progress" => Status::InProgress,
223                    "done" => Status::Done,
224                    "blocked" => Status::Blocked,
225                    "in_review" => Status::InReview,
226                    "approved" => Status::Approved,
227                    "needs_review" => Status::NeedsReview,
228                    "waiting" => Status::Waiting,
229                    _ => unreachable!(),
230                };
231
232                // Get internal tasks from store
233                if let Some(ref store) = self.store {
234                    let internal_tasks = store
235                        .list_internal_by_status(&self.repo, task_status)
236                        .await?;
237                    for t in internal_tasks {
238                        if let Some(ref source) = filter.source
239                            && t.source != *source
240                        {
241                            continue;
242                        }
243                        tasks.push(Task::Internal(t));
244                    }
245                }
246
247                // Get external tasks with this status (store-first, same pattern as list_external_by_status)
248                let external_tasks = self.list_external_by_status(backend_status).await?;
249                for t in external_tasks {
250                    tasks.push(Task::External(t));
251                }
252            }
253        } else if let Some(source) = &filter.source {
254            // Only source filter — query internal tasks by source directly in SQL
255            if let Some(ref store) = self.store {
256                let by_source = store.list_internal_by_source(&self.repo, source).await?;
257                for t in by_source {
258                    tasks.push(Task::Internal(t));
259                }
260            }
261        }
262
263        // No status filter or --status all: return all tasks for the repo
264        if filter.status.is_none() || is_all {
265            let mut has_external_in_store = false;
266            let store_tasks = if let Some(ref store) = self.store {
267                if is_all {
268                    let tasks = store.list_all(&self.repo).await?;
269                    has_external_in_store = match store.has_external_tasks(&self.repo).await {
270                        Ok(has_external) => has_external,
271                        Err(e) => {
272                            tracing::warn!(
273                                repo = %self.repo,
274                                error = %e,
275                                "failed to check external task sentinel; using store-only list path"
276                            );
277                            true
278                        }
279                    };
280                    tasks
281                } else {
282                    let tasks = store.list_active(&self.repo).await?;
283                    has_external_in_store = match store.has_external_tasks(&self.repo).await {
284                        Ok(has_external) => has_external,
285                        Err(e) => {
286                            tracing::warn!(
287                                repo = %self.repo,
288                                error = %e,
289                                "failed to check external task sentinel; using store-only list path"
290                            );
291                            true
292                        }
293                    };
294                    tasks
295                }
296            } else {
297                vec![]
298            };
299
300            for t in store_tasks {
301                if t.origin == "internal" {
302                    tasks.push(Task::Internal(t));
303                } else {
304                    tasks.push(Task::External(store_task_to_external(&t)));
305                }
306            }
307
308            if !has_external_in_store {
309                let external_tasks = self.backend.list_all_tasks().await?;
310                for t in external_tasks {
311                    tasks.push(Task::External(t));
312                }
313            }
314        }
315
316        Ok(tasks)
317    }
318
319    /// Get all external tasks across all statuses (for status summary).
320    /// Reads from the store when available, falling back to the backend.
321    pub async fn list_all_external_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
322        if let Some(ref store) = self.store {
323            let tasks = store.list_all_external(&self.repo).await?;
324            if !tasks.is_empty() {
325                return Ok(tasks.iter().map(store_task_to_external).collect());
326            }
327        }
328        self.backend.list_all_tasks().await
329    }
330
331    /// Get external tasks by status (for engine use).
332    /// Store-first: reads from the store when available, falls back to backend
333    /// if the store has no tasks (e.g. before first sync).
334    pub async fn list_external_by_status(
335        &self,
336        status: Status,
337    ) -> anyhow::Result<Vec<ExternalTask>> {
338        if let Some(ref store) = self.store {
339            let db_status = status_to_task_status(status);
340            let external = store.list_external_by_status(&self.repo, db_status).await?;
341            // Use the fetched results when non-empty; only check the sentinel when empty
342            // (empty could mean "no tasks with this status" OR "store not yet synced").
343            let has_external_in_store = if external.is_empty() {
344                match store.has_external_tasks(&self.repo).await {
345                    Ok(has_external) => has_external,
346                    Err(e) => {
347                        tracing::warn!(
348                            repo = %self.repo,
349                            status = ?status,
350                            error = %e,
351                            "failed to check external task sentinel; skipping backend fallback"
352                        );
353                        true
354                    }
355                }
356            } else {
357                true
358            };
359            if has_external_in_store {
360                let external: Vec<ExternalTask> =
361                    external.iter().map(store_task_to_external).collect();
362                return Ok(external);
363            }
364        }
365        self.backend.list_by_status(status).await
366    }
367
368    /// Get all tasks (external + internal) by status.
369    /// Store-first for external tasks: reads from the store when available,
370    /// falls back to backend if the store has no tasks.
371    /// Always includes internal tasks from the store when available.
372    pub async fn list_all_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
373        let mut tasks = Vec::new();
374
375        // Get external tasks with store-first pattern
376        if let Some(ref store) = self.store {
377            let db_status = status_to_task_status(status);
378            let external = store.list_external_by_status(&self.repo, db_status).await?;
379            // Use the fetched results when non-empty; only check the sentinel when empty
380            // (empty could mean "no tasks with this status" OR "store not yet synced").
381            let has_external_in_store = if external.is_empty() {
382                match store.has_external_tasks(&self.repo).await {
383                    Ok(has_external) => has_external,
384                    Err(e) => {
385                        tracing::warn!(
386                            repo = %self.repo,
387                            status = ?status,
388                            error = %e,
389                            "failed to check external task sentinel; skipping backend fallback"
390                        );
391                        true
392                    }
393                }
394            } else {
395                true
396            };
397            if has_external_in_store {
398                tasks.extend(external.into_iter().map(|t| store_task_to_external(&t)));
399            } else {
400                // Store not synced yet — fall back to backend for external tasks
401                tasks.extend(self.backend.list_by_status(status).await?);
402            }
403        } else {
404            // No store - get from backend
405            tasks.extend(self.backend.list_by_status(status).await?);
406        }
407
408        // Always include internal tasks from store when available
409        if self.store.is_some()
410            && let Ok(internal) = self
411                .list_internal_by_status(status_to_task_status(status))
412                .await
413        {
414            tasks.extend(internal);
415        }
416
417        Ok(tasks)
418    }
419
420    /// Get open tasks that are routable (status = new).
421    /// Store-first: reads all New tasks (external + internal) from the store,
422    /// falling back to the backend for external tasks before first sync.
423    pub async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
424        if let Some(ref store) = self.store {
425            let all_new = store.list_routable(&self.repo).await?;
426            // Check if any external (non-internal) tasks are in the result — if so
427            // the store has external tasks and we can skip the has_external_tasks query.
428            let has_external_new = all_new.iter().any(|t| t.origin != "internal");
429            let tasks: Vec<ExternalTask> = all_new.iter().map(store_task_to_external).collect();
430            let has_external_in_store = if has_external_new {
431                true
432            } else {
433                match store.has_external_tasks(&self.repo).await {
434                    Ok(has_external) => has_external,
435                    Err(e) => {
436                        tracing::warn!(
437                            repo = %self.repo,
438                            error = %e,
439                            "failed to check external task sentinel; skipping backend fallback"
440                        );
441                        true
442                    }
443                }
444            };
445            if has_external_in_store {
446                return Ok(tasks);
447            }
448        }
449
450        // Fallback: backend for external + store for internal
451        let mut tasks = self.backend.list_routable().await?;
452        if let Some(ref store) = self.store {
453            let internal_new = store
454                .list_internal_by_status(&self.repo, TaskStatus::New)
455                .await?;
456            for t in internal_new {
457                tasks.push(store_task_to_external(&t));
458            }
459        }
460        Ok(tasks)
461    }
462
463    /// Update the status of an internal or external task by its string ID.
464    /// For `"internal:{n}"` IDs, updates the store. For all others, calls the backend.
465    /// Store is always updated when available.
466    pub async fn update_task_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
467        let task_status = status_to_task_status(status);
468        self.release_dependents_if_done(task_status, &self.repo, &id.0)
469            .await;
470
471        // Read pre-update snapshot from the store (old_status + context fields).
472        // This must happen BEFORE any store.update_status() call so we capture
473        // the previous state for the event.
474        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
475
476        if is_internal_id(&id.0) {
477            // Internal tasks: store is the single source of truth
478            let store = self
479                .store
480                .as_ref()
481                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
482            let store_id = snapshot_store_id
483                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
484            if task_status != TaskStatus::Blocked {
485                store.set_block_reason(store_id, None).await?;
486            }
487            store.update_status(store_id, task_status).await?;
488            // Publish event to bus only after confirmed update
489            self.publish_event(id, status, &pre_snapshot, &self.repo, None);
490            return Ok(());
491        }
492
493        // External tasks: store-first, then mirror to backend.
494        // SQLite is the source of truth; backend sync is best-effort.
495        if let Some(ref store) = self.store
496            && let Some(store_id) = snapshot_store_id
497        {
498            if task_status != TaskStatus::Blocked {
499                store.set_block_reason(store_id, None).await?;
500            }
501            store.update_status(store_id, task_status).await?;
502        }
503
504        // Mirror to backend (GitHub labels). Log failure but don't fail the operation
505        // since the store already has the correct status.
506        if let Err(e) = self.backend.update_status(id, status).await {
507            tracing::warn!(
508                task_id = id.0,
509                ?status,
510                err = %e,
511                "failed to mirror status to backend — store is authoritative"
512            );
513        }
514
515        // Publish event to bus
516        self.publish_event(id, status, &pre_snapshot, &self.repo, None);
517
518        Ok(())
519    }
520
521    pub async fn update_task_status_and_result(
522        &self,
523        id: &ExternalId,
524        status: Status,
525        updates: &[(&str, serde_json::Value)],
526    ) -> anyhow::Result<()> {
527        let task_status = status_to_task_status(status);
528        self.release_dependents_if_done(task_status, &self.repo, &id.0)
529            .await;
530
531        // Read pre-update snapshot from the store (old_status + context fields).
532        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
533
534        if is_internal_id(&id.0) {
535            // Internal tasks: store is the single source of truth
536            let store = self
537                .store
538                .as_ref()
539                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
540            let store_id = snapshot_store_id
541                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
542
543            store
544                .update_status_and_fields(store_id, task_status, updates)
545                .await?;
546            // Publish event to bus only after confirmed update
547            self.publish_event(id, status, &pre_snapshot, &self.repo, None);
548            return Ok(());
549        }
550
551        // External tasks: store-first, then mirror to backend.
552        if let Some(ref store) = self.store
553            && let Some(store_id) = snapshot_store_id
554        {
555            store
556                .update_status_and_fields(store_id, task_status, updates)
557                .await?;
558        }
559
560        // Mirror to backend (GitHub labels).
561        if let Err(e) = self.backend.update_status(id, status).await {
562            tracing::warn!(
563                task_id = id.0,
564                ?status,
565                err = %e,
566                "failed to mirror status to backend — store is authoritative"
567            );
568        }
569
570        // Publish event to bus
571        self.publish_event(id, status, &pre_snapshot, &self.repo, None);
572
573        Ok(())
574    }
575
576    /// Update the status of a task only if it is currently in `expected_status`.
577    ///
578    /// Returns `Ok(true)` if the update was applied, `Ok(false)` if the task had
579    /// already transitioned to a different status (TOCTOU-safe — no-op on race).
580    pub async fn update_task_status_if(
581        &self,
582        id: &ExternalId,
583        status: Status,
584        expected_status: Status,
585    ) -> anyhow::Result<bool> {
586        let task_status = status_to_task_status(status);
587        self.release_dependents_if_done(task_status, &self.repo, &id.0)
588            .await;
589        let expected_task_status = status_to_task_status(expected_status);
590
591        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
592
593        if is_internal_id(&id.0) {
594            let store = self
595                .store
596                .as_ref()
597                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
598            let store_id = snapshot_store_id
599                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
600            let updated = store
601                .update_status_if(store_id, task_status, expected_task_status)
602                .await?;
603            if updated {
604                self.publish_event(id, status, &pre_snapshot, &self.repo, None);
605            }
606            return Ok(updated);
607        }
608
609        // External tasks: conditional store update, then mirror to backend.
610        // The conditional guarantee (update only when current == expected) relies on
611        // having a local store record. If the unified store isn't available or the
612        // task has no store record (e.g. not yet synced), we cannot safely perform
613        // the conditional update — return false and warn so callers treat this as
614        // a no-op instead of assuming the update succeeded.
615        if let Some(ref store) = self.store {
616            if let Some(store_id) = snapshot_store_id {
617                let updated = store
618                    .update_status_if(store_id, task_status, expected_task_status)
619                    .await?;
620                if !updated {
621                    return Ok(false);
622                }
623            } else {
624                tracing::warn!(
625                    task_id = id.0,
626                    ?expected_task_status,
627                    "update_task_status_if: external task has no store record — cannot perform conditional update"
628                );
629                return Ok(false);
630            }
631        } else {
632            tracing::warn!(
633                task_id = id.0,
634                ?expected_task_status,
635                "update_task_status_if: no store available — cannot perform conditional update for external task"
636            );
637            return Ok(false);
638        }
639
640        if let Err(e) = self.backend.update_status(id, status).await {
641            tracing::warn!(
642                task_id = id.0,
643                ?status,
644                err = %e,
645                "failed to mirror status to backend — store is authoritative"
646            );
647        }
648
649        self.publish_event(id, status, &pre_snapshot, &self.repo, None);
650        Ok(true)
651    }
652
653    /// Update the status of a task and include elapsed duration in the event.
654    ///
655    /// Use this at task completion points (success or failure) so the notify
656    /// subscriber can include accurate timing in channel messages instead of 0.0.
657    pub async fn update_task_status_with_duration(
658        &self,
659        id: &ExternalId,
660        status: Status,
661        duration_seconds: Option<f64>,
662    ) -> anyhow::Result<()> {
663        let task_status = status_to_task_status(status);
664        self.release_dependents_if_done(task_status, &self.repo, &id.0)
665            .await;
666        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
667
668        if is_internal_id(&id.0) {
669            let store = self
670                .store
671                .as_ref()
672                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
673            let store_id = snapshot_store_id
674                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
675            if task_status != TaskStatus::Blocked {
676                store.set_block_reason(store_id, None).await?;
677            }
678            store.update_status(store_id, task_status).await?;
679            self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
680            return Ok(());
681        }
682
683        if let Some(ref store) = self.store
684            && let Some(store_id) = snapshot_store_id
685        {
686            if task_status != TaskStatus::Blocked {
687                store.set_block_reason(store_id, None).await?;
688            }
689            store.update_status(store_id, task_status).await?;
690        }
691
692        if let Err(e) = self.backend.update_status(id, status).await {
693            tracing::warn!(
694                task_id = id.0,
695                ?status,
696                err = %e,
697                "failed to mirror status to backend — store is authoritative"
698            );
699        }
700
701        self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
702
703        Ok(())
704    }
705
706    /// Read the current task snapshot from the store for event enrichment.
707    /// Returns `(snapshot, store_id)` — store_id is `Some` when the task was found,
708    /// so callers can reuse it for subsequent store operations without a second lookup.
709    async fn read_task_snapshot(&self, task_id: &str) -> (TaskSnapshot, Option<i64>) {
710        let Some(ref store) = self.store else {
711            return (TaskSnapshot::default(), None);
712        };
713        let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, task_id).await else {
714            return (TaskSnapshot::default(), None);
715        };
716        let snapshot = Self::snapshot_for_store_id(store, store_id, task_id).await;
717        (snapshot, Some(store_id))
718    }
719
720    /// Build a `TaskSnapshot` for an already-resolved numeric store id, without
721    /// going through `resolve_task_id`. `metric_task_id` is the external id string
722    /// used as the key in `task_metrics` for duration lookup.
723    async fn snapshot_for_store_id(
724        store: &TaskStore,
725        store_id: i64,
726        metric_task_id: &str,
727    ) -> TaskSnapshot {
728        match store.get(store_id).await {
729            Ok(task) => {
730                let duration_seconds = store.latest_task_metric_duration(metric_task_id).await;
731                TaskSnapshot {
732                    old_status: Some(task.status.as_str().to_string()),
733                    agent: task.agent.clone(),
734                    model: task.model.clone(),
735                    branch: if task.branch.is_empty() {
736                        None
737                    } else {
738                        Some(task.branch.clone())
739                    },
740                    pr_number: task.pr_number.map(|n| n.to_string()),
741                    error: if task.last_error.is_empty() {
742                        None
743                    } else {
744                        Some(task.last_error.clone())
745                    },
746                    title: if task.title.is_empty() {
747                        None
748                    } else {
749                        Some(task.title.clone())
750                    },
751                    summary: if task.summary.is_empty() {
752                        None
753                    } else {
754                        Some(task.summary.clone())
755                    },
756                    duration_seconds,
757                }
758            }
759            Err(e) => {
760                tracing::warn!(
761                    task_id = store_id,
762                    err = %e,
763                    "failed to read task snapshot for event enrichment — event context will be empty"
764                );
765                TaskSnapshot::default()
766            }
767        }
768    }
769
770    /**
771     * Drop the blocker read cache for a task that is finishing, so the next snapshot
772     * refresh reads it and releases its dependents instead of waiting out the read TTL.
773     * Called before the write, because a cleared cache only costs one extra read if the
774     * write then fails or no-ops
775     **/
776    async fn release_dependents_if_done(&self, status: TaskStatus, repo: &str, ext_id: &str) {
777        if status != TaskStatus::Done {
778            return;
779        }
780        if let Some(ref store) = self.store {
781            dependencies::clear_blocker_read_cache(store, repo, ext_id).await;
782        }
783    }
784
785    /// Publish a TaskEvent to the event bus (if wired).
786    fn publish_event(
787        &self,
788        id: &ExternalId,
789        status: Status,
790        snapshot: &TaskSnapshot,
791        repo: &str,
792        duration_seconds: Option<f64>,
793    ) {
794        if let Some(ref tx) = self.event_tx {
795            let event = TaskEvent {
796                task_id: id.0.clone(),
797                repo: repo.to_string(),
798                old_status: snapshot.old_status.clone().unwrap_or_default(),
799                new_status: status.as_label().trim_start_matches("status:").to_string(),
800                agent: snapshot.agent.clone(),
801                model: snapshot.model.clone(),
802                pr_number: snapshot.pr_number.clone(),
803                branch: snapshot.branch.clone(),
804                review_context: None,
805                error: snapshot.error.clone(),
806                timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
807                title: snapshot.title.clone(),
808                summary: snapshot.summary.clone(),
809                duration_seconds: duration_seconds.or(snapshot.duration_seconds),
810            };
811            let _ = tx.send(event);
812        }
813    }
814
815    /// Mark an already-resolved task Done directly by its numeric store id and owning
816    /// repo, bypassing the repo-scoped `resolve_task_id` lookup that `self.repo` would
817    /// otherwise apply. For callers that already hold the correct store row from a
818    /// cross-repo query — e.g. the global CI-failure unblock sweep — and would otherwise
819    /// fail to resolve a task belonging to a different repo than this `TaskManager`.
820    pub async fn mark_task_done_by_store_id(
821        &self,
822        store_id: i64,
823        repo: &str,
824        ext_id: &ExternalId,
825    ) -> anyhow::Result<()> {
826        self.update_task_status_by_store_id(store_id, repo, ext_id, Status::Done)
827            .await
828    }
829
830    /// Update a task's status directly by its already-resolved numeric store id and
831    /// owning repo, bypassing the repo-scoped `resolve_task_id` lookup that `self.repo`
832    /// would otherwise apply. For callers that already hold the correct store row from a
833    /// cross-repo query — e.g. a global sweep over tasks from inactive/removed repos —
834    /// and would otherwise fail to resolve a task belonging to a different repo than this
835    /// `TaskManager`. Does not mirror to the backend (GitHub), since the caller's backend
836    /// instance may not correspond to the task's owning repo.
837    pub async fn update_task_status_by_store_id(
838        &self,
839        store_id: i64,
840        repo: &str,
841        ext_id: &ExternalId,
842        status: Status,
843    ) -> anyhow::Result<()> {
844        let store = self
845            .store
846            .as_ref()
847            .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
848        let task_status = status_to_task_status(status);
849        self.release_dependents_if_done(task_status, repo, &ext_id.0)
850            .await;
851
852        let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
853        if task_status != TaskStatus::Blocked {
854            store.set_block_reason(store_id, None).await?;
855        }
856        store.update_status(store_id, task_status).await?;
857        self.publish_event(ext_id, status, &snapshot, repo, None);
858        Ok(())
859    }
860
861    /// Like [`Self::update_task_status_by_store_id`] but also sets additional result
862    /// fields atomically with the status transition (e.g. `block_reason`, `last_error`).
863    pub async fn update_task_status_and_result_by_store_id(
864        &self,
865        store_id: i64,
866        repo: &str,
867        ext_id: &ExternalId,
868        status: Status,
869        updates: &[(&str, serde_json::Value)],
870    ) -> anyhow::Result<()> {
871        let store = self
872            .store
873            .as_ref()
874            .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
875        let task_status = status_to_task_status(status);
876        self.release_dependents_if_done(task_status, repo, &ext_id.0)
877            .await;
878
879        let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
880        store
881            .update_status_and_fields(store_id, task_status, updates)
882            .await?;
883        self.publish_event(ext_id, status, &snapshot, repo, None);
884        Ok(())
885    }
886
887    /// List internal tasks by status from the store.
888    /// Returns store::Task items converted to ExternalTask for backward compatibility.
889    pub async fn list_internal_by_status(
890        &self,
891        status: TaskStatus,
892    ) -> anyhow::Result<Vec<ExternalTask>> {
893        let store = self
894            .store
895            .as_ref()
896            .ok_or_else(|| anyhow::anyhow!("store required"))?;
897        let tasks = store.list_internal_by_status(&self.repo, status).await?;
898        Ok(tasks
899            .into_iter()
900            .map(|t| store_task_to_external(&t))
901            .collect())
902    }
903
904    /// List all internal tasks from the store.
905    pub async fn list_all_internal(&self) -> anyhow::Result<Vec<store::Task>> {
906        let store = self
907            .store
908            .as_ref()
909            .ok_or_else(|| anyhow::anyhow!("store required"))?;
910        store.list_all_internal(&self.repo).await
911    }
912
913    pub async fn publish_task(&self, id: i64, labels: &[String]) -> anyhow::Result<ExternalId> {
914        let store = self
915            .store
916            .as_ref()
917            .ok_or_else(|| anyhow::anyhow!("store required for publish_task"))?;
918        let task = store.get(id).await?;
919        let ext_id = self
920            .backend
921            .create_task(&task.title, &task.body, labels)
922            .await?;
923        store.update_external_id(id, &ext_id.0).await?;
924        store.update_status(id, TaskStatus::New).await?;
925        Ok(ext_id)
926    }
927}
928
929/// Convert a store::Task to an ExternalTask for backward compatibility with
930/// code that expects ExternalTask (engine dispatch, review, etc.).
931pub fn store_task_to_external(t: &store::Task) -> ExternalTask {
932    let ext_id = t
933        .external_id
934        .clone()
935        .unwrap_or_else(|| format!("internal:{}", t.id));
936    ExternalTask {
937        id: ExternalId(ext_id),
938        title: t.title.clone(),
939        body: t.body.clone(),
940        state: "open".to_string(),
941        labels: {
942            let mut labels: Vec<String> = t
943                .labels
944                .iter()
945                .filter(|l| !l.starts_with("status:"))
946                .cloned()
947                .collect();
948            labels.push(format!("status:{}", t.status.as_str()));
949            labels
950        },
951        author: t.source.clone(),
952        created_at: t.created_at.clone(),
953        updated_at: t.updated_at.clone(),
954        url: t.url.clone(),
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use crate::backends::{ExternalId, ExternalTask, Mention};
962    use crate::store::{NewTask, TaskStore};
963    use async_trait::async_trait;
964    use std::sync::{Arc, Mutex};
965    use tokio::sync::broadcast;
966
967    type StatusTaskMap = Arc<Mutex<Vec<(Status, Vec<ExternalTask>)>>>;
968
969    /// Mock backend that records update_status calls.
970    struct MockBackend {
971        status_updates: Arc<Mutex<Vec<(String, Status)>>>,
972        status_tasks: StatusTaskMap,
973        routable_tasks: Arc<Mutex<Vec<ExternalTask>>>,
974    }
975
976    impl MockBackend {
977        fn new() -> Self {
978            Self {
979                status_updates: Arc::new(Mutex::new(vec![])),
980                status_tasks: Arc::new(Mutex::new(vec![])),
981                routable_tasks: Arc::new(Mutex::new(vec![])),
982            }
983        }
984
985        fn with_status_tasks(self, status: Status, tasks: Vec<ExternalTask>) -> Self {
986            self.status_tasks.lock().unwrap().push((status, tasks));
987            self
988        }
989
990        fn with_routable_tasks(self, tasks: Vec<ExternalTask>) -> Self {
991            *self.routable_tasks.lock().unwrap() = tasks;
992            self
993        }
994    }
995
996    #[async_trait]
997    impl ExternalBackend for MockBackend {
998        fn name(&self) -> &'static str {
999            "mock"
1000        }
1001        async fn create_task(
1002            &self,
1003            _t: &str,
1004            _b: &str,
1005            _l: &[String],
1006        ) -> anyhow::Result<ExternalId> {
1007            Ok(ExternalId("new".to_string()))
1008        }
1009        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
1010            Ok(ExternalTask {
1011                id: id.clone(),
1012                title: "Mock".to_string(),
1013                body: String::new(),
1014                state: "open".to_string(),
1015                labels: vec![],
1016                author: "bot".to_string(),
1017                created_at: "2026-01-01T00:00:00Z".to_string(),
1018                updated_at: "2026-01-01T00:00:00Z".to_string(),
1019                url: String::new(),
1020            })
1021        }
1022        async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1023            Ok(self
1024                .status_tasks
1025                .lock()
1026                .unwrap()
1027                .iter()
1028                .find(|(s, _)| *s == status)
1029                .map(|(_, tasks)| tasks.clone())
1030                .unwrap_or_default())
1031        }
1032        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1033            Ok(self.routable_tasks.lock().unwrap().clone())
1034        }
1035        async fn post_comment(&self, _id: &ExternalId, _b: &str) -> anyhow::Result<()> {
1036            Ok(())
1037        }
1038        async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
1039            Ok(())
1040        }
1041        async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
1042            Ok(())
1043        }
1044        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1045            Ok(vec![])
1046        }
1047        async fn create_sub_task(
1048            &self,
1049            _p: &ExternalId,
1050            _t: &str,
1051            _b: &str,
1052            _l: &[String],
1053        ) -> anyhow::Result<ExternalId> {
1054            Ok(ExternalId("child".to_string()))
1055        }
1056        async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
1057            Ok(())
1058        }
1059        async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
1060            Ok(false)
1061        }
1062        async fn health_check(&self) -> anyhow::Result<()> {
1063            Ok(())
1064        }
1065        async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
1066            Ok(false)
1067        }
1068        async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
1069            Ok(Some("testbot".to_string()))
1070        }
1071        async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
1072            Ok(vec![])
1073        }
1074        async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
1075            self.status_updates
1076                .lock()
1077                .unwrap()
1078                .push((id.0.clone(), status));
1079            Ok(())
1080        }
1081    }
1082
1083    #[tokio::test]
1084    async fn with_store_constructor_enables_dual_write() {
1085        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1086        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1087
1088        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1089
1090        assert!(tm.store.is_some(), "store should be set via with_store");
1091        assert_eq!(tm.repo, "owner/repo");
1092    }
1093
1094    #[tokio::test]
1095    async fn update_status_dual_writes_to_store_for_external_task() {
1096        let mock = MockBackend::new();
1097        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
1098        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1099
1100        // Pre-create the task in the store so resolve_task_id finds it
1101        store
1102            .create(&NewTask {
1103                external_id: Some("42".to_string()),
1104                repo: "owner/repo".to_string(),
1105                origin: "github".to_string(),
1106                title: "Test".to_string(),
1107                ..Default::default()
1108            })
1109            .await
1110            .unwrap();
1111
1112        let tm = TaskManager::with_store(
1113            Arc::clone(&backend),
1114            Arc::clone(&store),
1115            "owner/repo".to_string(),
1116        );
1117
1118        // Update status — should write to both backend and store
1119        tm.update_task_status(&ExternalId("42".to_string()), Status::InProgress)
1120            .await
1121            .unwrap();
1122
1123        // Verify the store side was updated
1124        let task = store.get(1).await.unwrap();
1125        assert_eq!(
1126            task.status,
1127            TaskStatus::InProgress,
1128            "store should have updated status"
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn update_status_skips_store_when_task_not_in_store() {
1134        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1135        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1136
1137        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1138
1139        // Update for a task not in the store — should not error
1140        let result = tm
1141            .update_task_status(&ExternalId("999".to_string()), Status::Done)
1142            .await;
1143        assert!(result.is_ok(), "should succeed even when task not in store");
1144    }
1145
1146    #[tokio::test]
1147    async fn update_status_works_without_store() {
1148        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1149
1150        // Use old constructor (no store)
1151        let tm = TaskManager::new(backend);
1152
1153        let result = tm
1154            .update_task_status(&ExternalId("42".to_string()), Status::Routed)
1155            .await;
1156        assert!(
1157            result.is_ok(),
1158            "should work without store (backward compat)"
1159        );
1160    }
1161
1162    #[tokio::test]
1163    async fn update_status_writes_to_store_for_internal_task() {
1164        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1165        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1166
1167        // Create an internal task in the store
1168        let internal_id = store
1169            .create_internal("owner/repo", "Internal task", "body", "cron", "job:1", None)
1170            .await
1171            .unwrap();
1172
1173        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1174
1175        // Update the internal task status via TaskManager
1176        let id_str = format!("internal:{internal_id}");
1177        tm.update_task_status(&ExternalId(id_str), Status::Routed)
1178            .await
1179            .unwrap();
1180
1181        // Verify the store was updated
1182        let task = store.get(internal_id).await.unwrap();
1183        assert_eq!(task.status, TaskStatus::Routed);
1184    }
1185
1186    #[tokio::test]
1187    async fn status_to_task_status_maps_all_variants() {
1188        assert_eq!(status_to_task_status(Status::New), TaskStatus::New);
1189        assert_eq!(status_to_task_status(Status::Routed), TaskStatus::Routed);
1190        assert_eq!(
1191            status_to_task_status(Status::InProgress),
1192            TaskStatus::InProgress
1193        );
1194        assert_eq!(status_to_task_status(Status::Done), TaskStatus::Done);
1195        assert_eq!(status_to_task_status(Status::Blocked), TaskStatus::Blocked);
1196        assert_eq!(
1197            status_to_task_status(Status::InReview),
1198            TaskStatus::InReview
1199        );
1200        assert_eq!(
1201            status_to_task_status(Status::NeedsReview),
1202            TaskStatus::NeedsReview
1203        );
1204    }
1205
1206    #[test]
1207    fn is_internal_id_detects_prefix() {
1208        assert!(is_internal_id("internal:5"));
1209        assert!(is_internal_id("internal:0"));
1210        assert!(!is_internal_id("42"));
1211        assert!(!is_internal_id(""));
1212        assert!(!is_internal_id("internal"));
1213    }
1214
1215    #[test]
1216    fn parse_internal_id_extracts_number() {
1217        assert_eq!(parse_internal_id("internal:5"), Some(5));
1218        assert_eq!(parse_internal_id("internal:0"), Some(0));
1219        assert_eq!(parse_internal_id("internal:abc"), None);
1220        assert_eq!(parse_internal_id("42"), None);
1221        assert_eq!(parse_internal_id(""), None);
1222    }
1223
1224    #[tokio::test]
1225    async fn create_internal_task_via_task_manager() {
1226        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1227        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1228        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1229
1230        let task = tm
1231            .create_task(CreateTaskRequest {
1232                title: "Internal task".to_string(),
1233                body: "Do something".to_string(),
1234                task_type: TaskType::Internal,
1235                labels: vec![],
1236                source: "cron".to_string(),
1237                source_id: "job:daily".to_string(),
1238            })
1239            .await
1240            .unwrap();
1241
1242        match task {
1243            Task::Internal(t) => {
1244                assert_eq!(t.title, "Internal task");
1245                assert_eq!(t.origin, "internal");
1246                assert!(
1247                    t.external_id
1248                        .as_deref()
1249                        .is_some_and(|s| s.starts_with("internal:"))
1250                );
1251            }
1252            Task::External(_) => panic!("expected Internal variant"),
1253        }
1254    }
1255
1256    #[tokio::test]
1257    async fn create_internal_task_fails_without_store() {
1258        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1259        let tm = TaskManager::new(backend);
1260
1261        let result = tm
1262            .create_task(CreateTaskRequest {
1263                title: "No store".to_string(),
1264                body: String::new(),
1265                task_type: TaskType::Internal,
1266                labels: vec![],
1267                source: "manual".to_string(),
1268                source_id: String::new(),
1269            })
1270            .await;
1271
1272        assert!(result.is_err(), "should fail without store");
1273    }
1274
1275    /// Regression test for the phantom-event bug: when an internal task ID is not found
1276    /// in the store, update_task_status must return Err and must NOT publish any event.
1277    #[tokio::test]
1278    async fn update_status_internal_task_not_found_returns_err_and_no_event() {
1279        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1280        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1281
1282        let (event_tx, mut event_rx) = broadcast::channel::<TaskEvent>(16);
1283
1284        let tm = TaskManager::with_events(
1285            backend,
1286            Arc::clone(&store),
1287            "owner/repo".to_string(),
1288            event_tx,
1289        );
1290
1291        // "internal:999" does not exist in the (empty) store
1292        let result = tm
1293            .update_task_status(&ExternalId("internal:999".to_string()), Status::Done)
1294            .await;
1295
1296        assert!(
1297            result.is_err(),
1298            "should return Err when internal task is not found in store"
1299        );
1300
1301        // No event must have been published
1302        assert!(
1303            event_rx.try_recv().is_err(),
1304            "no event should be published for a not-found internal task"
1305        );
1306    }
1307
1308    /// Regression test for #3437: `update_task_status` resolves by `self.repo`, so a
1309    /// `TaskManager` scoped to one repo cannot mark Done a task that belongs to a
1310    /// different repo — exactly the case the global cross-repo CI-failure unblock sweep
1311    /// hits. `mark_task_done_by_store_id` bypasses that repo-scoped resolution and must
1312    /// succeed where `update_task_status` fails.
1313    #[tokio::test]
1314    async fn mark_task_done_by_store_id_succeeds_across_repos() {
1315        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1316        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1317
1318        let store_id = store
1319            .create_internal(
1320                "other/repo",
1321                "Cross-repo task",
1322                "body",
1323                "cron",
1324                "job:1",
1325                None,
1326            )
1327            .await
1328            .unwrap();
1329        let ext_id = ExternalId(format!("internal:{store_id}"));
1330
1331        // TaskManager scoped to a different repo than the task belongs to.
1332        let tm = TaskManager::with_store(backend, Arc::clone(&store), "active/repo".to_string());
1333
1334        // The repo-scoped path fails to even find the task.
1335        let resolve_result = tm.update_task_status(&ext_id, Status::Done).await;
1336        assert!(
1337            resolve_result.is_err(),
1338            "repo-scoped update_task_status must fail for a cross-repo task"
1339        );
1340
1341        // The bypass path succeeds using the already-resolved store id + owning repo.
1342        tm.mark_task_done_by_store_id(store_id, "other/repo", &ext_id)
1343            .await
1344            .unwrap();
1345
1346        let task = store.get(store_id).await.unwrap();
1347        assert_eq!(task.status, TaskStatus::Done);
1348    }
1349
1350    #[tokio::test]
1351    async fn get_task_returns_internal_from_store() {
1352        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1353        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1354        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1355
1356        let id = store
1357            .create_internal("owner/repo", "Fetch me", "body", "cron", "job:1", None)
1358            .await
1359            .unwrap();
1360
1361        let task = tm.get_task(id).await.unwrap();
1362        match task {
1363            Task::Internal(t) => assert_eq!(t.title, "Fetch me"),
1364            Task::External(_) => panic!("expected Internal variant"),
1365        }
1366    }
1367
1368    #[tokio::test]
1369    async fn list_routable_includes_internal_new_tasks() {
1370        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1371        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1372        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1373
1374        // Create internal task (starts as New)
1375        store
1376            .create_internal("owner/repo", "Route me", "", "cron", "job:2", None)
1377            .await
1378            .unwrap();
1379
1380        let routable = tm.list_routable().await.unwrap();
1381        assert_eq!(routable.len(), 1);
1382        assert!(routable[0].id.0.starts_with("internal:"));
1383        assert_eq!(routable[0].title, "Route me");
1384    }
1385
1386    #[tokio::test]
1387    async fn list_routable_excludes_non_new_internal_tasks() {
1388        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1389        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1390        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1391
1392        let id = store
1393            .create_internal("owner/repo", "Already routed", "", "cron", "job:3", None)
1394            .await
1395            .unwrap();
1396        store
1397            .update_status(id, TaskStatus::InProgress)
1398            .await
1399            .unwrap();
1400
1401        let routable = tm.list_routable().await.unwrap();
1402        assert!(
1403            routable.is_empty(),
1404            "in_progress tasks should not be routable"
1405        );
1406    }
1407
1408    #[tokio::test]
1409    async fn list_internal_by_status_returns_external_tasks() {
1410        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1411        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1412        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1413
1414        store
1415            .create_internal("owner/repo", "Task A", "", "cron", "a", None)
1416            .await
1417            .unwrap();
1418        let id_b = store
1419            .create_internal("owner/repo", "Task B", "", "cron", "b", None)
1420            .await
1421            .unwrap();
1422        store.update_status(id_b, TaskStatus::Done).await.unwrap();
1423
1424        let new_tasks = tm.list_internal_by_status(TaskStatus::New).await.unwrap();
1425        assert_eq!(new_tasks.len(), 1);
1426        assert_eq!(new_tasks[0].title, "Task A");
1427        // Verify it's wrapped as ExternalTask with status label
1428        assert!(new_tasks[0].labels.contains(&"status:new".to_string()));
1429    }
1430
1431    #[tokio::test]
1432    async fn store_task_to_external_maps_fields_correctly() {
1433        let store = TaskStore::open_memory().await.unwrap();
1434        let id = store
1435            .create_internal(
1436                "owner/repo",
1437                "Convert me",
1438                "body text",
1439                "manual",
1440                "m:1",
1441                None,
1442            )
1443            .await
1444            .unwrap();
1445        let task = store.get(id).await.unwrap();
1446        let ext = store_task_to_external(&task);
1447
1448        assert!(ext.id.0.starts_with("internal:"));
1449        assert_eq!(ext.title, "Convert me");
1450        assert_eq!(ext.body, "body text");
1451        assert_eq!(ext.state, "open");
1452        assert!(ext.labels.contains(&"status:new".to_string()));
1453        assert_eq!(ext.author, "manual"); // source maps to author
1454    }
1455
1456    #[tokio::test]
1457    async fn list_all_internal_returns_store_tasks() {
1458        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1459        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1460        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1461
1462        store
1463            .create_internal("owner/repo", "T1", "", "cron", "1", None)
1464            .await
1465            .unwrap();
1466        store
1467            .create_internal("owner/repo", "T2", "", "cron", "2", None)
1468            .await
1469            .unwrap();
1470        // Different repo — should not appear
1471        store
1472            .create_internal("other/repo", "T3", "", "cron", "3", None)
1473            .await
1474            .unwrap();
1475
1476        let tasks = tm.list_all_internal().await.unwrap();
1477        assert_eq!(tasks.len(), 2);
1478    }
1479
1480    // ── Phase 4: store-first reads ───────────────────────────────────
1481
1482    #[tokio::test]
1483    async fn list_external_by_status_reads_from_store() {
1484        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1485        let backend = Arc::new(MockBackend::new());
1486        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1487
1488        // Create an external task in the store with status Routed
1489        let id = store
1490            .upsert_external(&crate::store::UpsertExternal {
1491                repo: "owner/repo",
1492                ext_id: "42",
1493                title: "Store task",
1494                body: "",
1495                author: "",
1496                url: "",
1497                labels: &[],
1498                origin: "github",
1499            })
1500            .await
1501            .unwrap();
1502        store.update_status(id, TaskStatus::Routed).await.unwrap();
1503
1504        let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1505        assert_eq!(routed.len(), 1);
1506        assert_eq!(routed[0].id.0, "42");
1507        assert_eq!(routed[0].title, "Store task");
1508    }
1509
1510    #[tokio::test]
1511    async fn list_external_by_status_excludes_internal_tasks() {
1512        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1513        let backend = Arc::new(MockBackend::new());
1514        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1515
1516        // Create an internal task with Routed status
1517        let id = store
1518            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1519            .await
1520            .unwrap();
1521        store.update_status(id, TaskStatus::Routed).await.unwrap();
1522
1523        // Also create an external task to populate the store
1524        store
1525            .upsert_external(&crate::store::UpsertExternal {
1526                repo: "owner/repo",
1527                ext_id: "99",
1528                title: "External",
1529                body: "",
1530                author: "",
1531                url: "",
1532                labels: &[],
1533                origin: "github",
1534            })
1535            .await
1536            .unwrap();
1537
1538        let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1539        // Should only include the internal task since it's status Routed, but filtered to external only
1540        assert_eq!(routed.len(), 0);
1541    }
1542
1543    #[tokio::test]
1544    async fn list_external_by_status_falls_back_when_store_has_only_internal_tasks() {
1545        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1546        let backend = Arc::new(MockBackend::new().with_status_tasks(
1547            Status::Routed,
1548            vec![ExternalTask {
1549                id: ExternalId("123".to_string()),
1550                title: "Backend routed".to_string(),
1551                body: String::new(),
1552                state: "open".to_string(),
1553                labels: vec!["status:routed".to_string()],
1554                author: "bot".to_string(),
1555                created_at: "2026-01-01T00:00:00Z".to_string(),
1556                updated_at: "2026-01-01T00:00:00Z".to_string(),
1557                url: String::new(),
1558            }],
1559        ));
1560        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1561
1562        let id = store
1563            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1564            .await
1565            .unwrap();
1566        store.update_status(id, TaskStatus::Routed).await.unwrap();
1567
1568        let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1569        assert_eq!(routed.len(), 1);
1570        assert_eq!(routed[0].id.0, "123");
1571    }
1572
1573    #[tokio::test]
1574    async fn list_routable_reads_from_store_when_populated() {
1575        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1576        let backend = Arc::new(MockBackend::new());
1577        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1578
1579        // Create a new external task
1580        store
1581            .upsert_external(&crate::store::UpsertExternal {
1582                repo: "owner/repo",
1583                ext_id: "10",
1584                title: "New external",
1585                body: "",
1586                author: "",
1587                url: "",
1588                labels: &[],
1589                origin: "github",
1590            })
1591            .await
1592            .unwrap();
1593        // Also a new internal task
1594        store
1595            .create_internal("owner/repo", "New internal", "", "cron", "1", None)
1596            .await
1597            .unwrap();
1598
1599        let routable = tm.list_routable().await.unwrap();
1600        // Both should appear (both are status=new)
1601        assert_eq!(routable.len(), 2);
1602    }
1603
1604    #[tokio::test]
1605    async fn list_routable_falls_back_when_store_has_only_internal_tasks() {
1606        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1607        let backend = Arc::new(MockBackend::new().with_routable_tasks(vec![ExternalTask {
1608            id: ExternalId("55".to_string()),
1609            title: "Backend new".to_string(),
1610            body: String::new(),
1611            state: "open".to_string(),
1612            labels: vec!["status:new".to_string()],
1613            author: "bot".to_string(),
1614            created_at: "2026-01-01T00:00:00Z".to_string(),
1615            updated_at: "2026-01-01T00:00:00Z".to_string(),
1616            url: String::new(),
1617        }]));
1618        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1619
1620        store
1621            .create_internal("owner/repo", "Internal new", "", "cron", "1", None)
1622            .await
1623            .unwrap();
1624
1625        let routable = tm.list_routable().await.unwrap();
1626        assert_eq!(routable.len(), 2);
1627        assert!(routable.iter().any(|task| task.id.0 == "55"));
1628        assert!(
1629            routable
1630                .iter()
1631                .any(|task| task.id.0.starts_with("internal:"))
1632        );
1633    }
1634
1635    #[tokio::test]
1636    async fn list_all_external_tasks_reads_from_store() {
1637        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1638        let backend = Arc::new(MockBackend::new());
1639        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1640
1641        // Create tasks with different statuses
1642        let id1 = store
1643            .upsert_external(&crate::store::UpsertExternal {
1644                repo: "owner/repo",
1645                ext_id: "1",
1646                title: "Task 1",
1647                body: "",
1648                author: "",
1649                url: "",
1650                labels: &[],
1651                origin: "github",
1652            })
1653            .await
1654            .unwrap();
1655        let id2 = store
1656            .upsert_external(&crate::store::UpsertExternal {
1657                repo: "owner/repo",
1658                ext_id: "2",
1659                title: "Task 2",
1660                body: "",
1661                author: "",
1662                url: "",
1663                labels: &[],
1664                origin: "github",
1665            })
1666            .await
1667            .unwrap();
1668        store.update_status(id1, TaskStatus::Done).await.unwrap();
1669        store
1670            .update_status(id2, TaskStatus::InProgress)
1671            .await
1672            .unwrap();
1673
1674        // Internal tasks should be excluded
1675        store
1676            .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1677            .await
1678            .unwrap();
1679
1680        let all = tm.list_all_external_tasks().await.unwrap();
1681        assert_eq!(all.len(), 2);
1682    }
1683
1684    #[tokio::test]
1685    async fn list_all_external_tasks_falls_back_when_store_has_only_internal_tasks() {
1686        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1687        let backend = Arc::new(MockBackend::new().with_status_tasks(
1688            Status::Done,
1689            vec![ExternalTask {
1690                id: ExternalId("200".to_string()),
1691                title: "Backend done".to_string(),
1692                body: String::new(),
1693                state: "closed".to_string(),
1694                labels: vec!["status:done".to_string()],
1695                author: "bot".to_string(),
1696                created_at: "2026-01-01T00:00:00Z".to_string(),
1697                updated_at: "2026-01-01T00:00:00Z".to_string(),
1698                url: String::new(),
1699            }],
1700        ));
1701        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1702
1703        store
1704            .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1705            .await
1706            .unwrap();
1707
1708        let all = tm.list_all_external_tasks().await.unwrap();
1709        assert_eq!(all.len(), 1);
1710        assert_eq!(all[0].id.0, "200");
1711    }
1712
1713    #[tokio::test]
1714    async fn list_tasks_without_status_merges_backend_external_when_store_only_has_internal() {
1715        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1716        let backend = Arc::new(MockBackend::new().with_status_tasks(
1717            Status::New,
1718            vec![ExternalTask {
1719                id: ExternalId("301".to_string()),
1720                title: "Backend new".to_string(),
1721                body: String::new(),
1722                state: "open".to_string(),
1723                labels: vec!["status:new".to_string()],
1724                author: "bot".to_string(),
1725                created_at: "2026-01-01T00:00:00Z".to_string(),
1726                updated_at: "2026-01-01T00:00:00Z".to_string(),
1727                url: String::new(),
1728            }],
1729        ));
1730        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1731
1732        let internal_id = store
1733            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1734            .await
1735            .unwrap();
1736
1737        let listed = tm.list_tasks(TaskFilter::default()).await.unwrap();
1738        assert_eq!(listed.len(), 2);
1739        assert!(listed.iter().any(|task| matches!(
1740            task,
1741            Task::External(external) if external.id.0 == "301"
1742        )));
1743        assert!(listed.iter().any(|task| matches!(
1744            task,
1745            Task::Internal(internal) if internal.id == internal_id
1746        )));
1747    }
1748
1749    #[tokio::test]
1750    async fn list_tasks_status_all_merges_backend_external_when_store_only_has_internal() {
1751        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1752        let backend = Arc::new(MockBackend::new().with_status_tasks(
1753            Status::Done,
1754            vec![ExternalTask {
1755                id: ExternalId("302".to_string()),
1756                title: "Backend done".to_string(),
1757                body: String::new(),
1758                state: "closed".to_string(),
1759                labels: vec!["status:done".to_string()],
1760                author: "bot".to_string(),
1761                created_at: "2026-01-01T00:00:00Z".to_string(),
1762                updated_at: "2026-01-01T00:00:00Z".to_string(),
1763                url: String::new(),
1764            }],
1765        ));
1766        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1767
1768        let internal_id = store
1769            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1770            .await
1771            .unwrap();
1772
1773        let listed = tm
1774            .list_tasks(TaskFilter {
1775                status: Some("all".to_string()),
1776                source: None,
1777            })
1778            .await
1779            .unwrap();
1780        assert_eq!(listed.len(), 2);
1781        assert!(listed.iter().any(|task| matches!(
1782            task,
1783            Task::External(external) if external.id.0 == "302"
1784        )));
1785        assert!(listed.iter().any(|task| matches!(
1786            task,
1787            Task::Internal(internal) if internal.id == internal_id
1788        )));
1789    }
1790
1791    // ── store-first update_task_status ──────────────────────────────
1792
1793    #[tokio::test]
1794    async fn update_task_status_writes_store_first_for_external() {
1795        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1796        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1797        let tm = TaskManager::with_store(
1798            Arc::clone(&backend),
1799            Arc::clone(&store),
1800            "owner/repo".to_string(),
1801        );
1802
1803        // Upsert an external task
1804        let store_id = store
1805            .upsert_external(&crate::store::UpsertExternal {
1806                repo: "owner/repo",
1807                ext_id: "42",
1808                title: "Test",
1809                body: "",
1810                author: "user",
1811                url: "",
1812                labels: &[],
1813                origin: "github",
1814            })
1815            .await
1816            .unwrap();
1817        assert_eq!(store.get(store_id).await.unwrap().status, TaskStatus::New);
1818
1819        // Update via task_manager — store should update first
1820        let ext_id = ExternalId("42".to_string());
1821        tm.update_task_status(&ext_id, Status::Routed)
1822            .await
1823            .unwrap();
1824
1825        // Store should now have Routed
1826        let task = store.get(store_id).await.unwrap();
1827        assert_eq!(task.status, TaskStatus::Routed);
1828    }
1829
1830    #[tokio::test]
1831    async fn update_task_status_handles_internal_tasks() {
1832        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1833        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1834        let tm = TaskManager::with_store(
1835            Arc::clone(&backend),
1836            Arc::clone(&store),
1837            "owner/repo".to_string(),
1838        );
1839
1840        // Create an internal task
1841        let store_id = store
1842            .create_internal("owner/repo", "task", "body", "manual", "", None)
1843            .await
1844            .unwrap();
1845
1846        let internal_id = ExternalId(format!("internal:{store_id}"));
1847        tm.update_task_status(&internal_id, Status::InProgress)
1848            .await
1849            .unwrap();
1850
1851        let task = store.get(store_id).await.unwrap();
1852        assert_eq!(task.status, TaskStatus::InProgress);
1853    }
1854
1855    #[tokio::test]
1856    async fn update_task_status_publishes_event() {
1857        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1858        let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
1859        let tm = TaskManager {
1860            backend,
1861            store: None,
1862            repo: "owner/repo".to_string(),
1863            event_tx: Some(tx),
1864        };
1865        // External task — backend mock accepts any status update
1866        let id = ExternalId("42".to_string());
1867        tm.update_task_status(&id, Status::Routed).await.unwrap();
1868        let event = rx.try_recv().unwrap();
1869        assert_eq!(event.task_id, "42");
1870        assert_eq!(event.new_status, "routed");
1871        assert_eq!(event.repo, "owner/repo");
1872    }
1873
1874    #[tokio::test]
1875    async fn event_includes_old_status_and_context() {
1876        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1877        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1878        let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
1879
1880        // Upsert an external task (starts as New)
1881        let store_id = store
1882            .upsert_external(&crate::store::UpsertExternal {
1883                repo: "owner/repo",
1884                ext_id: "77",
1885                title: "Context test",
1886                body: "",
1887                author: "user",
1888                url: "",
1889                labels: &[],
1890                origin: "github",
1891            })
1892            .await
1893            .unwrap();
1894
1895        // Set some context fields on the task
1896        store
1897            .set_fields(
1898                store_id,
1899                &[
1900                    ("agent", serde_json::Value::String("claude".to_string())),
1901                    ("model", serde_json::Value::String("sonnet".to_string())),
1902                    ("branch", serde_json::Value::String("feat-77".to_string())),
1903                    ("pr_number", serde_json::Value::Number(42.into())),
1904                    (
1905                        "last_error",
1906                        serde_json::Value::String("timeout".to_string()),
1907                    ),
1908                ],
1909            )
1910            .await
1911            .unwrap();
1912
1913        let tm =
1914            TaskManager::with_events(backend, Arc::clone(&store), "owner/repo".to_string(), tx);
1915
1916        // Update New → Routed
1917        let ext_id = ExternalId("77".to_string());
1918        tm.update_task_status(&ext_id, Status::Routed)
1919            .await
1920            .unwrap();
1921
1922        let event = rx.try_recv().unwrap();
1923        assert_eq!(event.task_id, "77");
1924        assert_eq!(event.old_status, "new");
1925        assert_eq!(event.new_status, "routed");
1926        assert_eq!(event.agent.as_deref(), Some("claude"));
1927        assert_eq!(event.model.as_deref(), Some("sonnet"));
1928        assert_eq!(event.branch.as_deref(), Some("feat-77"));
1929        assert_eq!(event.pr_number.as_deref(), Some("42"));
1930        assert_eq!(event.error.as_deref(), Some("timeout"));
1931    }
1932
1933    #[tokio::test]
1934    async fn event_includes_old_status_for_internal_task() {
1935        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1936        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1937        let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
1938
1939        // Create an internal task (starts as New)
1940        let store_id = store
1941            .create_internal(
1942                "owner/repo",
1943                "internal ctx test",
1944                "body",
1945                "manual",
1946                "",
1947                None,
1948            )
1949            .await
1950            .unwrap();
1951
1952        let tm =
1953            TaskManager::with_events(backend, Arc::clone(&store), "owner/repo".to_string(), tx);
1954
1955        // Update New → Routed
1956        let internal_id = ExternalId(format!("internal:{store_id}"));
1957        tm.update_task_status(&internal_id, Status::Routed)
1958            .await
1959            .unwrap();
1960
1961        let event = rx.try_recv().unwrap();
1962        assert_eq!(event.old_status, "new");
1963        assert_eq!(event.new_status, "routed");
1964
1965        // Update Routed → InProgress
1966        tm.update_task_status(&internal_id, Status::InProgress)
1967            .await
1968            .unwrap();
1969
1970        let event2 = rx.try_recv().unwrap();
1971        assert_eq!(event2.old_status, "routed");
1972        assert_eq!(event2.new_status, "in_progress");
1973    }
1974
1975    // ── update_task_status_if ────────────────────────────────────────
1976
1977    #[tokio::test]
1978    async fn update_task_status_if_applies_when_status_matches() {
1979        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1980        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1981        let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1982
1983        let store_id = store
1984            .upsert_external(&crate::store::UpsertExternal {
1985                repo: "owner/repo",
1986                ext_id: "10",
1987                title: "T",
1988                body: "",
1989                author: "u",
1990                url: "",
1991                labels: &[],
1992                origin: "github",
1993            })
1994            .await
1995            .unwrap();
1996        // Task starts as New; move it to InReview first
1997        store
1998            .update_status(store_id, TaskStatus::InReview)
1999            .await
2000            .unwrap();
2001
2002        let id = ExternalId("10".to_string());
2003        let updated = tm
2004            .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
2005            .await
2006            .unwrap();
2007        assert!(updated, "should update when expected status matches");
2008
2009        let task = store.get(store_id).await.unwrap();
2010        assert_eq!(task.status, TaskStatus::NeedsReview);
2011    }
2012
2013    #[tokio::test]
2014    async fn update_task_status_if_is_noop_when_status_mismatches() {
2015        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2016        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
2017        let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
2018        let tm =
2019            TaskManager::with_events(backend, Arc::clone(&store), "owner/repo".to_string(), tx);
2020
2021        let store_id = store
2022            .upsert_external(&crate::store::UpsertExternal {
2023                repo: "owner/repo",
2024                ext_id: "11",
2025                title: "T",
2026                body: "",
2027                author: "u",
2028                url: "",
2029                labels: &[],
2030                origin: "github",
2031            })
2032            .await
2033            .unwrap();
2034        // Simulate a concurrent transition: task already moved to Done
2035        store
2036            .update_status(store_id, TaskStatus::Done)
2037            .await
2038            .unwrap();
2039
2040        let id = ExternalId("11".to_string());
2041        // Attempt to reset to NeedsReview expecting InReview — should be a no-op
2042        let updated = tm
2043            .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
2044            .await
2045            .unwrap();
2046        assert!(
2047            !updated,
2048            "should not update when status has already changed"
2049        );
2050
2051        // Status must remain Done
2052        let task = store.get(store_id).await.unwrap();
2053        assert_eq!(task.status, TaskStatus::Done);
2054
2055        // No event must be published
2056        assert!(
2057            rx.try_recv().is_err(),
2058            "no event should be published on a no-op update"
2059        );
2060    }
2061}