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
465        // Read pre-update snapshot from the store (old_status + context fields).
466        // This must happen BEFORE any store.update_status() call so we capture
467        // the previous state for the event.
468        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
469
470        if is_internal_id(&id.0) {
471            // Internal tasks: store is the single source of truth
472            let store = self
473                .store
474                .as_ref()
475                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
476            let store_id = snapshot_store_id
477                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
478            if task_status != TaskStatus::Blocked {
479                store.set_block_reason(store_id, None).await?;
480            }
481            store.update_status(store_id, task_status).await?;
482            // Publish event to bus only after confirmed update
483            self.publish_event(id, status, &pre_snapshot, &self.repo, None);
484            return Ok(());
485        }
486
487        // External tasks: store-first, then mirror to backend.
488        // SQLite is the source of truth; backend sync is best-effort.
489        if let Some(ref store) = self.store {
490            if let Some(store_id) = snapshot_store_id {
491                if task_status != TaskStatus::Blocked {
492                    store.set_block_reason(store_id, None).await?;
493                }
494                store.update_status(store_id, task_status).await?;
495            }
496        }
497
498        // Mirror to backend (GitHub labels). Log failure but don't fail the operation
499        // since the store already has the correct status.
500        if let Err(e) = self.backend.update_status(id, status).await {
501            tracing::warn!(
502                task_id = id.0,
503                ?status,
504                err = %e,
505                "failed to mirror status to backend — store is authoritative"
506            );
507        }
508
509        // Publish event to bus
510        self.publish_event(id, status, &pre_snapshot, &self.repo, None);
511
512        Ok(())
513    }
514
515    pub async fn update_task_status_and_result(
516        &self,
517        id: &ExternalId,
518        status: Status,
519        updates: &[(&str, serde_json::Value)],
520    ) -> anyhow::Result<()> {
521        let task_status = status_to_task_status(status);
522
523        // Read pre-update snapshot from the store (old_status + context fields).
524        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
525
526        if is_internal_id(&id.0) {
527            // Internal tasks: store is the single source of truth
528            let store = self
529                .store
530                .as_ref()
531                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
532            let store_id = snapshot_store_id
533                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
534
535            store
536                .update_status_and_fields(store_id, task_status, updates)
537                .await?;
538            // Publish event to bus only after confirmed update
539            self.publish_event(id, status, &pre_snapshot, &self.repo, None);
540            return Ok(());
541        }
542
543        // External tasks: store-first, then mirror to backend.
544        if let Some(ref store) = self.store {
545            if let Some(store_id) = snapshot_store_id {
546                store
547                    .update_status_and_fields(store_id, task_status, updates)
548                    .await?;
549            }
550        }
551
552        // Mirror to backend (GitHub labels).
553        if let Err(e) = self.backend.update_status(id, status).await {
554            tracing::warn!(
555                task_id = id.0,
556                ?status,
557                err = %e,
558                "failed to mirror status to backend — store is authoritative"
559            );
560        }
561
562        // Publish event to bus
563        self.publish_event(id, status, &pre_snapshot, &self.repo, None);
564
565        Ok(())
566    }
567
568    /// Update the status of a task only if it is currently in `expected_status`.
569    ///
570    /// Returns `Ok(true)` if the update was applied, `Ok(false)` if the task had
571    /// already transitioned to a different status (TOCTOU-safe — no-op on race).
572    pub async fn update_task_status_if(
573        &self,
574        id: &ExternalId,
575        status: Status,
576        expected_status: Status,
577    ) -> anyhow::Result<bool> {
578        let task_status = status_to_task_status(status);
579        let expected_task_status = status_to_task_status(expected_status);
580
581        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
582
583        if is_internal_id(&id.0) {
584            let store = self
585                .store
586                .as_ref()
587                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
588            let store_id = snapshot_store_id
589                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
590            let updated = store
591                .update_status_if(store_id, task_status, expected_task_status)
592                .await?;
593            if updated {
594                self.publish_event(id, status, &pre_snapshot, &self.repo, None);
595            }
596            return Ok(updated);
597        }
598
599        // External tasks: conditional store update, then mirror to backend.
600        // The conditional guarantee (update only when current == expected) relies on
601        // having a local store record. If the unified store isn't available or the
602        // task has no store record (e.g. not yet synced), we cannot safely perform
603        // the conditional update — return false and warn so callers treat this as
604        // a no-op instead of assuming the update succeeded.
605        if let Some(ref store) = self.store {
606            if let Some(store_id) = snapshot_store_id {
607                let updated = store
608                    .update_status_if(store_id, task_status, expected_task_status)
609                    .await?;
610                if !updated {
611                    return Ok(false);
612                }
613            } else {
614                tracing::warn!(
615                    task_id = id.0,
616                    ?expected_task_status,
617                    "update_task_status_if: external task has no store record — cannot perform conditional update"
618                );
619                return Ok(false);
620            }
621        } else {
622            tracing::warn!(
623                task_id = id.0,
624                ?expected_task_status,
625                "update_task_status_if: no store available — cannot perform conditional update for external task"
626            );
627            return Ok(false);
628        }
629
630        if let Err(e) = self.backend.update_status(id, status).await {
631            tracing::warn!(
632                task_id = id.0,
633                ?status,
634                err = %e,
635                "failed to mirror status to backend — store is authoritative"
636            );
637        }
638
639        self.publish_event(id, status, &pre_snapshot, &self.repo, None);
640        Ok(true)
641    }
642
643    /// Update the status of a task and include elapsed duration in the event.
644    ///
645    /// Use this at task completion points (success or failure) so the notify
646    /// subscriber can include accurate timing in channel messages instead of 0.0.
647    pub async fn update_task_status_with_duration(
648        &self,
649        id: &ExternalId,
650        status: Status,
651        duration_seconds: Option<f64>,
652    ) -> anyhow::Result<()> {
653        let task_status = status_to_task_status(status);
654        let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
655
656        if is_internal_id(&id.0) {
657            let store = self
658                .store
659                .as_ref()
660                .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
661            let store_id = snapshot_store_id
662                .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
663            if task_status != TaskStatus::Blocked {
664                store.set_block_reason(store_id, None).await?;
665            }
666            store.update_status(store_id, task_status).await?;
667            self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
668            return Ok(());
669        }
670
671        if let Some(ref store) = self.store {
672            if let Some(store_id) = snapshot_store_id {
673                if task_status != TaskStatus::Blocked {
674                    store.set_block_reason(store_id, None).await?;
675                }
676                store.update_status(store_id, task_status).await?;
677            }
678        }
679
680        if let Err(e) = self.backend.update_status(id, status).await {
681            tracing::warn!(
682                task_id = id.0,
683                ?status,
684                err = %e,
685                "failed to mirror status to backend — store is authoritative"
686            );
687        }
688
689        self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
690
691        Ok(())
692    }
693
694    /// Read the current task snapshot from the store for event enrichment.
695    /// Returns `(snapshot, store_id)` — store_id is `Some` when the task was found,
696    /// so callers can reuse it for subsequent store operations without a second lookup.
697    async fn read_task_snapshot(&self, task_id: &str) -> (TaskSnapshot, Option<i64>) {
698        let Some(ref store) = self.store else {
699            return (TaskSnapshot::default(), None);
700        };
701        let store_id = match store.resolve_task_id(&self.repo, task_id).await {
702            Ok(Some(id)) => id,
703            _ => return (TaskSnapshot::default(), None),
704        };
705        let snapshot = Self::snapshot_for_store_id(store, store_id, task_id).await;
706        (snapshot, Some(store_id))
707    }
708
709    /// Build a `TaskSnapshot` for an already-resolved numeric store id, without
710    /// going through `resolve_task_id`. `metric_task_id` is the external id string
711    /// used as the key in `task_metrics` for duration lookup.
712    async fn snapshot_for_store_id(
713        store: &TaskStore,
714        store_id: i64,
715        metric_task_id: &str,
716    ) -> TaskSnapshot {
717        match store.get(store_id).await {
718            Ok(task) => {
719                let duration_seconds = store.latest_task_metric_duration(metric_task_id).await;
720                TaskSnapshot {
721                    old_status: Some(task.status.as_str().to_string()),
722                    agent: task.agent.clone(),
723                    model: task.model.clone(),
724                    branch: if task.branch.is_empty() {
725                        None
726                    } else {
727                        Some(task.branch.clone())
728                    },
729                    pr_number: task.pr_number.map(|n| n.to_string()),
730                    error: if task.last_error.is_empty() {
731                        None
732                    } else {
733                        Some(task.last_error.clone())
734                    },
735                    title: if task.title.is_empty() {
736                        None
737                    } else {
738                        Some(task.title.clone())
739                    },
740                    summary: if task.summary.is_empty() {
741                        None
742                    } else {
743                        Some(task.summary.clone())
744                    },
745                    duration_seconds,
746                }
747            }
748            Err(e) => {
749                tracing::warn!(
750                    task_id = store_id,
751                    err = %e,
752                    "failed to read task snapshot for event enrichment — event context will be empty"
753                );
754                TaskSnapshot::default()
755            }
756        }
757    }
758
759    /// Publish a TaskEvent to the event bus (if wired).
760    fn publish_event(
761        &self,
762        id: &ExternalId,
763        status: Status,
764        snapshot: &TaskSnapshot,
765        repo: &str,
766        duration_seconds: Option<f64>,
767    ) {
768        if let Some(ref tx) = self.event_tx {
769            let event = crate::engine::events::TaskEvent {
770                task_id: id.0.clone(),
771                repo: repo.to_string(),
772                old_status: snapshot.old_status.clone().unwrap_or_default(),
773                new_status: status.as_label().trim_start_matches("status:").to_string(),
774                agent: snapshot.agent.clone(),
775                model: snapshot.model.clone(),
776                pr_number: snapshot.pr_number.clone(),
777                branch: snapshot.branch.clone(),
778                review_context: None,
779                error: snapshot.error.clone(),
780                timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
781                title: snapshot.title.clone(),
782                summary: snapshot.summary.clone(),
783                duration_seconds: duration_seconds.or(snapshot.duration_seconds),
784            };
785            let _ = tx.send(event);
786        }
787    }
788
789    /// Mark an already-resolved task Done directly by its numeric store id and owning
790    /// repo, bypassing the repo-scoped `resolve_task_id` lookup that `self.repo` would
791    /// otherwise apply. For callers that already hold the correct store row from a
792    /// cross-repo query — e.g. the global CI-failure unblock sweep — and would otherwise
793    /// fail to resolve a task belonging to a different repo than this `TaskManager`.
794    pub async fn mark_task_done_by_store_id(
795        &self,
796        store_id: i64,
797        repo: &str,
798        ext_id: &ExternalId,
799    ) -> anyhow::Result<()> {
800        self.update_task_status_by_store_id(store_id, repo, ext_id, Status::Done)
801            .await
802    }
803
804    /// Update a task's status directly by its already-resolved numeric store id and
805    /// owning repo, bypassing the repo-scoped `resolve_task_id` lookup that `self.repo`
806    /// would otherwise apply. For callers that already hold the correct store row from a
807    /// cross-repo query — e.g. a global sweep over tasks from inactive/removed repos —
808    /// and would otherwise fail to resolve a task belonging to a different repo than this
809    /// `TaskManager`. Does not mirror to the backend (GitHub), since the caller's backend
810    /// instance may not correspond to the task's owning repo.
811    pub async fn update_task_status_by_store_id(
812        &self,
813        store_id: i64,
814        repo: &str,
815        ext_id: &ExternalId,
816        status: Status,
817    ) -> anyhow::Result<()> {
818        let store = self
819            .store
820            .as_ref()
821            .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
822        let task_status = status_to_task_status(status);
823
824        let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
825        if task_status != TaskStatus::Blocked {
826            store.set_block_reason(store_id, None).await?;
827        }
828        store.update_status(store_id, task_status).await?;
829        self.publish_event(ext_id, status, &snapshot, repo, None);
830        Ok(())
831    }
832
833    /// Like [`Self::update_task_status_by_store_id`] but also sets additional result
834    /// fields atomically with the status transition (e.g. `block_reason`, `last_error`).
835    pub async fn update_task_status_and_result_by_store_id(
836        &self,
837        store_id: i64,
838        repo: &str,
839        ext_id: &ExternalId,
840        status: Status,
841        updates: &[(&str, serde_json::Value)],
842    ) -> anyhow::Result<()> {
843        let store = self
844            .store
845            .as_ref()
846            .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
847        let task_status = status_to_task_status(status);
848
849        let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
850        store
851            .update_status_and_fields(store_id, task_status, updates)
852            .await?;
853        self.publish_event(ext_id, status, &snapshot, repo, None);
854        Ok(())
855    }
856
857    /// List internal tasks by status from the store.
858    /// Returns store::Task items converted to ExternalTask for backward compatibility.
859    pub async fn list_internal_by_status(
860        &self,
861        status: TaskStatus,
862    ) -> anyhow::Result<Vec<ExternalTask>> {
863        let store = self
864            .store
865            .as_ref()
866            .ok_or_else(|| anyhow::anyhow!("store required"))?;
867        let tasks = store.list_internal_by_status(&self.repo, status).await?;
868        Ok(tasks
869            .into_iter()
870            .map(|t| store_task_to_external(&t))
871            .collect())
872    }
873
874    /// List all internal tasks from the store.
875    pub async fn list_all_internal(&self) -> anyhow::Result<Vec<store::Task>> {
876        let store = self
877            .store
878            .as_ref()
879            .ok_or_else(|| anyhow::anyhow!("store required"))?;
880        store.list_all_internal(&self.repo).await
881    }
882
883    pub async fn publish_task(&self, id: i64, labels: &[String]) -> anyhow::Result<ExternalId> {
884        let store = self
885            .store
886            .as_ref()
887            .ok_or_else(|| anyhow::anyhow!("store required for publish_task"))?;
888        let task = store.get(id).await?;
889        let ext_id = self
890            .backend
891            .create_task(&task.title, &task.body, labels)
892            .await?;
893        store.update_external_id(id, &ext_id.0).await?;
894        store.update_status(id, TaskStatus::New).await?;
895        Ok(ext_id)
896    }
897}
898
899/// Convert a store::Task to an ExternalTask for backward compatibility with
900/// code that expects ExternalTask (engine dispatch, review, etc.).
901pub fn store_task_to_external(t: &store::Task) -> ExternalTask {
902    let ext_id = t
903        .external_id
904        .clone()
905        .unwrap_or_else(|| format!("internal:{}", t.id));
906    ExternalTask {
907        id: ExternalId(ext_id),
908        title: t.title.clone(),
909        body: t.body.clone(),
910        state: "open".to_string(),
911        labels: {
912            let mut labels: Vec<String> = t
913                .labels
914                .iter()
915                .filter(|l| !l.starts_with("status:"))
916                .cloned()
917                .collect();
918            labels.push(format!("status:{}", t.status.as_str()));
919            labels
920        },
921        author: t.source.clone(),
922        created_at: t.created_at.clone(),
923        updated_at: t.updated_at.clone(),
924        url: t.url.clone(),
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931    use crate::backends::{ExternalId, ExternalTask, Mention};
932    use crate::store::{NewTask, TaskStore};
933    use async_trait::async_trait;
934    use std::sync::{Arc, Mutex};
935
936    type StatusTaskMap = Arc<Mutex<Vec<(Status, Vec<ExternalTask>)>>>;
937
938    /// Mock backend that records update_status calls.
939    struct MockBackend {
940        status_updates: Arc<Mutex<Vec<(String, Status)>>>,
941        status_tasks: StatusTaskMap,
942        routable_tasks: Arc<Mutex<Vec<ExternalTask>>>,
943    }
944
945    impl MockBackend {
946        fn new() -> Self {
947            Self {
948                status_updates: Arc::new(Mutex::new(vec![])),
949                status_tasks: Arc::new(Mutex::new(vec![])),
950                routable_tasks: Arc::new(Mutex::new(vec![])),
951            }
952        }
953
954        fn with_status_tasks(self, status: Status, tasks: Vec<ExternalTask>) -> Self {
955            self.status_tasks.lock().unwrap().push((status, tasks));
956            self
957        }
958
959        fn with_routable_tasks(self, tasks: Vec<ExternalTask>) -> Self {
960            *self.routable_tasks.lock().unwrap() = tasks;
961            self
962        }
963    }
964
965    #[async_trait]
966    impl ExternalBackend for MockBackend {
967        fn name(&self) -> &str {
968            "mock"
969        }
970        async fn create_task(
971            &self,
972            _t: &str,
973            _b: &str,
974            _l: &[String],
975        ) -> anyhow::Result<ExternalId> {
976            Ok(ExternalId("new".to_string()))
977        }
978        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
979            Ok(ExternalTask {
980                id: id.clone(),
981                title: "Mock".to_string(),
982                body: "".to_string(),
983                state: "open".to_string(),
984                labels: vec![],
985                author: "bot".to_string(),
986                created_at: "2026-01-01T00:00:00Z".to_string(),
987                updated_at: "2026-01-01T00:00:00Z".to_string(),
988                url: "".to_string(),
989            })
990        }
991        async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
992            Ok(self
993                .status_tasks
994                .lock()
995                .unwrap()
996                .iter()
997                .find(|(s, _)| *s == status)
998                .map(|(_, tasks)| tasks.clone())
999                .unwrap_or_default())
1000        }
1001        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1002            Ok(self.routable_tasks.lock().unwrap().clone())
1003        }
1004        async fn post_comment(&self, _id: &ExternalId, _b: &str) -> anyhow::Result<()> {
1005            Ok(())
1006        }
1007        async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
1008            Ok(())
1009        }
1010        async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
1011            Ok(())
1012        }
1013        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1014            Ok(vec![])
1015        }
1016        async fn create_sub_task(
1017            &self,
1018            _p: &ExternalId,
1019            _t: &str,
1020            _b: &str,
1021            _l: &[String],
1022        ) -> anyhow::Result<ExternalId> {
1023            Ok(ExternalId("child".to_string()))
1024        }
1025        async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
1026            Ok(())
1027        }
1028        async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
1029            Ok(false)
1030        }
1031        async fn health_check(&self) -> anyhow::Result<()> {
1032            Ok(())
1033        }
1034        async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
1035            Ok(false)
1036        }
1037        async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
1038            Ok(Some("testbot".to_string()))
1039        }
1040        async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
1041            Ok(vec![])
1042        }
1043        async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
1044            self.status_updates
1045                .lock()
1046                .unwrap()
1047                .push((id.0.clone(), status));
1048            Ok(())
1049        }
1050    }
1051
1052    #[tokio::test]
1053    async fn with_store_constructor_enables_dual_write() {
1054        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1055        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1056
1057        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1058
1059        assert!(tm.store.is_some(), "store should be set via with_store");
1060        assert_eq!(tm.repo, "owner/repo");
1061    }
1062
1063    #[tokio::test]
1064    async fn update_status_dual_writes_to_store_for_external_task() {
1065        let mock = MockBackend::new();
1066        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
1067        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1068
1069        // Pre-create the task in the store so resolve_task_id finds it
1070        store
1071            .create(&NewTask {
1072                external_id: Some("42".to_string()),
1073                repo: "owner/repo".to_string(),
1074                origin: "github".to_string(),
1075                title: "Test".to_string(),
1076                ..Default::default()
1077            })
1078            .await
1079            .unwrap();
1080
1081        let tm = TaskManager::with_store(backend.clone(), store.clone(), "owner/repo".to_string());
1082
1083        // Update status — should write to both backend and store
1084        tm.update_task_status(&ExternalId("42".to_string()), Status::InProgress)
1085            .await
1086            .unwrap();
1087
1088        // Verify the store side was updated
1089        let task = store.get(1).await.unwrap();
1090        assert_eq!(
1091            task.status,
1092            TaskStatus::InProgress,
1093            "store should have updated status"
1094        );
1095    }
1096
1097    #[tokio::test]
1098    async fn update_status_skips_store_when_task_not_in_store() {
1099        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1100        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1101
1102        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1103
1104        // Update for a task not in the store — should not error
1105        let result = tm
1106            .update_task_status(&ExternalId("999".to_string()), Status::Done)
1107            .await;
1108        assert!(result.is_ok(), "should succeed even when task not in store");
1109    }
1110
1111    #[tokio::test]
1112    async fn update_status_works_without_store() {
1113        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1114
1115        // Use old constructor (no store)
1116        let tm = TaskManager::new(backend);
1117
1118        let result = tm
1119            .update_task_status(&ExternalId("42".to_string()), Status::Routed)
1120            .await;
1121        assert!(
1122            result.is_ok(),
1123            "should work without store (backward compat)"
1124        );
1125    }
1126
1127    #[tokio::test]
1128    async fn update_status_writes_to_store_for_internal_task() {
1129        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1130        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1131
1132        // Create an internal task in the store
1133        let internal_id = store
1134            .create_internal("owner/repo", "Internal task", "body", "cron", "job:1", None)
1135            .await
1136            .unwrap();
1137
1138        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1139
1140        // Update the internal task status via TaskManager
1141        let id_str = format!("internal:{}", internal_id);
1142        tm.update_task_status(&ExternalId(id_str), Status::Routed)
1143            .await
1144            .unwrap();
1145
1146        // Verify the store was updated
1147        let task = store.get(internal_id).await.unwrap();
1148        assert_eq!(task.status, TaskStatus::Routed);
1149    }
1150
1151    #[tokio::test]
1152    async fn status_to_task_status_maps_all_variants() {
1153        assert_eq!(status_to_task_status(Status::New), TaskStatus::New);
1154        assert_eq!(status_to_task_status(Status::Routed), TaskStatus::Routed);
1155        assert_eq!(
1156            status_to_task_status(Status::InProgress),
1157            TaskStatus::InProgress
1158        );
1159        assert_eq!(status_to_task_status(Status::Done), TaskStatus::Done);
1160        assert_eq!(status_to_task_status(Status::Blocked), TaskStatus::Blocked);
1161        assert_eq!(
1162            status_to_task_status(Status::InReview),
1163            TaskStatus::InReview
1164        );
1165        assert_eq!(
1166            status_to_task_status(Status::NeedsReview),
1167            TaskStatus::NeedsReview
1168        );
1169    }
1170
1171    #[test]
1172    fn is_internal_id_detects_prefix() {
1173        assert!(is_internal_id("internal:5"));
1174        assert!(is_internal_id("internal:0"));
1175        assert!(!is_internal_id("42"));
1176        assert!(!is_internal_id(""));
1177        assert!(!is_internal_id("internal"));
1178    }
1179
1180    #[test]
1181    fn parse_internal_id_extracts_number() {
1182        assert_eq!(parse_internal_id("internal:5"), Some(5));
1183        assert_eq!(parse_internal_id("internal:0"), Some(0));
1184        assert_eq!(parse_internal_id("internal:abc"), None);
1185        assert_eq!(parse_internal_id("42"), None);
1186        assert_eq!(parse_internal_id(""), None);
1187    }
1188
1189    #[tokio::test]
1190    async fn create_internal_task_via_task_manager() {
1191        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1192        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1193        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1194
1195        let task = tm
1196            .create_task(CreateTaskRequest {
1197                title: "Internal task".to_string(),
1198                body: "Do something".to_string(),
1199                task_type: TaskType::Internal,
1200                labels: vec![],
1201                source: "cron".to_string(),
1202                source_id: "job:daily".to_string(),
1203            })
1204            .await
1205            .unwrap();
1206
1207        match task {
1208            Task::Internal(t) => {
1209                assert_eq!(t.title, "Internal task");
1210                assert_eq!(t.origin, "internal");
1211                assert!(t
1212                    .external_id
1213                    .as_deref()
1214                    .map(|s| s.starts_with("internal:"))
1215                    .unwrap_or(false));
1216            }
1217            Task::External(_) => panic!("expected Internal variant"),
1218        }
1219    }
1220
1221    #[tokio::test]
1222    async fn create_internal_task_fails_without_store() {
1223        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1224        let tm = TaskManager::new(backend);
1225
1226        let result = tm
1227            .create_task(CreateTaskRequest {
1228                title: "No store".to_string(),
1229                body: "".to_string(),
1230                task_type: TaskType::Internal,
1231                labels: vec![],
1232                source: "manual".to_string(),
1233                source_id: "".to_string(),
1234            })
1235            .await;
1236
1237        assert!(result.is_err(), "should fail without store");
1238    }
1239
1240    /// Regression test for the phantom-event bug: when an internal task ID is not found
1241    /// in the store, update_task_status must return Err and must NOT publish any event.
1242    #[tokio::test]
1243    async fn update_status_internal_task_not_found_returns_err_and_no_event() {
1244        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1245        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1246
1247        let (event_tx, mut event_rx) =
1248            tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1249
1250        let tm =
1251            TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), event_tx);
1252
1253        // "internal:999" does not exist in the (empty) store
1254        let result = tm
1255            .update_task_status(&ExternalId("internal:999".to_string()), Status::Done)
1256            .await;
1257
1258        assert!(
1259            result.is_err(),
1260            "should return Err when internal task is not found in store"
1261        );
1262
1263        // No event must have been published
1264        assert!(
1265            event_rx.try_recv().is_err(),
1266            "no event should be published for a not-found internal task"
1267        );
1268    }
1269
1270    /// Regression test for #3437: `update_task_status` resolves by `self.repo`, so a
1271    /// `TaskManager` scoped to one repo cannot mark Done a task that belongs to a
1272    /// different repo — exactly the case the global cross-repo CI-failure unblock sweep
1273    /// hits. `mark_task_done_by_store_id` bypasses that repo-scoped resolution and must
1274    /// succeed where `update_task_status` fails.
1275    #[tokio::test]
1276    async fn mark_task_done_by_store_id_succeeds_across_repos() {
1277        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1278        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1279
1280        let store_id = store
1281            .create_internal(
1282                "other/repo",
1283                "Cross-repo task",
1284                "body",
1285                "cron",
1286                "job:1",
1287                None,
1288            )
1289            .await
1290            .unwrap();
1291        let ext_id = ExternalId(format!("internal:{store_id}"));
1292
1293        // TaskManager scoped to a different repo than the task belongs to.
1294        let tm = TaskManager::with_store(backend, store.clone(), "active/repo".to_string());
1295
1296        // The repo-scoped path fails to even find the task.
1297        let resolve_result = tm.update_task_status(&ext_id, Status::Done).await;
1298        assert!(
1299            resolve_result.is_err(),
1300            "repo-scoped update_task_status must fail for a cross-repo task"
1301        );
1302
1303        // The bypass path succeeds using the already-resolved store id + owning repo.
1304        tm.mark_task_done_by_store_id(store_id, "other/repo", &ext_id)
1305            .await
1306            .unwrap();
1307
1308        let task = store.get(store_id).await.unwrap();
1309        assert_eq!(task.status, TaskStatus::Done);
1310    }
1311
1312    #[tokio::test]
1313    async fn get_task_returns_internal_from_store() {
1314        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1315        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1316        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1317
1318        let id = store
1319            .create_internal("owner/repo", "Fetch me", "body", "cron", "job:1", None)
1320            .await
1321            .unwrap();
1322
1323        let task = tm.get_task(id).await.unwrap();
1324        match task {
1325            Task::Internal(t) => assert_eq!(t.title, "Fetch me"),
1326            Task::External(_) => panic!("expected Internal variant"),
1327        }
1328    }
1329
1330    #[tokio::test]
1331    async fn list_routable_includes_internal_new_tasks() {
1332        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1333        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1334        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1335
1336        // Create internal task (starts as New)
1337        store
1338            .create_internal("owner/repo", "Route me", "", "cron", "job:2", None)
1339            .await
1340            .unwrap();
1341
1342        let routable = tm.list_routable().await.unwrap();
1343        assert_eq!(routable.len(), 1);
1344        assert!(routable[0].id.0.starts_with("internal:"));
1345        assert_eq!(routable[0].title, "Route me");
1346    }
1347
1348    #[tokio::test]
1349    async fn list_routable_excludes_non_new_internal_tasks() {
1350        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1351        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1352        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1353
1354        let id = store
1355            .create_internal("owner/repo", "Already routed", "", "cron", "job:3", None)
1356            .await
1357            .unwrap();
1358        store
1359            .update_status(id, crate::store::TaskStatus::InProgress)
1360            .await
1361            .unwrap();
1362
1363        let routable = tm.list_routable().await.unwrap();
1364        assert!(
1365            routable.is_empty(),
1366            "in_progress tasks should not be routable"
1367        );
1368    }
1369
1370    #[tokio::test]
1371    async fn list_internal_by_status_returns_external_tasks() {
1372        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1373        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1374        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1375
1376        store
1377            .create_internal("owner/repo", "Task A", "", "cron", "a", None)
1378            .await
1379            .unwrap();
1380        let id_b = store
1381            .create_internal("owner/repo", "Task B", "", "cron", "b", None)
1382            .await
1383            .unwrap();
1384        store
1385            .update_status(id_b, crate::store::TaskStatus::Done)
1386            .await
1387            .unwrap();
1388
1389        let new_tasks = tm
1390            .list_internal_by_status(crate::store::TaskStatus::New)
1391            .await
1392            .unwrap();
1393        assert_eq!(new_tasks.len(), 1);
1394        assert_eq!(new_tasks[0].title, "Task A");
1395        // Verify it's wrapped as ExternalTask with status label
1396        assert!(new_tasks[0].labels.contains(&"status:new".to_string()));
1397    }
1398
1399    #[tokio::test]
1400    async fn store_task_to_external_maps_fields_correctly() {
1401        let store = TaskStore::open_memory().await.unwrap();
1402        let id = store
1403            .create_internal(
1404                "owner/repo",
1405                "Convert me",
1406                "body text",
1407                "manual",
1408                "m:1",
1409                None,
1410            )
1411            .await
1412            .unwrap();
1413        let task = store.get(id).await.unwrap();
1414        let ext = store_task_to_external(&task);
1415
1416        assert!(ext.id.0.starts_with("internal:"));
1417        assert_eq!(ext.title, "Convert me");
1418        assert_eq!(ext.body, "body text");
1419        assert_eq!(ext.state, "open");
1420        assert!(ext.labels.contains(&"status:new".to_string()));
1421        assert_eq!(ext.author, "manual"); // source maps to author
1422    }
1423
1424    #[tokio::test]
1425    async fn list_all_internal_returns_store_tasks() {
1426        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1427        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1428        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1429
1430        store
1431            .create_internal("owner/repo", "T1", "", "cron", "1", None)
1432            .await
1433            .unwrap();
1434        store
1435            .create_internal("owner/repo", "T2", "", "cron", "2", None)
1436            .await
1437            .unwrap();
1438        // Different repo — should not appear
1439        store
1440            .create_internal("other/repo", "T3", "", "cron", "3", None)
1441            .await
1442            .unwrap();
1443
1444        let tasks = tm.list_all_internal().await.unwrap();
1445        assert_eq!(tasks.len(), 2);
1446    }
1447
1448    // ── Phase 4: store-first reads ───────────────────────────────────
1449
1450    #[tokio::test]
1451    async fn list_external_by_status_reads_from_store() {
1452        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1453        let backend = Arc::new(MockBackend::new());
1454        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1455
1456        // Create an external task in the store with status Routed
1457        let id = store
1458            .upsert_external(&crate::store::UpsertExternal {
1459                repo: "owner/repo",
1460                ext_id: "42",
1461                title: "Store task",
1462                body: "",
1463                author: "",
1464                url: "",
1465                labels: &[],
1466                origin: "github",
1467            })
1468            .await
1469            .unwrap();
1470        store.update_status(id, TaskStatus::Routed).await.unwrap();
1471
1472        let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1473        assert_eq!(routed.len(), 1);
1474        assert_eq!(routed[0].id.0, "42");
1475        assert_eq!(routed[0].title, "Store task");
1476    }
1477
1478    #[tokio::test]
1479    async fn list_external_by_status_excludes_internal_tasks() {
1480        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1481        let backend = Arc::new(MockBackend::new());
1482        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1483
1484        // Create an internal task with Routed status
1485        let id = store
1486            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1487            .await
1488            .unwrap();
1489        store.update_status(id, TaskStatus::Routed).await.unwrap();
1490
1491        // Also create an external task to populate the store
1492        store
1493            .upsert_external(&crate::store::UpsertExternal {
1494                repo: "owner/repo",
1495                ext_id: "99",
1496                title: "External",
1497                body: "",
1498                author: "",
1499                url: "",
1500                labels: &[],
1501                origin: "github",
1502            })
1503            .await
1504            .unwrap();
1505
1506        let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1507        // Should only include the internal task since it's status Routed, but filtered to external only
1508        assert_eq!(routed.len(), 0);
1509    }
1510
1511    #[tokio::test]
1512    async fn list_external_by_status_falls_back_when_store_has_only_internal_tasks() {
1513        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1514        let backend = Arc::new(MockBackend::new().with_status_tasks(
1515            Status::Routed,
1516            vec![ExternalTask {
1517                id: ExternalId("123".to_string()),
1518                title: "Backend routed".to_string(),
1519                body: "".to_string(),
1520                state: "open".to_string(),
1521                labels: vec!["status:routed".to_string()],
1522                author: "bot".to_string(),
1523                created_at: "2026-01-01T00:00:00Z".to_string(),
1524                updated_at: "2026-01-01T00:00:00Z".to_string(),
1525                url: "".to_string(),
1526            }],
1527        ));
1528        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1529
1530        let id = store
1531            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1532            .await
1533            .unwrap();
1534        store.update_status(id, TaskStatus::Routed).await.unwrap();
1535
1536        let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1537        assert_eq!(routed.len(), 1);
1538        assert_eq!(routed[0].id.0, "123");
1539    }
1540
1541    #[tokio::test]
1542    async fn list_routable_reads_from_store_when_populated() {
1543        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1544        let backend = Arc::new(MockBackend::new());
1545        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1546
1547        // Create a new external task
1548        store
1549            .upsert_external(&crate::store::UpsertExternal {
1550                repo: "owner/repo",
1551                ext_id: "10",
1552                title: "New external",
1553                body: "",
1554                author: "",
1555                url: "",
1556                labels: &[],
1557                origin: "github",
1558            })
1559            .await
1560            .unwrap();
1561        // Also a new internal task
1562        store
1563            .create_internal("owner/repo", "New internal", "", "cron", "1", None)
1564            .await
1565            .unwrap();
1566
1567        let routable = tm.list_routable().await.unwrap();
1568        // Both should appear (both are status=new)
1569        assert_eq!(routable.len(), 2);
1570    }
1571
1572    #[tokio::test]
1573    async fn list_routable_falls_back_when_store_has_only_internal_tasks() {
1574        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1575        let backend = Arc::new(MockBackend::new().with_routable_tasks(vec![ExternalTask {
1576            id: ExternalId("55".to_string()),
1577            title: "Backend new".to_string(),
1578            body: "".to_string(),
1579            state: "open".to_string(),
1580            labels: vec!["status:new".to_string()],
1581            author: "bot".to_string(),
1582            created_at: "2026-01-01T00:00:00Z".to_string(),
1583            updated_at: "2026-01-01T00:00:00Z".to_string(),
1584            url: "".to_string(),
1585        }]));
1586        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1587
1588        store
1589            .create_internal("owner/repo", "Internal new", "", "cron", "1", None)
1590            .await
1591            .unwrap();
1592
1593        let routable = tm.list_routable().await.unwrap();
1594        assert_eq!(routable.len(), 2);
1595        assert!(routable.iter().any(|task| task.id.0 == "55"));
1596        assert!(routable
1597            .iter()
1598            .any(|task| task.id.0.starts_with("internal:")));
1599    }
1600
1601    #[tokio::test]
1602    async fn list_all_external_tasks_reads_from_store() {
1603        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1604        let backend = Arc::new(MockBackend::new());
1605        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1606
1607        // Create tasks with different statuses
1608        let id1 = store
1609            .upsert_external(&crate::store::UpsertExternal {
1610                repo: "owner/repo",
1611                ext_id: "1",
1612                title: "Task 1",
1613                body: "",
1614                author: "",
1615                url: "",
1616                labels: &[],
1617                origin: "github",
1618            })
1619            .await
1620            .unwrap();
1621        let id2 = store
1622            .upsert_external(&crate::store::UpsertExternal {
1623                repo: "owner/repo",
1624                ext_id: "2",
1625                title: "Task 2",
1626                body: "",
1627                author: "",
1628                url: "",
1629                labels: &[],
1630                origin: "github",
1631            })
1632            .await
1633            .unwrap();
1634        store.update_status(id1, TaskStatus::Done).await.unwrap();
1635        store
1636            .update_status(id2, TaskStatus::InProgress)
1637            .await
1638            .unwrap();
1639
1640        // Internal tasks should be excluded
1641        store
1642            .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1643            .await
1644            .unwrap();
1645
1646        let all = tm.list_all_external_tasks().await.unwrap();
1647        assert_eq!(all.len(), 2);
1648    }
1649
1650    #[tokio::test]
1651    async fn list_all_external_tasks_falls_back_when_store_has_only_internal_tasks() {
1652        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1653        let backend = Arc::new(MockBackend::new().with_status_tasks(
1654            Status::Done,
1655            vec![ExternalTask {
1656                id: ExternalId("200".to_string()),
1657                title: "Backend done".to_string(),
1658                body: "".to_string(),
1659                state: "closed".to_string(),
1660                labels: vec!["status:done".to_string()],
1661                author: "bot".to_string(),
1662                created_at: "2026-01-01T00:00:00Z".to_string(),
1663                updated_at: "2026-01-01T00:00:00Z".to_string(),
1664                url: "".to_string(),
1665            }],
1666        ));
1667        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1668
1669        store
1670            .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1671            .await
1672            .unwrap();
1673
1674        let all = tm.list_all_external_tasks().await.unwrap();
1675        assert_eq!(all.len(), 1);
1676        assert_eq!(all[0].id.0, "200");
1677    }
1678
1679    #[tokio::test]
1680    async fn list_tasks_without_status_merges_backend_external_when_store_only_has_internal() {
1681        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1682        let backend = Arc::new(MockBackend::new().with_status_tasks(
1683            Status::New,
1684            vec![ExternalTask {
1685                id: ExternalId("301".to_string()),
1686                title: "Backend new".to_string(),
1687                body: "".to_string(),
1688                state: "open".to_string(),
1689                labels: vec!["status:new".to_string()],
1690                author: "bot".to_string(),
1691                created_at: "2026-01-01T00:00:00Z".to_string(),
1692                updated_at: "2026-01-01T00:00:00Z".to_string(),
1693                url: "".to_string(),
1694            }],
1695        ));
1696        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1697
1698        let internal_id = store
1699            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1700            .await
1701            .unwrap();
1702
1703        let listed = tm.list_tasks(TaskFilter::default()).await.unwrap();
1704        assert_eq!(listed.len(), 2);
1705        assert!(listed.iter().any(|task| matches!(
1706            task,
1707            Task::External(external) if external.id.0 == "301"
1708        )));
1709        assert!(listed.iter().any(|task| matches!(
1710            task,
1711            Task::Internal(internal) if internal.id == internal_id
1712        )));
1713    }
1714
1715    #[tokio::test]
1716    async fn list_tasks_status_all_merges_backend_external_when_store_only_has_internal() {
1717        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1718        let backend = Arc::new(MockBackend::new().with_status_tasks(
1719            Status::Done,
1720            vec![ExternalTask {
1721                id: ExternalId("302".to_string()),
1722                title: "Backend done".to_string(),
1723                body: "".to_string(),
1724                state: "closed".to_string(),
1725                labels: vec!["status:done".to_string()],
1726                author: "bot".to_string(),
1727                created_at: "2026-01-01T00:00:00Z".to_string(),
1728                updated_at: "2026-01-01T00:00:00Z".to_string(),
1729                url: "".to_string(),
1730            }],
1731        ));
1732        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1733
1734        let internal_id = store
1735            .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1736            .await
1737            .unwrap();
1738
1739        let listed = tm
1740            .list_tasks(TaskFilter {
1741                status: Some("all".to_string()),
1742                source: None,
1743            })
1744            .await
1745            .unwrap();
1746        assert_eq!(listed.len(), 2);
1747        assert!(listed.iter().any(|task| matches!(
1748            task,
1749            Task::External(external) if external.id.0 == "302"
1750        )));
1751        assert!(listed.iter().any(|task| matches!(
1752            task,
1753            Task::Internal(internal) if internal.id == internal_id
1754        )));
1755    }
1756
1757    // ── store-first update_task_status ──────────────────────────────
1758
1759    #[tokio::test]
1760    async fn update_task_status_writes_store_first_for_external() {
1761        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1762        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1763        let tm = TaskManager::with_store(backend.clone(), store.clone(), "owner/repo".to_string());
1764
1765        // Upsert an external task
1766        let store_id = store
1767            .upsert_external(&crate::store::UpsertExternal {
1768                repo: "owner/repo",
1769                ext_id: "42",
1770                title: "Test",
1771                body: "",
1772                author: "user",
1773                url: "",
1774                labels: &[],
1775                origin: "github",
1776            })
1777            .await
1778            .unwrap();
1779        assert_eq!(store.get(store_id).await.unwrap().status, TaskStatus::New);
1780
1781        // Update via task_manager — store should update first
1782        let ext_id = ExternalId("42".to_string());
1783        tm.update_task_status(&ext_id, Status::Routed)
1784            .await
1785            .unwrap();
1786
1787        // Store should now have Routed
1788        let task = store.get(store_id).await.unwrap();
1789        assert_eq!(task.status, TaskStatus::Routed);
1790    }
1791
1792    #[tokio::test]
1793    async fn update_task_status_handles_internal_tasks() {
1794        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1795        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1796        let tm = TaskManager::with_store(backend.clone(), store.clone(), "owner/repo".to_string());
1797
1798        // Create an internal task
1799        let store_id = store
1800            .create_internal("owner/repo", "task", "body", "manual", "", None)
1801            .await
1802            .unwrap();
1803
1804        let internal_id = ExternalId(format!("internal:{}", store_id));
1805        tm.update_task_status(&internal_id, Status::InProgress)
1806            .await
1807            .unwrap();
1808
1809        let task = store.get(store_id).await.unwrap();
1810        assert_eq!(task.status, TaskStatus::InProgress);
1811    }
1812
1813    #[tokio::test]
1814    async fn update_task_status_publishes_event() {
1815        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1816        let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1817        let tm = TaskManager {
1818            backend,
1819            store: None,
1820            repo: "owner/repo".to_string(),
1821            event_tx: Some(tx),
1822        };
1823        // External task — backend mock accepts any status update
1824        let id = ExternalId("42".to_string());
1825        tm.update_task_status(&id, Status::Routed).await.unwrap();
1826        let event = rx.try_recv().unwrap();
1827        assert_eq!(event.task_id, "42");
1828        assert_eq!(event.new_status, "routed");
1829        assert_eq!(event.repo, "owner/repo");
1830    }
1831
1832    #[tokio::test]
1833    async fn event_includes_old_status_and_context() {
1834        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1835        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1836        let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1837
1838        // Upsert an external task (starts as New)
1839        let store_id = store
1840            .upsert_external(&crate::store::UpsertExternal {
1841                repo: "owner/repo",
1842                ext_id: "77",
1843                title: "Context test",
1844                body: "",
1845                author: "user",
1846                url: "",
1847                labels: &[],
1848                origin: "github",
1849            })
1850            .await
1851            .unwrap();
1852
1853        // Set some context fields on the task
1854        store
1855            .set_fields(
1856                store_id,
1857                &[
1858                    ("agent", serde_json::Value::String("claude".to_string())),
1859                    ("model", serde_json::Value::String("sonnet".to_string())),
1860                    ("branch", serde_json::Value::String("feat-77".to_string())),
1861                    ("pr_number", serde_json::Value::Number(42.into())),
1862                    (
1863                        "last_error",
1864                        serde_json::Value::String("timeout".to_string()),
1865                    ),
1866                ],
1867            )
1868            .await
1869            .unwrap();
1870
1871        let tm = TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), tx);
1872
1873        // Update New → Routed
1874        let ext_id = ExternalId("77".to_string());
1875        tm.update_task_status(&ext_id, Status::Routed)
1876            .await
1877            .unwrap();
1878
1879        let event = rx.try_recv().unwrap();
1880        assert_eq!(event.task_id, "77");
1881        assert_eq!(event.old_status, "new");
1882        assert_eq!(event.new_status, "routed");
1883        assert_eq!(event.agent.as_deref(), Some("claude"));
1884        assert_eq!(event.model.as_deref(), Some("sonnet"));
1885        assert_eq!(event.branch.as_deref(), Some("feat-77"));
1886        assert_eq!(event.pr_number.as_deref(), Some("42"));
1887        assert_eq!(event.error.as_deref(), Some("timeout"));
1888    }
1889
1890    #[tokio::test]
1891    async fn event_includes_old_status_for_internal_task() {
1892        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1893        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1894        let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1895
1896        // Create an internal task (starts as New)
1897        let store_id = store
1898            .create_internal(
1899                "owner/repo",
1900                "internal ctx test",
1901                "body",
1902                "manual",
1903                "",
1904                None,
1905            )
1906            .await
1907            .unwrap();
1908
1909        let tm = TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), tx);
1910
1911        // Update New → Routed
1912        let internal_id = ExternalId(format!("internal:{}", store_id));
1913        tm.update_task_status(&internal_id, Status::Routed)
1914            .await
1915            .unwrap();
1916
1917        let event = rx.try_recv().unwrap();
1918        assert_eq!(event.old_status, "new");
1919        assert_eq!(event.new_status, "routed");
1920
1921        // Update Routed → InProgress
1922        tm.update_task_status(&internal_id, Status::InProgress)
1923            .await
1924            .unwrap();
1925
1926        let event2 = rx.try_recv().unwrap();
1927        assert_eq!(event2.old_status, "routed");
1928        assert_eq!(event2.new_status, "in_progress");
1929    }
1930
1931    // ── update_task_status_if ────────────────────────────────────────
1932
1933    #[tokio::test]
1934    async fn update_task_status_if_applies_when_status_matches() {
1935        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1936        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1937        let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1938
1939        let store_id = store
1940            .upsert_external(&crate::store::UpsertExternal {
1941                repo: "owner/repo",
1942                ext_id: "10",
1943                title: "T",
1944                body: "",
1945                author: "u",
1946                url: "",
1947                labels: &[],
1948                origin: "github",
1949            })
1950            .await
1951            .unwrap();
1952        // Task starts as New; move it to InReview first
1953        store
1954            .update_status(store_id, crate::store::TaskStatus::InReview)
1955            .await
1956            .unwrap();
1957
1958        let id = ExternalId("10".to_string());
1959        let updated = tm
1960            .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
1961            .await
1962            .unwrap();
1963        assert!(updated, "should update when expected status matches");
1964
1965        let task = store.get(store_id).await.unwrap();
1966        assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
1967    }
1968
1969    #[tokio::test]
1970    async fn update_task_status_if_is_noop_when_status_mismatches() {
1971        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1972        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1973        let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1974        let tm = TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), tx);
1975
1976        let store_id = store
1977            .upsert_external(&crate::store::UpsertExternal {
1978                repo: "owner/repo",
1979                ext_id: "11",
1980                title: "T",
1981                body: "",
1982                author: "u",
1983                url: "",
1984                labels: &[],
1985                origin: "github",
1986            })
1987            .await
1988            .unwrap();
1989        // Simulate a concurrent transition: task already moved to Done
1990        store
1991            .update_status(store_id, crate::store::TaskStatus::Done)
1992            .await
1993            .unwrap();
1994
1995        let id = ExternalId("11".to_string());
1996        // Attempt to reset to NeedsReview expecting InReview — should be a no-op
1997        let updated = tm
1998            .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
1999            .await
2000            .unwrap();
2001        assert!(
2002            !updated,
2003            "should not update when status has already changed"
2004        );
2005
2006        // Status must remain Done
2007        let task = store.get(store_id).await.unwrap();
2008        assert_eq!(task.status, crate::store::TaskStatus::Done);
2009
2010        // No event must be published
2011        assert!(
2012            rx.try_recv().is_err(),
2013            "no event should be published on a no-op update"
2014        );
2015    }
2016}