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