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