Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions desloppify/app/commands/helpers/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,13 +20,15 @@
)

logger = logging.getLogger(__name__)
_REVIEW_DETECTORS = frozenset({"review", "concerns"})


@dataclass
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)
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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."
]
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion desloppify/app/commands/plan/triage/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
2 changes: 2 additions & 0 deletions desloppify/app/commands/resolve/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "",
)
Expand Down
12 changes: 11 additions & 1 deletion desloppify/engine/plan_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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 (
Expand Down
93 changes: 93 additions & 0 deletions desloppify/tests/commands/helpers/test_guardrails.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions desloppify/tests/plan/test_triage_phase_banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading