Skip to content

Commit 33e5e2f

Browse files
authored
Merge pull request #67 from BraveNewCapital/copilot/fix-execution-reconciliation-bugs
Make `closed_unmerged` terminal in repo-architect execution and fix execution artifact upload
2 parents 2f1faaf + c3a7d29 commit 33e5e2f

File tree

3 files changed

+155
-4
lines changed

3 files changed

+155
-4
lines changed

.github/workflows/repo-architect-execution.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,9 @@ jobs:
125125
uses: actions/upload-artifact@v4
126126
with:
127127
name: repo-architect-execution-${{ github.run_id }}
128-
path: .agent
128+
path: .agent/work_state.json
129129
if-no-files-found: warn
130+
include-hidden-files: true
130131
retention-days: 7
131132

132133
- name: Write workflow summary

repo_architect.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2328,9 +2328,9 @@ def select_ready_issue(
23282328
if it.get("lane"):
23292329
blocked_lanes.add(it["lane"])
23302330

2331-
# Also block superseded/blocked items by fingerprint
2331+
# Block terminal, superseded, or explicitly blocked items by identity.
23322332
for it in items:
2333-
if it.get("blocked") or it.get("superseded"):
2333+
if it.get("merged") or it.get("closed_unmerged") or it.get("blocked") or it.get("superseded"):
23342334
if it.get("fingerprint"):
23352335
blocked_fingerprints.add(it["fingerprint"])
23362336
if it.get("issue_number"):
@@ -2953,18 +2953,25 @@ def reconcile_pr_state(
29532953
new_item["updated_at"] = now
29542954
if pr_class == "merged":
29552955
new_item["merged"] = True
2956+
new_item["closed_unmerged"] = False
29562957
new_item["delegation_state"] = "delegation-confirmed"
29572958
new_item["lifecycle_fact_state"] = "merged"
29582959
new_item["lifecycle_inferred_state"] = "completed"
29592960
elif pr_class == "closed_unmerged":
2961+
new_item["merged"] = False
29602962
new_item["closed_unmerged"] = True
2963+
new_item["delegation_state"] = "delegation-failed"
29612964
new_item["lifecycle_fact_state"] = "closed-unmerged"
29622965
new_item["lifecycle_inferred_state"] = "needs-replanning"
29632966
elif pr_class == "draft":
2967+
new_item["merged"] = False
2968+
new_item["closed_unmerged"] = False
29642969
new_item["delegation_state"] = "delegation-confirmed"
29652970
new_item["lifecycle_fact_state"] = "pr-draft"
29662971
new_item["lifecycle_inferred_state"] = "in-progress"
29672972
elif pr_class == "open":
2973+
new_item["merged"] = False
2974+
new_item["closed_unmerged"] = False
29682975
new_item["delegation_state"] = "delegation-confirmed"
29692976
new_item["lifecycle_fact_state"] = "pr-open"
29702977
new_item["lifecycle_inferred_state"] = "in-progress"
@@ -3077,6 +3084,32 @@ def _active_fingerprints_in_work_state(work_state: Dict[str, Any]) -> Set[str]:
30773084
}
30783085

30793086

3087+
def _execution_no_selection_message(
3088+
reconcile_result: Dict[str, Any], work_state: Dict[str, Any]
3089+
) -> str:
3090+
"""Explain why execution found nothing ready when reconciliation terminalized items."""
3091+
closed_unmerged_issues = sorted({
3092+
int(detail["issue"])
3093+
for detail in reconcile_result.get("details", [])
3094+
if detail.get("pr_state") == "closed_unmerged" and detail.get("issue") is not None
3095+
})
3096+
if not closed_unmerged_issues:
3097+
closed_unmerged_issues = sorted({
3098+
int(it["issue_number"])
3099+
for it in work_state.get("items", [])
3100+
if it.get("closed_unmerged") and it.get("issue_number") is not None
3101+
})
3102+
if not closed_unmerged_issues:
3103+
return "No ready issues available for delegation."
3104+
3105+
issue_list = ", ".join(f"#{issue_number}" for issue_number in closed_unmerged_issues)
3106+
noun = "issue remains" if len(closed_unmerged_issues) == 1 else "issues remain"
3107+
return (
3108+
f"No ready issues available for delegation. Tracked {noun} "
3109+
f"in closed-unmerged state ({issue_list}) and require planner intervention."
3110+
)
3111+
3112+
30803113
def run_execution_cycle(config: Config) -> Dict[str, Any]:
30813114
"""Execute one execution-lane pass: select + delegate one ready issue.
30823115
@@ -3103,14 +3136,15 @@ def run_execution_cycle(config: Config) -> Dict[str, Any]:
31033136
selected = select_ready_issue(config, work_state)
31043137
if selected is None:
31053138
save_work_state(config, work_state)
3139+
message = _execution_no_selection_message(reconcile_result, work_state)
31063140
return {
31073141
"status": "execution_cycle_complete",
31083142
"mode": EXECUTION_MODE,
31093143
"dry_run": not config.enable_live_delegation,
31103144
"selected_issue": None,
31113145
"delegation": None,
31123146
"reconcile": reconcile_result,
3113-
"message": "No ready issues available for delegation.",
3147+
"message": message,
31143148
}
31153149

31163150
delegation_result = delegate_to_copilot(config, selected, work_state, run_id)

tests/test_repo_architect.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 -->\nLane: 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

Comments
 (0)