feat(toolchain): block QA/dev/PR gates when the suite cannot be executed

Adds _toolchain_broken_guard: when toolchain matching is on and the acting
agent's workspace recorded a 'broken' status (the project's suite cannot be
collected under the provisioned interpreter), i_am_done, pass_review, and
pr_pass refuse with an i_am_blocked remediation instead of letting the role
"pass" on a source read. fail_review / pr_fail stay available. git gains
toolchain_status_for_task (resolves the actor's workspace, reads the marker,
fail-open None). Inert when the flag is off; a missing/unknown status never
strands a task.
This commit is contained in:
Renn F
2026-06-22 01:49:44 +02:00
parent 036416878d
commit 0ea9f48187
6 changed files with 175 additions and 9 deletions
@@ -1663,6 +1663,8 @@ class Choreographer:
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._check_quality_gate(ctx):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._toolchain_broken_guard(ctx.agent_id, ctx.task):
return await self._reject_i_am_done(ctx, rejection)
# Pre-gateway parity: persist per-criterion
# status now that all gates have passed. The write runs AFTER the
# verdict so it cannot change i_am_done's rejection behavior.
@@ -1695,6 +1697,38 @@ class Choreographer:
context_briefing=ctx.briefing,
)
async def _toolchain_broken_guard(
self, agent_id: UUID, task: Any
) -> Envelope | None:
"""Refuse a delivery gate when the acting agent's workspace cannot run
the project's suite (interpreter mismatch).
A role that cannot execute the suite degrades to "verifying by reading
source", which the QA + PR-review gates exist to prevent. Inert when the
flag is off; only a recorded ``broken`` status blocks — a missing or
``unknown`` status never strands a task (fail-open).
"""
from roboco.config import settings as _settings
if not _settings.toolchain_match_enabled:
return None
status = await self.git.toolchain_status_for_task(agent_id, task)
if status != "broken":
return None
return Envelope.invalid_state(
message=(
"the project's test suite cannot be executed in this workspace "
"(interpreter mismatch) — verifying by reading source is hollow"
),
remediate=(
"the workspace Python does not match the project's requirement; "
"call i_am_blocked(reason='toolchain') so the environment is "
"rebuilt against the right interpreter — do NOT pass on a "
"source read"
),
context_briefing={},
)
async def _ensure_branch_pushed(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Push the task branch to origin before it reaches awaiting_qa.
@@ -53,6 +53,11 @@ class ChoreographerHelpers:
) -> Envelope:
raise NotImplementedError
async def _toolchain_broken_guard(
self, agent_id: UUID, task: Any
) -> Envelope | None:
raise NotImplementedError
@classmethod
def _free_text_soup(
cls, checks: tuple[tuple[str, Any, int], ...]
@@ -234,6 +234,17 @@ class PRGateMixin(_Base):
)
if gate is not None:
return gate
# A reviewer must not PASS an assembled PR whose suite cannot be run in
# the workspace (interpreter mismatch). pr_fail stays available.
if verb == "pr_pass" and (
toolchain := await self._toolchain_broken_guard(reviewer_agent_id, t)
):
return await self._emit_rejection(
toolchain.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
+35 -8
View File
@@ -448,6 +448,37 @@ class QAMixin(_Base):
verb=verb,
)
async def _qa_pass_final_gates(
self,
qa_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
ac_verdicts: list[str] | None,
) -> Envelope | None:
"""AC-coverage + toolchain-runnability gates for pass_review.
Returns the first rejection (already emitted), else None. QA must not
PASS on a workspace that cannot run the suite that is a source-read
"verification"; fail_review is unaffected.
"""
ac_rejection = self._qa_ac_coverage_check(t, ac_verdicts)
if ac_rejection is not None:
return await self._emit_rejection(
ac_rejection.with_introspection(task=t, role=role_str),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
if toolchain := await self._toolchain_broken_guard(qa_agent_id, t):
return await self._emit_rejection(
toolchain.with_introspection(task=t, role=role_str),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
return None
async def pass_review(
self,
qa_agent_id: UUID,
@@ -493,14 +524,10 @@ class QAMixin(_Base):
soup_checks=(("notes", notes, 8),),
):
return gate_rejection
ac_rejection = self._qa_ac_coverage_check(t, ac_verdicts)
if ac_rejection is not None:
return await self._emit_rejection(
ac_rejection.with_introspection(task=t, role=role_str),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
if rej := await self._qa_pass_final_gates(
qa_agent_id, task_id, t, role_str, ac_verdicts
):
return rej
briefing = await self._briefing_for(qa_agent_id, task_id)
spec_ctx = spec_module.Context(