diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 9cbc2445..1064dbe8 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -1482,6 +1482,12 @@ where { let queue_label = tracker::automation_queue_label(project.service_id()); let concurrency = ConcurrencySnapshot::new(project.service_id(), state_store)?; + let retained_post_review_issue_ids = state_store + .list_worktrees(project.service_id())? + .into_iter() + .map(|mapping| mapping.issue_id().to_owned()) + .collect::>(); + let success_state = workflow.frontmatter().tracker().success_state(); let mut issues = tracker.list_issues_with_label(&queue_label)?; issues.sort_by(compare_issue_candidates); @@ -1489,6 +1495,13 @@ where issues .into_iter() .filter(|issue| !is_terminal_issue(issue, workflow)) + .filter(|issue| { + !queued_issue_is_retained_post_review_lane( + issue, + success_state, + &retained_post_review_issue_ids, + ) + }) .map(|issue| { operator_queued_issue_status( tracker, @@ -1502,6 +1515,14 @@ where .collect() } +fn queued_issue_is_retained_post_review_lane( + issue: &TrackerIssue, + success_state: &str, + retained_post_review_issue_ids: &HashSet, +) -> bool { + issue.state.name == success_state && retained_post_review_issue_ids.contains(&issue.id) +} + fn operator_queued_issue_status( tracker: &T, project: &ServiceConfig, @@ -1515,6 +1536,7 @@ where { let (classification, reason) = classify_queued_issue(tracker, project, workflow, state_store, concurrency, &issue)?; + let blocker_identifiers = queued_issue_blocker_identifiers(&issue, workflow, reason); let attention = operator_queued_issue_attention_status( tracker, project, @@ -1535,10 +1557,27 @@ where classification: classification.to_owned(), reason: reason.to_owned(), attention, - blocker_identifiers: issue.blockers.into_iter().map(|blocker| blocker.identifier).collect(), + blocker_identifiers, }) } +fn queued_issue_blocker_identifiers( + issue: &TrackerIssue, + workflow: &WorkflowDocument, + reason: &str, +) -> Vec { + if reason != "open_tracker_blockers" { + return Vec::new(); + } + + issue + .blockers + .iter() + .filter(|blocker| !state_name_is_terminal(&blocker.state.name, workflow)) + .map(|blocker| blocker.identifier.clone()) + .collect() +} + fn classify_queued_issue( tracker: &T, project: &ServiceConfig, diff --git a/apps/decodex/src/orchestrator/tests/operator/status/queue.rs b/apps/decodex/src/orchestrator/tests/operator/status/queue.rs index bb563c30..a0038d45 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/queue.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/queue.rs @@ -112,6 +112,95 @@ fn live_operator_status_snapshot_includes_queued_candidates_with_dispatch_classi ); } +#[test] +fn live_operator_status_snapshot_routes_retained_success_state_lane_out_of_queue() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut issue = sample_issue_with_sort_fields( + "issue-review", + "PUB-106", + "In Review", + &[], + Some(1), + "2026-03-13T04:16:17.133Z", + ); + let worktree_path = config.worktree_root().join(&issue.identifier); + + issue.blockers = vec![sample_blocker("issue-done", "PUB-105", "Done")]; + + state_store + .upsert_worktree( + config.service_id(), + &issue.id, + "x/pubfi-pub-106", + &worktree_path.display().to_string(), + ) + .expect("retained review worktree should record"); + + let tracker = FakeTracker::new(vec![issue.clone()]); + let snapshot = orchestrator::build_live_operator_status_snapshot( + &tracker, + &config, + &workflow, + &state_store, + 10, + ) + .expect("snapshot should build"); + let project = snapshot.projects.first().expect("project summary should exist"); + let lane = snapshot + .post_review_lanes + .iter() + .find(|lane| lane.issue_identifier == "PUB-106") + .expect("retained success-state worktree should be owned by post-review status"); + + assert!( + snapshot + .queued_candidates + .iter() + .all(|candidate| candidate.issue_identifier != "PUB-106"), + "post-review retained lanes must not also appear as queue intake blockers" + ); + assert_eq!(lane.reason, "missing_review_handoff_record"); + assert_eq!( + project.queued_candidate_count, 0, + "post-review retained lanes must not inflate intake backlog" + ); +} + +#[test] +fn live_operator_status_snapshot_reports_only_open_tracker_blockers() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut issue = sample_issue_with_sort_fields( + "issue-blocked", + "PUB-107", + "Todo", + &[], + Some(1), + "2026-03-13T04:16:17.133Z", + ); + + issue.blockers = vec![ + sample_blocker("issue-done", "PUB-106", "Done"), + sample_blocker("issue-open", "PUB-105", "Todo"), + ]; + + let tracker = FakeTracker::new(vec![issue]); + let snapshot = orchestrator::build_live_operator_status_snapshot( + &tracker, + &config, + &workflow, + &state_store, + 10, + ) + .expect("snapshot should build"); + let candidate = + snapshot.queued_candidates.first().expect("blocked queued issue should exist"); + + assert_eq!(candidate.reason, "open_tracker_blockers"); + assert_eq!(candidate.blocker_identifiers, vec![String::from("PUB-105")]); +} + #[test] fn live_operator_status_snapshot_excludes_claimed_candidates_from_waiting_intake_count() { let (_temp_dir, config, workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/review_landing/orchestration.rs b/apps/decodex/src/orchestrator/tests/review_landing/orchestration.rs index aa7cde26..ea9b5a4c 100644 --- a/apps/decodex/src/orchestrator/tests/review_landing/orchestration.rs +++ b/apps/decodex/src/orchestrator/tests/review_landing/orchestration.rs @@ -425,7 +425,23 @@ fn reconcile_post_review_orchestration_routes_non_clean_landing_to_agent_fallbac #[test] fn reconcile_post_review_orchestration_runs_admin_merge_without_external_review_when_disabled() { + assert_reconcile_post_review_orchestration_runs_admin_merge_without_external_review( + InternalReviewMode::Loop, + ); +} + +#[test] +fn reconcile_post_review_orchestration_runs_admin_merge_in_prompt_internal_review_mode() { + assert_reconcile_post_review_orchestration_runs_admin_merge_without_external_review( + InternalReviewMode::Prompt, + ); +} + +fn assert_reconcile_post_review_orchestration_runs_admin_merge_without_external_review( + internal_review_mode: InternalReviewMode, +) { let (temp_dir, config, workflow) = temp_project_layout(); + let config = service_config_with_internal_review_mode(&config, internal_review_mode); let config = service_config_with_external_review_enabled( &service_config_with_github_token_env_var(&config, "PATH"), false,