From 12638be0365922fde60dfe853f6ff94452d0286d Mon Sep 17 00:00:00 2001 From: Andrew Robinson Date: Fri, 13 Mar 2026 11:52:58 +0000 Subject: [PATCH] fix: unblock objective resolves while triage is pending --- desloppify/app/commands/helpers/guardrails.py | 68 ++++++++++++-- .../app/commands/plan/triage/lifecycle.py | 2 +- desloppify/app/commands/resolve/cmd.py | 2 + desloppify/engine/plan_triage.py | 12 ++- .../tests/commands/helpers/test_guardrails.py | 93 +++++++++++++++++++ .../tests/plan/test_triage_phase_banner.py | 16 ++++ 6 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 desloppify/tests/commands/helpers/test_guardrails.py diff --git a/desloppify/app/commands/helpers/guardrails.py b/desloppify/app/commands/helpers/guardrails.py index 1c1af9d0..bf7533e1 100644 --- a/desloppify/app/commands/helpers/guardrails.py +++ b/desloppify/app/commands/helpers/guardrails.py @@ -5,9 +5,11 @@ import logging from dataclasses import dataclass, field +from desloppify import state as state_mod from desloppify.app.commands.helpers.issue_id_display import short_issue_id from desloppify.base.exception_sets import PLAN_LOAD_EXCEPTIONS, CommandError from desloppify.base.output.terminal import colorize +from desloppify.engine._plan.sync.context import has_objective_backlog, is_mid_cycle from desloppify.engine.plan_state import load_plan from desloppify.engine.plan_triage import ( TRIAGE_CMD_RUN_STAGES_CLAUDE, @@ -18,6 +20,7 @@ ) logger = logging.getLogger(__name__) +_REVIEW_DETECTORS = frozenset({"review", "concerns"}) @dataclass @@ -25,6 +28,7 @@ class TriageGuardrailResult: """Structured result from triage staleness detection.""" is_stale: bool = False + pending_behind_objective_backlog: bool = False new_ids: set[str] = field(default_factory=set) _plan: dict | None = field(default=None, repr=False) _snapshot: TriageSnapshot | None = field(default=None, repr=False) @@ -48,8 +52,16 @@ def triage_guardrail_status( if not snapshot.is_triage_stale: return TriageGuardrailResult(_plan=resolved_plan, _snapshot=snapshot) + pending_behind_objective_backlog = ( + not snapshot.has_triage_in_queue + and bool(resolved_state) + and is_mid_cycle(resolved_plan) + and has_objective_backlog(resolved_state, None) + ) + return TriageGuardrailResult( is_stale=True, + pending_behind_objective_backlog=pending_behind_objective_backlog, new_ids=set(snapshot.new_since_triage_ids), _plan=resolved_plan, _snapshot=snapshot, @@ -69,11 +81,17 @@ def triage_guardrail_messages( messages: list[str] = [] if result.new_ids: - messages.append( - f"{len(result.new_ids)} new review issue(s) not yet triaged." - " Run the staged triage runner to incorporate them " - f"(`{TRIAGE_CMD_RUN_STAGES_CODEX}` or `{TRIAGE_CMD_RUN_STAGES_CLAUDE}`)." - ) + if result.pending_behind_objective_backlog: + messages.append( + f"{len(result.new_ids)} new review issue(s) arrived since the last triage." + " They will activate after the current objective backlog is clear." + ) + else: + messages.append( + f"{len(result.new_ids)} new review issue(s) not yet triaged." + " Run the staged triage runner to incorporate them " + f"(`{TRIAGE_CMD_RUN_STAGES_CODEX}` or `{TRIAGE_CMD_RUN_STAGES_CLAUDE}`)." + ) if result._plan is not None: banner = triage_phase_banner(result._plan, resolved_state, snapshot=result._snapshot) @@ -98,11 +116,13 @@ def print_triage_guardrail_info( def require_triage_current_or_exit( *, state: dict, + plan: dict | None = None, + patterns: list[str] | None = None, bypass: bool = False, attest: str = "", ) -> None: """Gate: exit(1) if triage is stale and not bypassed. Name signals the exit.""" - result = triage_guardrail_status(state=state) + result = triage_guardrail_status(plan=plan, state=state) if not result.is_stale: return @@ -113,7 +133,29 @@ def require_triage_current_or_exit( )) return + if result.pending_behind_objective_backlog and patterns: + matched_targets = _matched_open_targets(state, patterns) + if matched_targets and not _targets_include_review_work(matched_targets): + banner = triage_phase_banner( + result._plan or {}, + state, + snapshot=result._snapshot, + ) + if banner: + print(colorize(f" {banner}", "yellow")) + return + new_ids = result.new_ids + if result.pending_behind_objective_backlog: + lines = [ + "BLOCKED: review issues changed since the last triage, but triage is pending" + " behind the current objective backlog.", + "", + " Finish current objective work first; triage will activate after the backlog clears.", + ' To bypass: --force-resolve --attest "I understand the plan may be stale..."', + ] + raise CommandError("\n".join(lines)) + lines = [ f"BLOCKED: {len(new_ids) or 'some'} new review issue(s) have not been triaged." ] @@ -135,6 +177,20 @@ def require_triage_current_or_exit( raise CommandError("\n".join(lines)) +def _matched_open_targets(state: dict, patterns: list[str]) -> list[dict]: + matched_by_id: dict[str, dict] = {} + for pattern in patterns: + for issue in state_mod.match_issues(state, pattern, status_filter="open"): + issue_id = str(issue.get("id", "")).strip() + if issue_id and issue_id not in matched_by_id: + matched_by_id[issue_id] = issue + return list(matched_by_id.values()) + + +def _targets_include_review_work(matched_targets: list[dict]) -> bool: + return any(issue.get("detector") in _REVIEW_DETECTORS for issue in matched_targets) + + __all__ = [ "TriageGuardrailResult", "print_triage_guardrail_info", diff --git a/desloppify/app/commands/plan/triage/lifecycle.py b/desloppify/app/commands/plan/triage/lifecycle.py index 75b0ac2f..1fcbfba8 100644 --- a/desloppify/app/commands/plan/triage/lifecycle.py +++ b/desloppify/app/commands/plan/triage/lifecycle.py @@ -58,7 +58,7 @@ def _print_triage_start_block(reason: str, *, deps: TriageLifecycleDeps) -> None ) return - print(deps.colorize(" Cannot start triage while objective backlog is still open.", "red")) + print(deps.colorize(" Triage is pending behind the current objective backlog.", "red")) print( deps.colorize( " Finish current objective work first, or pass --attestation " diff --git a/desloppify/app/commands/resolve/cmd.py b/desloppify/app/commands/resolve/cmd.py index 7ff69870..c47fbeee 100644 --- a/desloppify/app/commands/resolve/cmd.py +++ b/desloppify/app/commands/resolve/cmd.py @@ -74,6 +74,8 @@ def _load_state_with_guards( if args.status == "fixed": require_triage_current_or_exit( state=state, + plan=plan_access.plan if isinstance(plan_access.plan, dict) and not plan_access.degraded else None, + patterns=args.patterns, bypass=bool(getattr(args, "force_resolve", False)), attest=getattr(args, "attest", "") or "", ) diff --git a/desloppify/engine/plan_triage.py b/desloppify/engine/plan_triage.py index f389d6a3..85f30874 100644 --- a/desloppify/engine/plan_triage.py +++ b/desloppify/engine/plan_triage.py @@ -59,7 +59,7 @@ TriageStartDecision, decide_triage_start, ) -from desloppify.engine._plan.sync.context import has_objective_backlog +from desloppify.engine._plan.sync.context import has_objective_backlog, is_mid_cycle from desloppify.engine.plan_state import PlanModel, ensure_plan_defaults @@ -80,6 +80,16 @@ def triage_phase_banner( resolved_snapshot = snapshot or build_triage_snapshot(plan, resolved_state) if not resolved_snapshot.has_triage_in_queue: + if ( + resolved_state + and resolved_snapshot.is_triage_stale + and is_mid_cycle(plan) + and has_objective_backlog(resolved_state, None) + ): + return ( + "TRIAGE PENDING — review issues changed since last triage and will " + "activate after objective work is complete." + ) undispositioned = len(resolved_snapshot.undispositioned_ids) if undispositioned: return ( diff --git a/desloppify/tests/commands/helpers/test_guardrails.py b/desloppify/tests/commands/helpers/test_guardrails.py new file mode 100644 index 00000000..f0916bed --- /dev/null +++ b/desloppify/tests/commands/helpers/test_guardrails.py @@ -0,0 +1,93 @@ +"""Regression tests for triage guardrails around deferred mid-cycle triage.""" + +from __future__ import annotations + +import pytest + +from desloppify.app.commands.helpers.guardrails import ( + require_triage_current_or_exit, + triage_guardrail_messages, + triage_guardrail_status, +) +from desloppify.base.exception_sets import CommandError +from desloppify.engine._plan.schema import empty_plan + + +def _pending_triage_plan() -> dict: + plan = empty_plan() + plan["plan_start_scores"] = {"strict": 72.0} + plan["epic_triage_meta"] = {"triaged_ids": ["review::old"]} + return plan + + +def _pending_triage_state() -> dict: + return { + "issues": { + "obj::1": { + "id": "obj::1", + "status": "open", + "detector": "complexity", + "summary": "Objective work still open", + }, + "review::old": { + "id": "review::old", + "status": "open", + "detector": "review", + "summary": "Previously triaged review issue", + "detail": {"dimension": "naming"}, + }, + "review::new": { + "id": "review::new", + "status": "open", + "detector": "review", + "summary": "New review issue", + "detail": {"dimension": "naming"}, + }, + } + } + + +def test_triage_guardrail_status_marks_pending_behind_objective_backlog() -> None: + result = triage_guardrail_status( + plan=_pending_triage_plan(), + state=_pending_triage_state(), + ) + + assert result.is_stale is True + assert result.pending_behind_objective_backlog is True + assert result.new_ids == {"review::new"} + + +def test_triage_guardrail_messages_use_pending_copy_when_triage_is_deferred() -> None: + messages = triage_guardrail_messages( + plan=_pending_triage_plan(), + state=_pending_triage_state(), + ) + + assert any("activate after the current objective backlog is clear" in msg for msg in messages) + assert any(msg.startswith("TRIAGE PENDING") for msg in messages) + assert not any("Run the staged triage runner" in msg for msg in messages) + + +def test_require_triage_current_allows_objective_resolve_while_pending(capsys) -> None: + require_triage_current_or_exit( + state=_pending_triage_state(), + plan=_pending_triage_plan(), + patterns=["obj::1"], + attest="", + ) + + out = capsys.readouterr().out + assert "TRIAGE PENDING" in out + + +def test_require_triage_current_blocks_review_resolve_while_pending() -> None: + with pytest.raises(CommandError) as exc_info: + require_triage_current_or_exit( + state=_pending_triage_state(), + plan=_pending_triage_plan(), + patterns=["review::new"], + attest="", + ) + + assert "triage is pending behind the current objective backlog" in str(exc_info.value) diff --git a/desloppify/tests/plan/test_triage_phase_banner.py b/desloppify/tests/plan/test_triage_phase_banner.py index ca6c7fad..76e1fd06 100644 --- a/desloppify/tests/plan/test_triage_phase_banner.py +++ b/desloppify/tests/plan/test_triage_phase_banner.py @@ -26,6 +26,22 @@ def test_banner_pending_when_objective_backlog_exists(): assert banner.startswith("TRIAGE PENDING") +def test_banner_pending_when_stale_triage_is_deferred_behind_objective_backlog(): + plan = empty_plan() + plan["plan_start_scores"] = {"strict": 72.0} + plan["epic_triage_meta"] = {"triaged_ids": ["review::old"]} + state = { + "issues": { + "obj-1": {"id": "obj-1", "status": "open", "detector": "complexity"}, + "review::old": {"id": "review::old", "status": "open", "detector": "review"}, + "review::new": {"id": "review::new", "status": "open", "detector": "review"}, + } + } + + banner = triage_phase_banner(plan, state) + assert banner.startswith("TRIAGE PENDING") + + def test_banner_mode_when_no_objective_backlog(): plan = empty_plan() plan["queue_order"] = list(TRIAGE_STAGE_IDS)