Skip to main content

orch/engine/
tasks.rs

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