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