diff --git a/desloppify/app/commands/resolve/living_plan.py b/desloppify/app/commands/resolve/living_plan.py index 5219d35b..ac83694f 100644 --- a/desloppify/app/commands/resolve/living_plan.py +++ b/desloppify/app/commands/resolve/living_plan.py @@ -8,25 +8,25 @@ from typing import NamedTuple from desloppify.app.commands.helpers.transition_messages import emit_transition_message +from desloppify.app.commands.resolve.plan_load import warn_plan_load_degraded_once from desloppify.base.config import target_strict_score_from_config from desloppify.base.exception_sets import PLAN_LOAD_EXCEPTIONS from desloppify.base.output.terminal import colorize -from desloppify.app.commands.resolve.plan_load import warn_plan_load_degraded_once -from desloppify.engine._plan.sync import live_planned_queue_empty, reconcile_plan from desloppify.engine._plan.cluster_semantics import EXECUTION_STATUS_DONE -from desloppify.engine.plan_ops import ( - append_log_entry, - auto_complete_steps, - purge_ids, -) from desloppify.engine._plan.refresh_lifecycle import ( current_lifecycle_phase, invalidate_postflight_scan, ) +from desloppify.engine._plan.sync import live_planned_queue_empty, reconcile_plan from desloppify.engine._state.progression import ( maybe_append_entered_planning, maybe_append_execution_drain, ) +from desloppify.engine.plan_ops import ( + append_log_entry, + auto_complete_steps, + purge_ids, +) from desloppify.engine.plan_state import ( add_uncommitted_issues, has_living_plan, @@ -93,6 +93,24 @@ def capture_cluster_context(plan: dict, resolved_ids: list[str]) -> ClusterConte ) +def _cluster_has_open_member( + plan: dict, state: dict | None, cluster_name: str +) -> bool: + """Return whether a cluster still contains an open state work item.""" + if state is None: + return False + cluster = (plan.get("clusters") or {}).get(cluster_name) + if not isinstance(cluster, dict): + return False + issues = state.get("work_items") or state.get("issues", {}) + if not isinstance(issues, dict): + return False + return any( + issues.get(issue_id, {}).get("status") == "open" + for issue_id in cluster.get("issue_ids") or [] + ) + + def update_living_plan_after_resolve( *, args: argparse.Namespace, @@ -142,8 +160,12 @@ def update_living_plan_after_resolve( # Clear focus when the active cluster is done if plan.get("active_cluster") in set(completed_clusters): plan["active_cluster"] = None - elif ctx.cluster_name and ctx.cluster_remaining > 0: - # Auto-focus on the cluster while there's still work in it + elif ( + ctx.cluster_name + and ctx.cluster_remaining > 0 + and _cluster_has_open_member(plan, state, ctx.cluster_name) + ): + # Auto-focus only while a state-open member remains in the cluster. plan["active_cluster"] = ctx.cluster_name if args.status == "fixed": add_uncommitted_issues(plan, all_resolved) diff --git a/desloppify/tests/commands/resolve/test_living_plan_direct.py b/desloppify/tests/commands/resolve/test_living_plan_direct.py index 3a6e3eb9..53ded82b 100644 --- a/desloppify/tests/commands/resolve/test_living_plan_direct.py +++ b/desloppify/tests/commands/resolve/test_living_plan_direct.py @@ -103,6 +103,94 @@ def test_update_living_plan_after_resolve_fixed_flow(monkeypatch, capsys) -> Non assert "add" in calls and "clear" in calls and "save" in calls +def test_update_living_plan_after_resolve_does_not_focus_stale_fixed_member( + monkeypatch, +) -> None: + plan = { + "queue_order": ["a"], + "overrides": { + "a": {"cluster": "epic/a"}, + "stale-b": {"cluster": "epic/a"}, + }, + "clusters": {"epic/a": {"issue_ids": ["a", "stale-b"]}}, + } + state = { + "work_items": { + "a": {"id": "a", "status": "fixed"}, + "stale-b": {"id": "stale-b", "status": "fixed"}, + } + } + + monkeypatch.setattr(living_plan_mod, "has_living_plan", lambda _p=None: True) + monkeypatch.setattr(living_plan_mod, "load_plan", lambda _p=None: plan) + monkeypatch.setattr(living_plan_mod, "auto_complete_steps", lambda _plan: []) + monkeypatch.setattr(living_plan_mod, "append_log_entry", lambda *_a, **_k: None) + monkeypatch.setattr( + living_plan_mod, "add_uncommitted_issues", lambda *_a, **_k: None + ) + monkeypatch.setattr( + living_plan_mod, "invalidate_postflight_scan", lambda *_a, **_k: None + ) + monkeypatch.setattr(living_plan_mod, "live_planned_queue_empty", lambda _plan: False) + monkeypatch.setattr(living_plan_mod, "save_plan", lambda _plan, _p=None: None) + + updated_plan, ctx = living_plan_mod.update_living_plan_after_resolve( + args=_args(status="fixed"), + all_resolved=["a"], + attestation="attest", + state=state, + ) + + assert ctx.cluster_remaining == 1 + assert updated_plan is plan + assert updated_plan["clusters"]["epic/a"]["issue_ids"] == ["stale-b"] + assert updated_plan.get("active_cluster") is None + + +def test_update_living_plan_after_resolve_focuses_open_remaining_member( + monkeypatch, +) -> None: + plan = { + "queue_order": ["a", "live-b"], + "overrides": { + "a": {"cluster": "epic/a"}, + "live-b": {"cluster": "epic/a"}, + }, + "clusters": {"epic/a": {"issue_ids": ["a", "live-b"]}}, + } + state = { + "work_items": { + "a": {"id": "a", "status": "fixed"}, + "live-b": {"id": "live-b", "status": "open"}, + } + } + + monkeypatch.setattr(living_plan_mod, "has_living_plan", lambda _p=None: True) + monkeypatch.setattr(living_plan_mod, "load_plan", lambda _p=None: plan) + monkeypatch.setattr(living_plan_mod, "auto_complete_steps", lambda _plan: []) + monkeypatch.setattr(living_plan_mod, "append_log_entry", lambda *_a, **_k: None) + monkeypatch.setattr( + living_plan_mod, "add_uncommitted_issues", lambda *_a, **_k: None + ) + monkeypatch.setattr( + living_plan_mod, "invalidate_postflight_scan", lambda *_a, **_k: None + ) + monkeypatch.setattr(living_plan_mod, "live_planned_queue_empty", lambda _plan: False) + monkeypatch.setattr(living_plan_mod, "save_plan", lambda _plan, _p=None: None) + + updated_plan, ctx = living_plan_mod.update_living_plan_after_resolve( + args=_args(status="fixed"), + all_resolved=["a"], + attestation="attest", + state=state, + ) + + assert ctx.cluster_remaining == 1 + assert updated_plan is plan + assert updated_plan["clusters"]["epic/a"]["issue_ids"] == ["live-b"] + assert updated_plan["active_cluster"] == "epic/a" + + def test_update_living_plan_after_resolve_marks_all_completed_clusters_done( monkeypatch, ) -> None: