[F101] enforce PR-open state gate on gateway open_pr (parity with HTTP path)

This commit is contained in:
Renn F
2026-06-28 21:23:56 +02:00
parent 3e768bbb89
commit c34e978f9e
3 changed files with 156 additions and 12 deletions
+41
View File
@@ -951,6 +951,46 @@ PRECONDITION_NON_TERMINAL = Precondition(
)
# The set of states from which a PR may be opened — the lifecycle-owned canon.
# The HTTP PR-create path (GitService._assert_pr_create_allowed) and the gateway
# ``open_pr`` intent must agree on this, so it lives here (the policy layer) as
# the single source and the service derives its str set from it. A PR opens
# during active dev (in_progress / verifying), the doc phase
# (awaiting_documentation), QA review (awaiting_qa), or a rework cycle
# (needs_revision) — never from claim/pause/block/terminal, which the HTTP path
# already blocked but the gateway ``open_pr`` (composes=() → no source-status
# gate) historically did not (F101).
PR_OPEN_STATES: frozenset[Status] = frozenset(
{
Status.IN_PROGRESS,
Status.VERIFYING,
Status.AWAITING_QA,
Status.AWAITING_DOCUMENTATION,
Status.NEEDS_REVISION,
}
)
def _p_pr_open_state(task: Any, _agent: Any, _ctx: Any) -> bool:
"""True iff the task is in a PR-open-eligible state (see ``PR_OPEN_STATES``)."""
status = getattr(task, "status", None)
value = status.value if isinstance(status, Status) else str(status)
return value in {s.value for s in PR_OPEN_STATES}
PRECONDITION_PR_OPEN_STATE = Precondition(
key="pr_open_state",
check=_p_pr_open_state,
remediate=(
"open_pr is only valid during active dev states "
"(in_progress / verifying / awaiting_qa / awaiting_documentation / "
"needs_revision); move the task into one of those first"
),
missing_token="pr_open_state",
rejection_kind="invalid_state",
)
_INTENT_VERBS: dict[str, IntentSpec] = {
# Phase 1: developer verbs
"give_me_work": IntentSpec(
@@ -1022,6 +1062,7 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
composes=(),
extra_preconditions=(
PRECONDITION_OWNERSHIP,
PRECONDITION_PR_OPEN_STATE,
PRECONDITION_COMMITS,
PRECONDITION_NO_PR,
),
+9 -12
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import os
import re
@@ -47,6 +48,7 @@ from roboco.exceptions import (
GitTimeoutError,
MergeConflictError,
)
from roboco.foundation.policy import lifecycle
from roboco.models.base import AgentRole, TaskStatus
from roboco.services.base import (
BaseService,
@@ -141,12 +143,10 @@ def _remove_stale_git_locks(workspace: Path) -> None:
return
try:
for lock in git_dir.rglob("*.lock"):
try:
# A lock a real process just grabbed, or a permission issue —
# leave it. The TTL/next-op path is the backstop.
with contextlib.suppress(OSError):
lock.unlink()
except OSError:
# A lock a real process just grabbed, or a permission issue —
# leave it. The TTL/next-op path is the backstop.
pass
except OSError:
return
@@ -2509,14 +2509,11 @@ class GitService(BaseService):
"updated_fields": updated,
}
# PR-open-eligible states — the lifecycle policy owns the canon
# (``PR_OPEN_STATES``); the HTTP path derives its str set from it so the
# gateway ``open_pr`` spec gate and this HTTP gate can never drift.
_PR_OPEN_STATES: ClassVar[frozenset[str]] = frozenset(
{
TaskStatus.IN_PROGRESS.value,
TaskStatus.VERIFYING.value,
TaskStatus.AWAITING_QA.value,
TaskStatus.AWAITING_DOCUMENTATION.value,
TaskStatus.NEEDS_REVISION.value,
}
s.value for s in lifecycle.PR_OPEN_STATES
)
@staticmethod