@@ -2708,7 +2708,248 @@ def test_active_fingerprints_in_work_state(self) -> None:
27082708
27092709
27102710# ---------------------------------------------------------------------------
2711- # 37. Delegation dry-run behavior
2711+ # 37. Stale terminal state revival (live-state authority)
2712+ # ---------------------------------------------------------------------------
2713+
2714+ class TestStaleTerminalRevival (unittest .TestCase ):
2715+ """Verify that live GitHub ready state overrides stale cached terminal state."""
2716+
2717+ def _make_config_exec (self , ** overrides : Any ) -> ra .Config :
2718+ cfg = _make_config (mode = "execution" )
2719+ return dataclasses .replace (
2720+ cfg , github_token = "tok" , github_repo = "x/y" , max_concurrent_delegated = 5 ,
2721+ ** overrides
2722+ )
2723+
2724+ def _make_issue (
2725+ self ,
2726+ number : int = 66 ,
2727+ labels : Optional [List [str ]] = None ,
2728+ body : str = "" ,
2729+ ) -> Dict [str , Any ]:
2730+ if labels is None :
2731+ labels = [
2732+ "arch-gap" , "copilot-task" , "needs-implementation" ,
2733+ "ready-for-delegation" ,
2734+ ]
2735+ return {
2736+ "number" : number ,
2737+ "title" : f"Fix issue { number } " ,
2738+ "html_url" : f"https://github.com/x/y/issues/{ number } " ,
2739+ "body" : body or f"<!-- arch-gap-fingerprint: aabbccddeeff{ number :02x} -->" ,
2740+ "labels" : [{"name" : lbl } for lbl in labels ],
2741+ "state" : "open" ,
2742+ }
2743+
2744+ def _make_stale_terminal_ws (
2745+ self ,
2746+ number : int = 66 ,
2747+ fp : str = "aabbccddeeff42" ,
2748+ closed_unmerged : bool = True ,
2749+ blocked : bool = False ,
2750+ superseded : bool = False ,
2751+ ) -> Dict [str , Any ]:
2752+ return {
2753+ "items" : [{
2754+ "fingerprint" : fp ,
2755+ "issue_number" : number ,
2756+ "lane" : "runtime" ,
2757+ "delegation_state" : "delegation-failed" ,
2758+ "merged" : False ,
2759+ "closed_unmerged" : closed_unmerged ,
2760+ "blocked" : blocked ,
2761+ "superseded" : superseded ,
2762+ "pr_number" : None ,
2763+ "pr_url" : None ,
2764+ "pr_state" : None ,
2765+ "objective" : "" ,
2766+ "assignee" : None ,
2767+ "gap_title" : "x" ,
2768+ "gap_subsystem" : "runtime" ,
2769+ "issue_state" : "open" ,
2770+ "lifecycle_fact_state" : "closed-unmerged" ,
2771+ "lifecycle_inferred_state" : "needs-replanning" ,
2772+ "created_at" : "2025-01-01T00:00:00+00:00" ,
2773+ "updated_at" : "2025-01-01T00:00:00+00:00" ,
2774+ "run_id" : "r1" ,
2775+ }]
2776+ }
2777+
2778+ # ------------------------------------------------------------------
2779+ # Case 1: stale closed_unmerged + live issue open/ready → revived
2780+ # ------------------------------------------------------------------
2781+ def test_stale_closed_unmerged_revived_when_live_issue_ready (self ) -> None :
2782+ """Stale cached closed_unmerged must not block when live issue is ready."""
2783+ fp = "aabbccddeeff42"
2784+ issue = self ._make_issue (number = 66 , body = f"<!-- arch-gap-fingerprint: { fp } -->" )
2785+ ws = self ._make_stale_terminal_ws (number = 66 , fp = fp , closed_unmerged = True )
2786+
2787+ config = self ._make_config_exec ()
2788+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2789+ result = ra .select_ready_issue (config , ws )
2790+
2791+ self .assertIsNotNone (result , "Issue should be revived and selected" )
2792+ self .assertEqual (result ["number" ], 66 )
2793+ self .assertTrue (
2794+ result .get ("revived_from_stale_terminal_state" ),
2795+ "Selected issue must carry revival flag" ,
2796+ )
2797+
2798+ # ------------------------------------------------------------------
2799+ # Case 2: stale closed_unmerged + live issue still blocked by label
2800+ # ------------------------------------------------------------------
2801+ def test_stale_closed_unmerged_not_revived_when_live_issue_blocked (self ) -> None :
2802+ """Stale terminal state is not overridden when live issue has blocking labels."""
2803+ fp = "aabbccddeeff42"
2804+ issue = self ._make_issue (
2805+ number = 66 ,
2806+ labels = ["arch-gap" , "copilot-task" , "needs-implementation" ,
2807+ "blocked-by-dependency" ],
2808+ body = f"<!-- arch-gap-fingerprint: { fp } -->" ,
2809+ )
2810+ ws = self ._make_stale_terminal_ws (number = 66 , fp = fp , closed_unmerged = True )
2811+
2812+ config = self ._make_config_exec ()
2813+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2814+ result = ra .select_ready_issue (config , ws )
2815+
2816+ self .assertIsNone (result , "Blocked live issue must not be selected" )
2817+
2818+ # ------------------------------------------------------------------
2819+ # Case 3: stale terminal + live issue has open-PR label
2820+ # ------------------------------------------------------------------
2821+ def test_stale_terminal_not_revived_when_live_issue_has_pr_open_label (self ) -> None :
2822+ """A live pr-open label is a genuine blocker and must prevent revival."""
2823+ fp = "aabbccddeeff42"
2824+ issue = self ._make_issue (
2825+ number = 66 ,
2826+ labels = ["arch-gap" , "copilot-task" , "needs-implementation" ,
2827+ "ready-for-delegation" , "pr-open" ],
2828+ body = f"<!-- arch-gap-fingerprint: { fp } -->" ,
2829+ )
2830+ ws = self ._make_stale_terminal_ws (number = 66 , fp = fp , closed_unmerged = True )
2831+
2832+ config = self ._make_config_exec ()
2833+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2834+ result = ra .select_ready_issue (config , ws )
2835+
2836+ self .assertIsNone (result , "pr-open label must prevent revival" )
2837+
2838+ # ------------------------------------------------------------------
2839+ # Case 4: merged work state is irreversible — never revived
2840+ # ------------------------------------------------------------------
2841+ def test_merged_work_state_not_revived_even_if_live_ready (self ) -> None :
2842+ """merged=True is a permanent terminal state and must not be revived."""
2843+ fp = "aabbccddeeff42"
2844+ issue = self ._make_issue (number = 66 , body = f"<!-- arch-gap-fingerprint: { fp } -->" )
2845+ ws : Dict [str , Any ] = {
2846+ "items" : [{
2847+ "fingerprint" : fp ,
2848+ "issue_number" : 66 ,
2849+ "lane" : "runtime" ,
2850+ "delegation_state" : "done" ,
2851+ "merged" : True ,
2852+ "closed_unmerged" : False ,
2853+ "blocked" : False ,
2854+ "superseded" : False ,
2855+ }]
2856+ }
2857+
2858+ config = self ._make_config_exec ()
2859+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2860+ result = ra .select_ready_issue (config , ws )
2861+
2862+ self .assertIsNone (result , "merged items must never be revived" )
2863+
2864+ # ------------------------------------------------------------------
2865+ # Case 5: work state remains coherent after revival (no contradictory flags)
2866+ # ------------------------------------------------------------------
2867+ def test_work_state_coherent_after_revival (self ) -> None :
2868+ """After revival the work state item must have no contradictory terminal flags."""
2869+ fp = "aabbccddeeff42"
2870+ issue = self ._make_issue (number = 66 , body = f"<!-- arch-gap-fingerprint: { fp } -->" )
2871+ ws = self ._make_stale_terminal_ws (number = 66 , fp = fp , closed_unmerged = True )
2872+
2873+ config = self ._make_config_exec ()
2874+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2875+ ra .select_ready_issue (config , ws )
2876+
2877+ item = ws ["items" ][0 ]
2878+ self .assertFalse (item ["closed_unmerged" ], "closed_unmerged must be cleared" )
2879+ self .assertFalse (item ["blocked" ], "blocked must be cleared" )
2880+ self .assertFalse (item ["superseded" ], "superseded must be cleared" )
2881+ self .assertEqual (item ["delegation_state" ], "ready-for-delegation" )
2882+ self .assertTrue (item .get ("revived_from_stale_terminal_state" ))
2883+
2884+ # ------------------------------------------------------------------
2885+ # Case 6: stale blocked flag revived when live issue ready
2886+ # ------------------------------------------------------------------
2887+ def test_stale_blocked_flag_revived_when_live_issue_ready (self ) -> None :
2888+ """Stale blocked=True must also be overridable by live readiness."""
2889+ fp = "aabbccddeeff42"
2890+ issue = self ._make_issue (number = 66 , body = f"<!-- arch-gap-fingerprint: { fp } -->" )
2891+ ws = self ._make_stale_terminal_ws (
2892+ number = 66 , fp = fp , closed_unmerged = False , blocked = True
2893+ )
2894+
2895+ config = self ._make_config_exec ()
2896+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2897+ result = ra .select_ready_issue (config , ws )
2898+
2899+ self .assertIsNotNone (result )
2900+ self .assertTrue (result .get ("revived_from_stale_terminal_state" ))
2901+
2902+ # ------------------------------------------------------------------
2903+ # Case 7: live issue lacks ready-for-delegation → stale terminal stands
2904+ # ------------------------------------------------------------------
2905+ def test_stale_terminal_blocks_when_live_issue_lacks_ready_label (self ) -> None :
2906+ """Without ready-for-delegation label, stale terminal state must still block."""
2907+ fp = "aabbccddeeff42"
2908+ issue = self ._make_issue (
2909+ number = 66 ,
2910+ labels = ["arch-gap" , "copilot-task" , "needs-implementation" ], # no ready-for-delegation
2911+ body = f"<!-- arch-gap-fingerprint: { fp } -->" ,
2912+ )
2913+ ws = self ._make_stale_terminal_ws (number = 66 , fp = fp , closed_unmerged = True )
2914+
2915+ config = self ._make_config_exec ()
2916+ with patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]):
2917+ result = ra .select_ready_issue (config , ws )
2918+
2919+ self .assertIsNone (result )
2920+
2921+ # ------------------------------------------------------------------
2922+ # Case 8: run_execution_cycle output reflects revival
2923+ # ------------------------------------------------------------------
2924+ def test_run_execution_cycle_output_reflects_revival (self ) -> None :
2925+ """run_execution_cycle output JSON must expose revived_from_stale_terminal_state."""
2926+ fp = "aabbccddeeff42"
2927+ issue = self ._make_issue (number = 66 , body = f"<!-- arch-gap-fingerprint: { fp } -->" )
2928+ ws = self ._make_stale_terminal_ws (number = 66 , fp = fp , closed_unmerged = True )
2929+
2930+ config = self ._make_config_exec (enable_live_delegation = False )
2931+
2932+ with (
2933+ patch .object (ra , "load_work_state" , return_value = ws ),
2934+ patch .object (ra , "save_work_state" ),
2935+ patch .object (ra , "reconcile_pr_state" , return_value = {"updated" : 0 , "prs_found" : 0 , "details" : []}),
2936+ patch .object (ra , "_list_github_issues_by_labels" , return_value = [issue ]),
2937+ patch .object (ra , "delegate_to_copilot" , return_value = {"action" : "dry_run" , "issue_number" : 66 }),
2938+ patch .object (ra , "write_step_summary" ),
2939+ ):
2940+ result = ra .run_execution_cycle (config )
2941+
2942+ self .assertEqual (result ["status" ], "execution_cycle_complete" )
2943+ self .assertIsNotNone (result ["selected_issue" ])
2944+ self .assertEqual (result ["selected_issue" ]["number" ], 66 )
2945+ self .assertTrue (
2946+ result ["selected_issue" ].get ("revived_from_stale_terminal_state" ),
2947+ "Output JSON must declare revival" ,
2948+ )
2949+
2950+
2951+ # ---------------------------------------------------------------------------
2952+ # 38. Delegation dry-run behavior
27122953# ---------------------------------------------------------------------------
27132954
27142955class TestDelegationDryRun (unittest .TestCase ):
0 commit comments