Skip to content

Commit d5f04bc

Browse files
CopilotSteake
andcommitted
fix: override stale terminal state in select_ready_issue when live issue is ready-for-delegation
Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
1 parent 57b7117 commit d5f04bc

File tree

2 files changed

+308
-13
lines changed

2 files changed

+308
-13
lines changed

repo_architect.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2316,25 +2316,38 @@ def select_ready_issue(
23162316
if len(in_flight) >= config.max_concurrent_delegated:
23172317
return None
23182318

2319-
# Build blocked sets from work state
2320-
blocked_fingerprints: Set[str] = set()
2321-
blocked_issue_numbers: Set[int] = set()
2319+
# Build blocked sets from in-flight items (unconditional blockers).
2320+
in_flight_fingerprints: Set[str] = set()
2321+
in_flight_issue_numbers: Set[int] = set()
23222322
blocked_lanes: Set[str] = set()
23232323
for it in in_flight:
23242324
if it.get("fingerprint"):
2325-
blocked_fingerprints.add(it["fingerprint"])
2325+
in_flight_fingerprints.add(it["fingerprint"])
23262326
if it.get("issue_number"):
2327-
blocked_issue_numbers.add(int(it["issue_number"]))
2327+
in_flight_issue_numbers.add(int(it["issue_number"]))
23282328
if it.get("lane"):
23292329
blocked_lanes.add(it["lane"])
23302330

2331-
# Block terminal, superseded, or explicitly blocked items by identity.
2331+
# Build terminal blocked sets from work state.
2332+
# merged: irreversible — cannot be revived under any circumstances.
2333+
# closed_unmerged / blocked / superseded: stale — overridable when the live
2334+
# GitHub issue has been reset to ready-for-delegation.
2335+
merged_fingerprints: Set[str] = set()
2336+
merged_issue_numbers: Set[int] = set()
2337+
stale_terminal_by_issue_num: Dict[int, Dict[str, Any]] = {}
2338+
stale_terminal_fingerprints: Set[str] = set()
2339+
23322340
for it in items:
2333-
if it.get("merged") or it.get("closed_unmerged") or it.get("blocked") or it.get("superseded"):
2341+
if it.get("merged"):
2342+
if it.get("fingerprint"):
2343+
merged_fingerprints.add(it["fingerprint"])
2344+
if it.get("issue_number"):
2345+
merged_issue_numbers.add(int(it["issue_number"]))
2346+
elif it.get("closed_unmerged") or it.get("blocked") or it.get("superseded"):
23342347
if it.get("fingerprint"):
2335-
blocked_fingerprints.add(it["fingerprint"])
2348+
stale_terminal_fingerprints.add(it["fingerprint"])
23362349
if it.get("issue_number"):
2337-
blocked_issue_numbers.add(int(it["issue_number"]))
2350+
stale_terminal_by_issue_num[int(it["issue_number"])] = it
23382351

23392352
# Fetch eligible issues from GitHub
23402353
candidate_issues = _list_github_issues_by_labels(
@@ -2355,17 +2368,45 @@ def select_ready_issue(
23552368
for lbl in issue.get("labels", [])
23562369
if isinstance(lbl, dict)
23572370
}
2358-
# Skip lifecycle-blocked issues
2371+
# Skip lifecycle-blocked issues (live-state check — authoritative).
23592372
if issue_labels & blocking_lifecycle:
23602373
continue
23612374

23622375
issue_num = issue.get("number")
2363-
if issue_num and int(issue_num) in blocked_issue_numbers:
2376+
2377+
# Unconditional blocks: in-flight or permanently merged.
2378+
if issue_num and int(issue_num) in in_flight_issue_numbers:
23642379
continue
2380+
if issue_num and int(issue_num) in merged_issue_numbers:
2381+
continue
2382+
2383+
# Stale-terminal check: overridable when live issue signals readiness.
2384+
revived = False
2385+
if issue_num and int(issue_num) in stale_terminal_by_issue_num:
2386+
if "ready-for-delegation" in issue_labels:
2387+
# Live GitHub state is authoritative — clear the stale terminal
2388+
# state so work_state remains coherent after this selection.
2389+
stale_item = stale_terminal_by_issue_num[int(issue_num)]
2390+
stale_item["closed_unmerged"] = False
2391+
stale_item["blocked"] = False
2392+
stale_item["superseded"] = False
2393+
stale_item["delegation_state"] = "ready-for-delegation"
2394+
stale_item["lifecycle_fact_state"] = "ready-for-delegation"
2395+
stale_item["lifecycle_inferred_state"] = "ready-for-redelegation"
2396+
stale_item["revived_from_stale_terminal_state"] = True
2397+
stale_item["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
2398+
revived = True
2399+
else:
2400+
continue
23652401

23662402
body = issue.get("body") or ""
23672403
fp = _extract_fingerprint_from_body(body)
2368-
if fp and fp in blocked_fingerprints:
2404+
2405+
if fp and fp in in_flight_fingerprints:
2406+
continue
2407+
if fp and fp in merged_fingerprints:
2408+
continue
2409+
if fp and fp in stale_terminal_fingerprints and not revived:
23692410
continue
23702411

23712412
lane = _extract_lane_from_body(body)
@@ -2378,6 +2419,10 @@ def select_ready_issue(
23782419
if config.lane_filter and lane and lane != config.lane_filter:
23792420
continue
23802421

2422+
if revived:
2423+
issue = dict(issue) # shallow copy — don't mutate the original list
2424+
issue["revived_from_stale_terminal_state"] = True
2425+
23812426
filtered.append((issue, fp, lane))
23822427

23832428
if not filtered:
@@ -3150,6 +3195,7 @@ def run_execution_cycle(config: Config) -> Dict[str, Any]:
31503195
delegation_result = delegate_to_copilot(config, selected, work_state, run_id)
31513196
save_work_state(config, work_state)
31523197

3198+
revived = bool(selected.get("revived_from_stale_terminal_state"))
31533199
if not config.enable_live_delegation:
31543200
summary_line = (
31553201
f"[dry-run] delegation requested for issue #{selected.get('number')} "
@@ -3160,6 +3206,13 @@ def run_execution_cycle(config: Config) -> Dict[str, Any]:
31603206
f"{delegation_result.get('action', 'delegation_requested')} issue "
31613207
f"#{selected.get('number')}{selected.get('title', '')}"
31623208
)
3209+
if revived:
3210+
summary_line += " [revived from stale terminal state]"
3211+
log(
3212+
f"Issue #{selected.get('number')} revived: stale cached terminal state overridden "
3213+
f"by live ready-for-delegation label.",
3214+
json_mode=config.log_json,
3215+
)
31633216
log(summary_line, json_mode=config.log_json)
31643217

31653218
result: Dict[str, Any] = {
@@ -3170,6 +3223,7 @@ def run_execution_cycle(config: Config) -> Dict[str, Any]:
31703223
"number": selected.get("number"),
31713224
"title": selected.get("title"),
31723225
"url": selected.get("html_url"),
3226+
"revived_from_stale_terminal_state": revived,
31733227
},
31743228
"delegation": delegation_result,
31753229
"reconcile": reconcile_result,

tests/test_repo_architect.py

Lines changed: 242 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

27142955
class TestDelegationDryRun(unittest.TestCase):

0 commit comments

Comments
 (0)