@@ -2644,6 +2644,29 @@ def test_lifecycle_labels_exclude_issue(self) -> None:
26442644 issue_labels = {"arch-gap" , "copilot-task" , "needs-implementation" , "in-progress" }
26452645 self .assertTrue (issue_labels & blocking , "in-progress should block selection" )
26462646
2647+ def test_closed_unmerged_work_state_excludes_issue_even_if_labels_lag (self ) -> None :
2648+ """Terminal closed-unmerged work items must stay ineligible even before label sync."""
2649+ config = self ._make_config_exec (
2650+ github_token = "tok" , github_repo = "x/y" , max_concurrent_delegated = 5
2651+ )
2652+ fp = "aabbccddeeff"
2653+ issue = self ._make_issue (number = 12 , body = f"<!-- arch-gap-fingerprint: { fp } -->" )
2654+ ws : Dict [str , Any ] = {
2655+ "items" : [{
2656+ "fingerprint" : fp ,
2657+ "issue_number" : 12 ,
2658+ "delegation_state" : "delegation-failed" ,
2659+ "merged" : False ,
2660+ "closed_unmerged" : True ,
2661+ "lane" : "import_cycles" ,
2662+ "blocked" : False ,
2663+ "superseded" : False ,
2664+ }]
2665+ }
2666+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2667+ result = ra .select_ready_issue (config , ws )
2668+ self .assertIsNone (result )
2669+
26472670 def test_priority_ordering (self ) -> None :
26482671 """Priority critical < high < medium < low in rank (lower index = higher priority)."""
26492672 rank = {p : i for i , p in enumerate (ra .ISSUE_PRIORITY_LEVELS )}
@@ -2869,6 +2892,76 @@ def test_run_execution_cycle_no_credentials_returns_no_selection(self) -> None:
28692892 self .assertEqual (result ["status" ], "execution_cycle_complete" )
28702893 self .assertIsNone (result ["selected_issue" ])
28712894
2895+ def test_run_execution_cycle_reports_closed_unmerged_as_non_ready (self ) -> None :
2896+ """Execution output should truthfully report closed-unmerged items as non-ready."""
2897+ with tempfile .TemporaryDirectory () as tmp :
2898+ root = _make_git_root (tmp )
2899+ config = dataclasses .replace (
2900+ _make_config (root , mode = "execution" ),
2901+ github_token = "tok" ,
2902+ github_repo = "x/y" ,
2903+ )
2904+ ws : Dict [str , Any ] = {
2905+ "items" : [{
2906+ "fingerprint" : "aabbccddeeff" ,
2907+ "issue_number" : 42 ,
2908+ "lane" : "import_cycles" ,
2909+ "delegation_state" : "ready-for-delegation" ,
2910+ "merged" : False ,
2911+ "closed_unmerged" : False ,
2912+ "blocked" : False ,
2913+ "superseded" : False ,
2914+ "pr_number" : None ,
2915+ "pr_url" : None ,
2916+ "pr_state" : None ,
2917+ "updated_at" : "2025-01-01T00:00:00+00:00" ,
2918+ "objective" : "" ,
2919+ "assignee" : None ,
2920+ "created_at" : "2025-01-01T00:00:00+00:00" ,
2921+ "run_id" : "r7" ,
2922+ "gap_title" : "Fix cycles" ,
2923+ "gap_subsystem" : "runtime" ,
2924+ "issue_state" : "open" ,
2925+ }]
2926+ }
2927+ issue = {
2928+ "number" : 42 ,
2929+ "title" : "Fix cycles" ,
2930+ "html_url" : "https://github.com/x/y/issues/42" ,
2931+ "body" : "<!-- arch-gap-fingerprint: aabbccddeeff -->\n Lane: import_cycles" ,
2932+ "labels" : [
2933+ {"name" : "arch-gap" },
2934+ {"name" : "copilot-task" },
2935+ {"name" : "needs-implementation" },
2936+ ],
2937+ "state" : "open" ,
2938+ }
2939+ closed_pr = {
2940+ "number" : 64 ,
2941+ "title" : "PR #64" ,
2942+ "html_url" : "https://github.com/x/y/pull/64" ,
2943+ "state" : "closed" ,
2944+ "draft" : False ,
2945+ "body" : "Fixes #42" ,
2946+ "merged_at" : None ,
2947+ "head" : {"ref" : "feature/issue-64" },
2948+ "created_at" : dt .datetime .now (dt .timezone .utc ).isoformat ().replace ("+00:00" , "Z" ),
2949+ "updated_at" : dt .datetime .now (dt .timezone .utc ).isoformat ().replace ("+00:00" , "Z" ),
2950+ }
2951+ with patch .object (ra , "load_work_state" , return_value = ws ), \
2952+ patch .object (ra , "save_work_state" , return_value = None ), \
2953+ patch .object (ra , "_list_prs_for_repo" , return_value = [closed_pr ]), \
2954+ patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]), \
2955+ patch .object (ra , "_update_issue_lifecycle_labels_for_pr" , return_value = None ):
2956+ result = ra .run_execution_cycle (config )
2957+ self .assertIsNone (result ["selected_issue" ])
2958+ self .assertIsNone (result ["delegation" ])
2959+ self .assertIn ("closed-unmerged state" , result ["message" ])
2960+ self .assertIn ("#42" , result ["message" ])
2961+ self .assertEqual (result ["reconcile" ]["details" ][0 ]["new_delegation" ], "delegation-failed" )
2962+ self .assertEqual (ws ["items" ][0 ]["delegation_state" ], "delegation-failed" )
2963+ self .assertTrue (ws ["items" ][0 ]["closed_unmerged" ])
2964+
28722965
28732966# ---------------------------------------------------------------------------
28742967# 38. PR reconciliation
@@ -3097,6 +3190,29 @@ def test_closed_unmerged_not_auto_superseded(self) -> None:
30973190 self .assertEqual (ws ["items" ][0 ]["lifecycle_fact_state" ], "closed-unmerged" )
30983191 self .assertNotEqual (ws ["items" ][0 ]["lifecycle_fact_state" ], "superseded-by-pr" )
30993192
3193+ def test_reconcile_closed_unmerged_marks_terminal_non_ready_state (self ) -> None :
3194+ """Closed-unmerged reconciliation should make the work item terminal and non-ready."""
3195+ config = _make_config (mode = "reconcile" )
3196+ ws : Dict [str , Any ] = {"items" : [{
3197+ "fingerprint" : "aa11bb22cc33" , "issue_number" : 42 , "lane" : "runtime" ,
3198+ "delegation_state" : "ready-for-delegation" , "merged" : False , "closed_unmerged" : False ,
3199+ "blocked" : False , "superseded" : False , "pr_number" : None , "pr_url" : None , "pr_state" : None ,
3200+ "updated_at" : "2025-01-01T00:00:00+00:00" , "objective" : "" , "assignee" : None ,
3201+ "created_at" : "2025-01-01T00:00:00+00:00" , "run_id" : "r5" ,
3202+ "gap_title" : "x" , "gap_subsystem" : "runtime" , "issue_state" : "open" ,
3203+ }]}
3204+ closed_pr = self ._make_pr (201 , state = "closed" , body = "fixes #42" , merged_at = None )
3205+ with patch .object (ra , "_list_prs_for_repo" , return_value = [closed_pr ]):
3206+ result = ra .reconcile_pr_state (config , ws )
3207+ self .assertEqual (result ["updated" ], 1 )
3208+ self .assertTrue (ws ["items" ][0 ]["closed_unmerged" ])
3209+ self .assertFalse (ws ["items" ][0 ]["merged" ])
3210+ self .assertEqual (ws ["items" ][0 ]["delegation_state" ], "delegation-failed" )
3211+ self .assertEqual (ws ["items" ][0 ]["lifecycle_fact_state" ], "closed-unmerged" )
3212+ self .assertEqual (ws ["items" ][0 ]["lifecycle_inferred_state" ], "needs-replanning" )
3213+ self .assertEqual (result ["details" ][0 ]["new_delegation" ], "delegation-failed" )
3214+ self .assertEqual (result ["details" ][0 ]["pr_state" ], "closed_unmerged" )
3215+
31003216 def test_stale_not_auto_blocked_dependency (self ) -> None :
31013217 """Stale state should not be encoded as blocked-by-dependency without explicit evidence."""
31023218 config = _make_config (mode = "reconcile" )
0 commit comments