fix(gateway): bounded fail-open evidence assembly + PM decision transient-failure bypass

Evidence-assembly git legs (diff, changed-files, branch fetch, advisory
conventions run) ran unbounded inside claim_review / claim_doc_task /
claim_gate_review / evidence() / i_am_done's envelope build, so a slow
clone turned the whole verb into a silent 120s FlowVerbTimeout 504.
Each leg now runs through run_bounded_leg under a shared LegBudget
(evidence_assembly_timeout_seconds, 45s total): a timed-out leg — both
asyncio TimeoutError and git's own GitTimeoutError — degrades into an
evidence_gaps note on the envelope instead of hanging the verb, while
non-timeout git errors still propagate. The advisory conventions run
gets an inner-only timeout (conventions_validator_advisory_timeout_
seconds, 30s) threaded down to the subprocess so it is never orphaned
by an outer cancel; the fail-closed i_am_done/pr_pass conventions
gates keep their hardcoded 120s.

_ensure_pm_decision now reports a PmDecisionOutcome: a transient DB
failure recording the PM's decision journal (e.g. lock timeout under
load) no longer launders into a journal:decision gate rejection that
escalates and BLOCKS the task — the verb's own rationale satisfies the
gate with a structured warning, across all seven PM verbs.

Also: repo-wide ruff realignment to the lockfile-pinned ruff (8
format-only diffs, 14 UP038 isinstance conversions) that a transiently
newer venv ruff had masked.

Gate: 15474 passed, 459 skipped; ruff/mypy/xenon/vulture/bandit/
pip-audit/deptry/import-linter/foundation-check all green.
This commit is contained in:
Renn F
2026-07-31 13:56:19 +02:00
parent ef8849b0cd
commit ce4d02b3c2
41 changed files with 2160 additions and 138 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ def _verify_expiring_token(
except (ValueError, KeyError): except (ValueError, KeyError):
return False return False
exp = payload.get("exp") exp = payload.get("exp")
if not isinstance(exp, (int, float)): if not isinstance(exp, int | float):
return False return False
# Short-circuit like the original: time.time() is read only when the # Short-circuit like the original: time.time() is read only when the
# (id, role, team) fields already match, never on every call. # (id, role, team) fields already match, never on every call.
+1 -1
View File
@@ -66,7 +66,7 @@ async def revoke_and_logout(
) )
jti = data.get("jti") jti = data.get("jti")
exp = data.get("exp") exp = data.get("exp")
if isinstance(jti, str) and isinstance(exp, (int, float)): if isinstance(jti, str) and isinstance(exp, int | float):
await revocation.revoke_jti(jti, max(int(exp - time.time()), 1)) await revocation.revoke_jti(jti, max(int(exp - time.time()), 1))
except Exception: except Exception:
_logger.warning("logout jti revocation skipped", exc_info=True) _logger.warning("logout jti revocation skipped", exc_info=True)
+1 -1
View File
@@ -477,7 +477,7 @@ def _should_remint(token: str) -> bool:
}, },
) )
exp = data.get("exp") exp = data.get("exp")
if isinstance(exp, (int, float)): if isinstance(exp, int | float):
return (exp - time.time()) < settings.cloud_auth_remint_threshold_seconds return (exp - time.time()) < settings.cloud_auth_remint_threshold_seconds
except Exception: except Exception:
return True return True
+27
View File
@@ -1726,6 +1726,33 @@ class Settings(BaseSettings):
"the branch reached the remote." "the branch reached the remote."
), ),
) )
evidence_assembly_timeout_seconds: float = Field(
default=45.0,
ge=1.0,
description=(
"TOTAL budget for one advisory-evidence build (branch fetch, "
"diff, list_changed_files — every slow leg) on claim_review / "
"claim_doc_task / claim_gate_review / evidence() / i_am_done's "
"success envelope. A `LegBudget` (roboco.services.gateway."
"choreographer.evidence_legs) is created once per build and "
"shared across every leg in it — each leg's wait_for gets only "
"what's left of this total, shrinking as legs consume it, so "
"summing per-leg budgets can never exceed this cap (and stays "
"well under flow_verb_timeout_seconds). A hung leg degrades "
"(evidence_gaps) instead of taking the whole verb down with it."
),
)
conventions_validator_advisory_timeout_seconds: float = Field(
default=30.0,
ge=1.0,
description=(
"Ceiling for the conventions-validator subprocess on the "
"ADVISORY claim path (claim_review) — the actual budget used is "
"min(this, the build's remaining evidence_assembly_timeout_seconds), "
"so it also shrinks with the shared LegBudget. Fail-closed paths "
"(i_am_done, pr_pass) keep their own hardcoded cap unchanged."
),
)
protected_git_urls: list[str] = Field( protected_git_urls: list[str] = Field(
default_factory=list, default_factory=list,
+1 -1
View File
@@ -108,7 +108,7 @@ def _exp_from_jwt(token: str) -> datetime | None:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return None return None
exp = payload.get("exp") exp = payload.get("exp")
if not isinstance(exp, (int, float)): if not isinstance(exp, int | float):
return None return None
return datetime.fromtimestamp(float(exp), tz=UTC) return datetime.fromtimestamp(float(exp), tz=UTC)
+1 -1
View File
@@ -59,7 +59,7 @@ _USAGE_FIELDS = (
def _as_int(value: object) -> int: def _as_int(value: object) -> int:
return int(value) if isinstance(value, (int, float)) else 0 return int(value) if isinstance(value, int | float) else 0
def _usage_from_event(event: dict[str, Any]) -> dict[str, int] | None: def _usage_from_event(event: dict[str, Any]) -> dict[str, int] | None:
+1 -1
View File
@@ -95,7 +95,7 @@ _RATE_LIMIT_EXIT_CODE = 75
def _coerce_int(value: object) -> int: def _coerce_int(value: object) -> int:
return int(value) if isinstance(value, (int, float)) else 0 return int(value) if isinstance(value, int | float) else 0
def _model_tokens(entry: dict[str, Any]) -> tuple[int, int]: def _model_tokens(entry: dict[str, Any]) -> tuple[int, int]:
+2 -2
View File
@@ -202,7 +202,7 @@ def _exp_from_access_token(access_token: str) -> datetime | None:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return None return None
exp = payload.get("exp") exp = payload.get("exp")
if not isinstance(exp, (int, float)): if not isinstance(exp, int | float):
return None return None
return datetime.fromtimestamp(float(exp), tz=UTC) return datetime.fromtimestamp(float(exp), tz=UTC)
@@ -215,7 +215,7 @@ def _apply_refreshed_token(
if token.get("refresh_token"): if token.get("refresh_token"):
creds["refresh_token"] = token["refresh_token"] creds["refresh_token"] = token["refresh_token"]
expires_in = token.get("expires_in") expires_in = token.get("expires_in")
if isinstance(expires_in, (int, float)): if isinstance(expires_in, int | float):
creds["expires_at"] = _to_iso_z(now + timedelta(seconds=float(expires_in))) creds["expires_at"] = _to_iso_z(now + timedelta(seconds=float(expires_in)))
else: else:
# xAI's refresh response sometimes omits expires_in. The access token # xAI's refresh response sometimes omits expires_in. The access token
+2 -2
View File
@@ -86,9 +86,9 @@ def _extract_total_tokens(event: dict[str, Any]) -> int:
for meta in (update_meta, params_meta): for meta in (update_meta, params_meta):
if isinstance(meta, dict) and "totalTokens" in meta: if isinstance(meta, dict) and "totalTokens" in meta:
value = meta["totalTokens"] value = meta["totalTokens"]
return int(value) if isinstance(value, (int, float)) else 0 return int(value) if isinstance(value, int | float) else 0
value = event.get("totalTokens", 0) value = event.get("totalTokens", 0)
return int(value) if isinstance(value, (int, float)) else 0 return int(value) if isinstance(value, int | float) else 0
def find_updates_path(grok_home: Path, cwd: str, session_id: str) -> Path | None: def find_updates_path(grok_home: Path, cwd: str, session_id: str) -> Path | None:
+193 -32
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import contextlib import contextlib
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar, cast from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -33,6 +33,10 @@ from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer._protocol import actor_context_fields from roboco.services.gateway.choreographer._protocol import actor_context_fields
from roboco.services.gateway.choreographer._verb_runner import VerbRunner from roboco.services.gateway.choreographer._verb_runner import VerbRunner
from roboco.services.gateway.choreographer.collision import build_collision_context from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.gateway.choreographer.evidence_legs import (
LegBudget,
run_bounded_leg,
)
from roboco.services.gateway.claim_guards import ( from roboco.services.gateway.claim_guards import (
already_active_guard, already_active_guard,
paused_tasks_guard, paused_tasks_guard,
@@ -81,6 +85,17 @@ if TYPE_CHECKING:
from roboco.models.base import TaskNature from roboco.models.base import TaskNature
from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers
# _ensure_pm_decision's result: "fresh" (a recent decision already satisfies
# the window, nothing written), "wrote" (recorded now), "transient_failure"
# (the write raised WITH a non-empty rationale in hand — a DB-contention
# lock-timeout, not a real absence), "absent" (no rationale AND the window
# check never ran — legacy default for every call site that hasn't been
# threaded through yet). Every PM-decision gate helper accepts this as an
# optional param so "transient_failure" can satisfy the gate for THIS call
# (the rationale is in the verb payload; the write is a convenience, not the
# substance) without changing every verb's own public signature.
PmDecisionOutcome = Literal["fresh", "wrote", "transient_failure", "absent"]
# Minimum character length enforced on rich_plan["approach"] by the PM # Minimum character length enforced on rich_plan["approach"] by the PM
# sub-tasks gate. Must match the Pydantic min_length on # sub-tasks gate. Must match the Pydantic min_length on
# IWillPlanRequest.approach. Raised 20→150: plans were vague # IWillPlanRequest.approach. Raised 20→150: plans were vague
@@ -3384,7 +3399,7 @@ class Choreographer:
async def _ensure_pm_decision( async def _ensure_pm_decision(
self, agent_id: UUID, task_id: UUID, rationale: str | None self, agent_id: UUID, task_id: UUID, rationale: str | None
) -> None: ) -> PmDecisionOutcome:
"""Auto-record a journal:decision from a PM verb's own rationale. """Auto-record a journal:decision from a PM verb's own rationale.
The write-then-gate pattern (mirrors i_am_blocked write_struggle): The write-then-gate pattern (mirrors i_am_blocked write_struggle):
@@ -3399,7 +3414,10 @@ class Choreographer:
Idempotent within the decision window: when a fresh decision already Idempotent within the decision window: when a fresh decision already
satisfies the gate, no duplicate is written. Best-effort a journal satisfies the gate, no duplicate is written. Best-effort a journal
write failure is logged and swallowed so the verb falls through to write failure is logged and swallowed so the verb falls through to
the normal gate (which rejects as before), never crashing the verb. the normal gate which the caller can now short-circuit to PASS
(see ``PmDecisionOutcome`` / the gate helpers below) instead of
laundering a transient DB hiccup into a rejection the PM's own
rationale already answers.
Savepoint-guarded: the journal INSERT can lock-timeout on a Savepoint-guarded: the journal INSERT can lock-timeout on a
concurrent claim holding the task row's FK share lock (live concurrent claim holding the task row's FK share lock (live
@@ -3413,12 +3431,19 @@ class Choreographer:
below rolls back to, leaving the outer transaction and every below rolls back to, leaving the outer transaction and every
object this call didn't itself touch, e.g. the caller's ``t`` object this call didn't itself touch, e.g. the caller's ``t``
exactly as usable as if the write had never been attempted. exactly as usable as if the write had never been attempted.
Returns the outcome so the caller can thread it into the gate that
runs right after: "fresh" / "wrote" both mean a decision genuinely
exists; "transient_failure" means the write raised but a rationale
WAS in hand (the caller may treat the gate as satisfied for this
call); "absent" means there was no rationale to record at all (the
gate should reject exactly as before).
""" """
from roboco.config import settings as _settings from roboco.config import settings as _settings
text = (rationale or "").strip() text = (rationale or "").strip()
if not text: if not text:
return return "absent"
try: try:
async with self.task.session.begin_nested(): async with self.task.session.begin_nested():
latest = await self.journal.latest_decision_at(agent_id, task_id) latest = await self.journal.latest_decision_at(agent_id, task_id)
@@ -3427,19 +3452,27 @@ class Choreographer:
latest is not None latest is not None
and (datetime.now(UTC) - latest).total_seconds() <= window and (datetime.now(UTC) - latest).total_seconds() <= window
): ):
return return "fresh"
await self.journal.write_decision( await self.journal.write_decision(
agent_id=agent_id, task_id=task_id, content=text agent_id=agent_id, task_id=task_id, content=text
) )
except Exception as exc: # best-effort; gate rejects normally on failure return "wrote"
except Exception as exc: # best-effort; caller's gate handles the fallout
logger.warning( logger.warning(
"auto-record pm decision failed", "auto-record pm decision failed",
error=str(exc), error=str(exc),
task_id=str(task_id), task_id=str(task_id),
) )
return "transient_failure"
async def _check_pm_decision_required( async def _check_pm_decision_required(
self, verb: str, agent_id: UUID, task_id: UUID, t: Any self,
verb: str,
agent_id: UUID,
task_id: UUID,
t: Any,
*,
pm_decision_outcome: PmDecisionOutcome | None = None,
) -> Envelope | None: ) -> Envelope | None:
"""Standard PM-verb tracing gate driven by VERB_REQUIREMENTS. """Standard PM-verb tracing gate driven by VERB_REQUIREMENTS.
@@ -3454,6 +3487,15 @@ class Choreographer:
than ``settings.pm_decision_window_seconds``. Older decisions are than ``settings.pm_decision_window_seconds``. Older decisions are
treated as missing so PMs write a fresh decision around each treated as missing so PMs write a fresh decision around each
decision point rather than once at task creation. decision point rather than once at task creation.
``pm_decision_outcome`` (from the caller's own
``_ensure_pm_decision`` call, defaulting to ``None`` for legacy
parity) short-circuits the freshness check to satisfied when it is
``"transient_failure"`` the write lock-timed out under DB
contention, but the verb's own rationale WAS in hand and is the
substance the gate actually cares about; the write was only ever a
convenience. Any other value (or ``None``) leaves this check
byte-for-byte unchanged.
""" """
from roboco.config import settings as _settings from roboco.config import settings as _settings
from roboco.foundation.policy import tracing as _tr from roboco.foundation.policy import tracing as _tr
@@ -3468,6 +3510,14 @@ class Choreographer:
latest is not None latest is not None
and (datetime.now(UTC) - latest).total_seconds() <= window_seconds and (datetime.now(UTC) - latest).total_seconds() <= window_seconds
) )
gate_satisfied_by_rationale = pm_decision_outcome == "transient_failure"
if gate_satisfied_by_rationale:
logger.warning(
"pm decision auto-record failed transiently; gate satisfied "
"by verb rationale",
task_id=str(task_id),
verb=verb,
)
# QUICK_CONTEXT_MIN_CHARS applies only to ``delegate`` (its # QUICK_CONTEXT_MIN_CHARS applies only to ``delegate`` (its
# required-set is the only one carrying it); the PM pre-writes the # required-set is the only one carrying it); the PM pre-writes the
@@ -3475,7 +3525,7 @@ class Choreographer:
# Setting the threshold here is inert for unblock / escalate, which do # Setting the threshold here is inert for unblock / escalate, which do
# not require it. # not require it.
ctx = _tr.GateContext( ctx = _tr.GateContext(
journal_decision_present=fresh, journal_decision_present=fresh or gate_satisfied_by_rationale,
quick_context_min_chars=_settings.quick_context_min_chars, quick_context_min_chars=_settings.quick_context_min_chars,
) )
result = _tr.check_requirements( result = _tr.check_requirements(
@@ -3488,7 +3538,12 @@ class Choreographer:
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t) return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
async def _check_complete_gates( async def _check_complete_gates(
self, agent_id: UUID, task_id: UUID, notes: str self,
agent_id: UUID,
task_id: UUID,
notes: str,
*,
pm_decision_outcome: PmDecisionOutcome | None = None,
) -> Envelope | None: ) -> Envelope | None:
"""Tracing gate for cell-PM and main-PM ``complete`` verbs. """Tracing gate for cell-PM and main-PM ``complete`` verbs.
@@ -3498,6 +3553,13 @@ class Choreographer:
guards because that gate emits a richer remediation message guards because that gate emits a richer remediation message
listing the non-terminal subtasks; keeping it inline preserves listing the non-terminal subtasks; keeping it inline preserves
that UX. that UX.
``pm_decision_outcome`` mirrors ``_check_pm_decision_required``'s
param: ``"transient_failure"`` (the caller's ``_ensure_pm_decision``
write lock-timed out but a rationale WAS provided) satisfies both
the decision and reflect legs for this call same substitution
already applied to a genuinely-written decision below instead of
rejecting a DB hiccup as a missing decision.
""" """
from types import SimpleNamespace from types import SimpleNamespace
@@ -3506,9 +3568,17 @@ class Choreographer:
has_decision = await self.journal.has_decision_for_task(agent_id, task_id) has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id) has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
gate_satisfied_by_rationale = pm_decision_outcome == "transient_failure"
if gate_satisfied_by_rationale:
logger.warning(
"pm decision auto-record failed transiently; gate satisfied "
"by verb rationale",
task_id=str(task_id),
verb="complete",
)
task_view = SimpleNamespace(notes=notes) task_view = SimpleNamespace(notes=notes)
ctx = _tr.GateContext( ctx = _tr.GateContext(
journal_decision_present=has_decision, journal_decision_present=has_decision or gate_satisfied_by_rationale,
# A PM closing/submitting a task documents it in its *decision* note; # A PM closing/submitting a task documents it in its *decision* note;
# a separate *reflect* adds little for a coordination/review close and # a separate *reflect* adds little for a coordination/review close and
# is exactly the artifact weak-model PMs forget — looping on the # is exactly the artifact weak-model PMs forget — looping on the
@@ -3516,7 +3586,9 @@ class Choreographer:
# decision as satisfying reflect for the PM complete/submit_up close; # decision as satisfying reflect for the PM complete/submit_up close;
# the gate still requires a decision + substantive notes, so the close # the gate still requires a decision + substantive notes, so the close
# stays documented — only the redundant second-artifact demand drops. # stays documented — only the redundant second-artifact demand drops.
journal_reflect_present=has_reflect or has_decision, journal_reflect_present=(
has_reflect or has_decision or gate_satisfied_by_rationale
),
notes_min_chars=getattr(_settings, "notes_min_chars", 20), notes_min_chars=getattr(_settings, "notes_min_chars", 20),
) )
result = _tr.check_requirements( result = _tr.check_requirements(
@@ -3529,7 +3601,12 @@ class Choreographer:
return await self._build_tracing_gap(agent_id, task_id, result.missing) return await self._build_tracing_gap(agent_id, task_id, result.missing)
async def _check_submit_up_gates( async def _check_submit_up_gates(
self, agent_id: UUID, task_id: UUID, notes: str self,
agent_id: UUID,
task_id: UUID,
notes: str,
*,
pm_decision_outcome: PmDecisionOutcome | None = None,
) -> Envelope | None: ) -> Envelope | None:
"""Tracing gate for ``submit_up`` (cell PM bubble-up). """Tracing gate for ``submit_up`` (cell PM bubble-up).
@@ -3543,6 +3620,11 @@ class Choreographer:
``submit_root`` (both route through ``_submit_up_guard``), so ``submit_root`` (both route through ``_submit_up_guard``), so
``open_finding_ids`` covers pr_gate/pm/ceo-origin findings on either ``open_finding_ids`` covers pr_gate/pm/ceo-origin findings on either
a cell root or a Main-PM root. a cell root or a Main-PM root.
``pm_decision_outcome`` mirrors ``_check_complete_gates``'s param —
``"transient_failure"`` satisfies the decision + reflect legs for
this call instead of rejecting a DB-contention write hiccup as a
missing decision.
""" """
from types import SimpleNamespace from types import SimpleNamespace
@@ -3552,9 +3634,17 @@ class Choreographer:
has_decision = await self.journal.has_decision_for_task(agent_id, task_id) has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id) has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
open_finding_ids = await self._open_finding_ids(task_id) open_finding_ids = await self._open_finding_ids(task_id)
gate_satisfied_by_rationale = pm_decision_outcome == "transient_failure"
if gate_satisfied_by_rationale:
logger.warning(
"pm decision auto-record failed transiently; gate satisfied "
"by verb rationale",
task_id=str(task_id),
verb="submit_up",
)
task_view = SimpleNamespace(notes=notes) task_view = SimpleNamespace(notes=notes)
ctx = _tr.GateContext( ctx = _tr.GateContext(
journal_decision_present=has_decision, journal_decision_present=has_decision or gate_satisfied_by_rationale,
# A PM closing/submitting a task documents it in its *decision* note; # A PM closing/submitting a task documents it in its *decision* note;
# a separate *reflect* adds little for a coordination/review close and # a separate *reflect* adds little for a coordination/review close and
# is exactly the artifact weak-model PMs forget — looping on the # is exactly the artifact weak-model PMs forget — looping on the
@@ -3562,7 +3652,9 @@ class Choreographer:
# decision as satisfying reflect for the PM complete/submit_up close; # decision as satisfying reflect for the PM complete/submit_up close;
# the gate still requires a decision + substantive notes, so the close # the gate still requires a decision + substantive notes, so the close
# stays documented — only the redundant second-artifact demand drops. # stays documented — only the redundant second-artifact demand drops.
journal_reflect_present=has_reflect or has_decision, journal_reflect_present=(
has_reflect or has_decision or gate_satisfied_by_rationale
),
notes_min_chars=getattr(_settings, "notes_min_chars", 20), notes_min_chars=getattr(_settings, "notes_min_chars", 20),
open_finding_ids=open_finding_ids, open_finding_ids=open_finding_ids,
) )
@@ -3622,13 +3714,31 @@ class Choreographer:
files_changed sourced from git (authoritative) so the files_changed sourced from git (authoritative) so the
i_am_done envelope shows the same file list QA / docs / PMs will i_am_done envelope shows the same file list QA / docs / PMs will
see independent of legacy ``add_files_modified`` plumbing. see independent of legacy ``add_files_modified`` plumbing.
Runs strictly AFTER the composed transition already committed (the
caller already ran submit_verification/submit_qa) this is purely
informational, not a gate so the list_changed_files leg runs
bounded via ``run_bounded_leg``: a timeout must not strand the
dev's already-succeeded submit behind a hung response.
""" """
from roboco.config import settings as _settings
journal_highlights = await self.evidence_repo.journal_highlights_for_task( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id task_id
) )
files_changed: list[str] = [] files_changed: list[str] = []
evidence_gaps: list[str] = []
if t.branch_name: if t.branch_name:
files_changed = await self.git.list_changed_files(branch_name=t.branch_name) budget = LegBudget(_settings.evidence_assembly_timeout_seconds)
files_changed = await run_bounded_leg(
self.git.list_changed_files(branch_name=t.branch_name),
default=[],
budget=budget,
leg="files_changed",
hint="review the PR diff on GitHub directly",
task_id=task_id,
gaps=evidence_gaps,
)
# Normally empty here — i_am_done's FINDINGS_ADDRESSED gate already # Normally empty here — i_am_done's FINDINGS_ADDRESSED gate already
# required every open finding resolved before this point — but wired # required every open finding resolved before this point — but wired
# in for consistency with every other evidence call site. # in for consistency with every other evidence call site.
@@ -3640,6 +3750,7 @@ class Choreographer:
journal_highlights=journal_highlights, journal_highlights=journal_highlights,
files_changed=files_changed, files_changed=files_changed,
revision_findings=open_findings, revision_findings=open_findings,
evidence_gaps=evidence_gaps,
) )
agent = await self.task.agent_for(agent_id) agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer" role = str(agent.role) if agent is not None else "developer"
@@ -5486,7 +5597,7 @@ class Choreographer:
# Write-then-gate: the delegated subtask's title + description (the # Write-then-gate: the delegated subtask's title + description (the
# PM's own articulation of the work) is recorded as the # PM's own articulation of the work) is recorded as the
# journal:decision the tracing guard below requires. # journal:decision the tracing guard below requires.
await self._ensure_pm_decision( pm_decision_outcome = await self._ensure_pm_decision(
pm_agent_id, pm_agent_id,
parent_task_id, parent_task_id,
f"Delegating subtask '{inputs.title}': {inputs.description}", f"Delegating subtask '{inputs.title}': {inputs.description}",
@@ -5496,7 +5607,12 @@ class Choreographer:
# coercion + assignee-vs-task_type, parent-ownership/subtask-cap, and # coercion + assignee-vs-task_type, parent-ownership/subtask-cap, and
# (last) decomposition-coverage. # (last) decomposition-coverage.
guard = await self._delegate_post_spec_guards( guard = await self._delegate_post_spec_guards(
pm_agent_id, parent_task_id, parent, role_str, inputs pm_agent_id,
parent_task_id,
parent,
role_str,
inputs,
pm_decision_outcome=pm_decision_outcome,
) )
if guard is not None: if guard is not None:
return await self._emit_rejection( return await self._emit_rejection(
@@ -5516,6 +5632,8 @@ class Choreographer:
parent: Any, parent: Any,
role_str: str, role_str: str,
inputs: DelegateInputs, inputs: DelegateInputs,
*,
pm_decision_outcome: PmDecisionOutcome,
) -> Envelope | None: ) -> Envelope | None:
"""``_delegate_extra_guards``, then the decomposition-coverage gate. """``_delegate_extra_guards``, then the decomposition-coverage gate.
@@ -5526,7 +5644,12 @@ class Choreographer:
gate only ever fires on an otherwise-valid delegate. gate only ever fires on an otherwise-valid delegate.
""" """
guard = await self._delegate_extra_guards( guard = await self._delegate_extra_guards(
pm_agent_id, parent_task_id, parent, role_str, inputs pm_agent_id,
parent_task_id,
parent,
role_str,
inputs,
pm_decision_outcome=pm_decision_outcome,
) )
if guard is not None: if guard is not None:
return guard return guard
@@ -5608,6 +5731,8 @@ class Choreographer:
parent: Any, parent: Any,
role_str: str, role_str: str,
inputs: DelegateInputs, inputs: DelegateInputs,
*,
pm_decision_outcome: PmDecisionOutcome,
) -> Envelope | None: ) -> Envelope | None:
"""Delegate-specific guards the spec doesn't model. """Delegate-specific guards the spec doesn't model.
@@ -5621,7 +5746,11 @@ class Choreographer:
""" """
# Pre-gateway PM.md required journal:decision before each delegate. # Pre-gateway PM.md required journal:decision before each delegate.
if env := await self._check_pm_decision_required( if env := await self._check_pm_decision_required(
"delegate", pm_agent_id, parent_task_id, parent "delegate",
pm_agent_id,
parent_task_id,
parent,
pm_decision_outcome=pm_decision_outcome,
): ):
return env return env
chain_error = self._validate_delegation_chain(role_str, inputs.assigned_to) chain_error = self._validate_delegation_chain(role_str, inputs.assigned_to)
@@ -6970,8 +7099,12 @@ class Choreographer:
""" """
# Write-then-gate: the bubble-up rationale (notes, already validated # Write-then-gate: the bubble-up rationale (notes, already validated
# >= min by the ownership guard) becomes the journal:decision. # >= min by the ownership guard) becomes the journal:decision.
await self._ensure_pm_decision(pm_agent_id, task_id, notes) pm_decision_outcome = await self._ensure_pm_decision(
if env := await self._check_submit_up_gates(pm_agent_id, task_id, notes): pm_agent_id, task_id, notes
)
if env := await self._check_submit_up_gates(
pm_agent_id, task_id, notes, pm_decision_outcome=pm_decision_outcome
):
return env return env
if env := await self._subtasks_not_terminal_envelope( if env := await self._subtasks_not_terminal_envelope(
pm_agent_id, task_id, context_phrase="bubbling up" pm_agent_id, task_id, context_phrase="bubbling up"
@@ -7197,9 +7330,11 @@ class Choreographer:
# journal:decision the gate below requires, so a PM that didn't # journal:decision the gate below requires, so a PM that didn't
# pre-call note(scope='decision') doesn't stall in a tracing_gap # pre-call note(scope='decision') doesn't stall in a tracing_gap
# respawn loop. # respawn loop.
await self._ensure_pm_decision(pm_agent_id, task_id, reason) pm_decision_outcome = await self._ensure_pm_decision(
pm_agent_id, task_id, reason
)
if env := await self._check_pm_decision_required( if env := await self._check_pm_decision_required(
"unblock", pm_agent_id, task_id, t "unblock", pm_agent_id, task_id, t, pm_decision_outcome=pm_decision_outcome
): ):
return await self._emit_rejection( return await self._emit_rejection(
env.with_introspection(task=t, role=role), env.with_introspection(task=t, role=role),
@@ -7456,8 +7591,12 @@ class Choreographer:
) )
# Write-then-gate: the PM's merge rationale (notes) becomes the # Write-then-gate: the PM's merge rationale (notes) becomes the
# journal:decision the gate requires — no separate note() call needed. # journal:decision the gate requires — no separate note() call needed.
await self._ensure_pm_decision(pm_agent_id, task_id, notes) pm_decision_outcome = await self._ensure_pm_decision(
if env := await self._check_complete_gates(pm_agent_id, task_id, notes): pm_agent_id, task_id, notes
)
if env := await self._check_complete_gates(
pm_agent_id, task_id, notes, pm_decision_outcome=pm_decision_outcome
):
return env return env
if env := ( if env := (
await self._subtasks_not_terminal_envelope( await self._subtasks_not_terminal_envelope(
@@ -8236,9 +8375,14 @@ class Choreographer:
) )
# Write-then-gate: the Main PM's root-close rationale (notes) becomes # Write-then-gate: the Main PM's root-close rationale (notes) becomes
# the journal:decision the gate requires. # the journal:decision the gate requires.
await self._ensure_pm_decision(main_pm_agent_id, root_task_id, notes) pm_decision_outcome = await self._ensure_pm_decision(
if env := await self._check_complete_gates(
main_pm_agent_id, root_task_id, notes main_pm_agent_id, root_task_id, notes
)
if env := await self._check_complete_gates(
main_pm_agent_id,
root_task_id,
notes,
pm_decision_outcome=pm_decision_outcome,
): ):
return env return env
if env := ( if env := (
@@ -8731,9 +8875,16 @@ class Choreographer:
# Write-then-gate: the escalation reason becomes the journal:decision # Write-then-gate: the escalation reason becomes the journal:decision
# the preflight gate requires. # the preflight gate requires.
await self._ensure_pm_decision(pm_agent_id, task_id, reason) pm_decision_outcome = await self._ensure_pm_decision(
pm_agent_id, task_id, reason
)
preflight = await self._escalate_up_preflight( preflight = await self._escalate_up_preflight(
pm_agent_id, t, me, briefing, role_str pm_agent_id,
t,
me,
briefing,
role_str,
pm_decision_outcome=pm_decision_outcome,
) )
if preflight is not None: if preflight is not None:
return await self._emit_rejection( return await self._emit_rejection(
@@ -8778,6 +8929,8 @@ class Choreographer:
me: Any, me: Any,
briefing: dict[str, Any], briefing: dict[str, Any],
role_str: str, role_str: str,
*,
pm_decision_outcome: PmDecisionOutcome,
) -> Envelope | None: ) -> Envelope | None:
"""Verb-specific preflight gates for escalate_up. """Verb-specific preflight gates for escalate_up.
@@ -8789,7 +8942,11 @@ class Choreographer:
``VERB_REQUIREMENTS["escalate_up"]``. ``VERB_REQUIREMENTS["escalate_up"]``.
""" """
if env := await self._check_pm_decision_required( if env := await self._check_pm_decision_required(
"escalate_up", pm_agent_id, t.id, t "escalate_up",
pm_agent_id,
t.id,
t,
pm_decision_outcome=pm_decision_outcome,
): ):
return env.with_introspection(task=t, role=role_str) return env.with_introspection(task=t, role=role_str)
target_slug = me.escalation_target if me else None target_slug = me.escalation_target if me else None
@@ -8909,9 +9066,13 @@ class Choreographer:
# completed subtask covered — inert until coverage is declared). # completed subtask covered — inert until coverage is declared).
# Write-then-gate: the escalation reason becomes the journal:decision # Write-then-gate: the escalation reason becomes the journal:decision
# the gate below requires. # the gate below requires.
await self._ensure_pm_decision(agent_id, task_id, reason) pm_decision_outcome = await self._ensure_pm_decision(agent_id, task_id, reason)
env = await self._check_pm_decision_required( env = await self._check_pm_decision_required(
"escalate_to_ceo", agent_id, task_id, t "escalate_to_ceo",
agent_id,
task_id,
t,
pm_decision_outcome=pm_decision_outcome,
) or await self._parent_acs_covered_envelope( ) or await self._parent_acs_covered_envelope(
agent_id, task_id, context_phrase="escalating to CEO" agent_id, task_id, context_phrase="escalating to CEO"
) )
+59 -8
View File
@@ -45,6 +45,10 @@ from roboco.foundation.policy import tracing as _tr
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.models.task import DocRef from roboco.models.task import DocRef
from roboco.services.gateway.choreographer import findings as findings_lib from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer.evidence_legs import (
LegBudget,
run_bounded_leg,
)
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task from roboco.services.gateway.evidence_builder import build_evidence_for_task
@@ -196,13 +200,29 @@ class DocMixin(_Base):
# the task branch already exists (dev created it) so no checkout # the task branch already exists (dev created it) so no checkout
# ran in the doc's workspace. Put the doc on the task branch now # ran in the doc's workspace. Put the doc on the task branch now
# so roboco_docs_write / commit don't fail BRANCH_MISMATCH. # so roboco_docs_write / commit don't fail BRANCH_MISMATCH.
# Best-effort — a checkout hiccup must not fail the claim. # Best-effort — a checkout hiccup must not fail the claim. Bounded
# against the SAME shared LegBudget the evidence legs below use
# (one budget per build), so this leg + diff + list_changed_files
# can never sum past the total evidence-assembly budget.
budget = LegBudget(settings.evidence_assembly_timeout_seconds)
gaps: list[str] = []
if t.branch_name: if t.branch_name:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await self.git.checkout_branch_in_agent_workspace( await run_bounded_leg(
t.branch_name, actor_agent_id=doc_agent_id self.git.checkout_branch_in_agent_workspace(
t.branch_name, actor_agent_id=doc_agent_id
),
default=None,
budget=budget,
leg="workspace checkout",
hint=(
"the diff below may reflect a stale worktree; "
"re-run roboco_git_diff to confirm"
),
task_id=task_id,
gaps=gaps,
) )
ev = await self._claim_doc_evidence(t, task_id) ev = await self._claim_doc_evidence(t, task_id, budget=budget, extra_gaps=gaps)
return Envelope.ok( return Envelope.ok(
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
@@ -211,19 +231,49 @@ class DocMixin(_Base):
context_briefing=briefing, context_briefing=briefing,
).with_introspection(task=t, role=role_str) ).with_introspection(task=t, role=role_str)
async def _claim_doc_evidence(self, task: Any, task_id: UUID) -> dict[str, Any]: async def _claim_doc_evidence(
self,
task: Any,
task_id: UUID,
*,
budget: LegBudget,
extra_gaps: list[str] | None = None,
) -> dict[str, Any]:
"""Build the evidence dict surfaced inline on claim_doc_task ok envelopes. """Build the evidence dict surfaced inline on claim_doc_task ok envelopes.
files_changed sourced from git (authoritative) instead files_changed sourced from git (authoritative) instead
of ``work_session.files_modified``, which the gateway commit() of ``work_session.files_modified``, which the gateway commit()
does not populate. The docs writer sees an accurate file list. does not populate. The docs writer sees an accurate file list.
The diff/list_changed_files legs run bounded via ``run_bounded_leg``
against ``budget`` the SAME shared instance the caller's checkout
leg already drew from, so a timeout skips that piece and records a
note in ``evidence_gaps`` instead of hanging this advisory verb, and
the whole build (checkout + diff + list_changed_files) stays under
one total. ``extra_gaps`` folds in the caller's own pre-evidence
gaps (the checkout leg above).
""" """
files_changed: list[str] = [] files_changed: list[str] = []
diff = "" diff = ""
evidence_gaps: list[str] = list(extra_gaps or [])
if task.branch_name: if task.branch_name:
diff = await self.git.diff(branch_name=task.branch_name) diff = await run_bounded_leg(
files_changed = await self.git.list_changed_files( self.git.diff(branch_name=task.branch_name),
branch_name=task.branch_name default="",
budget=budget,
leg="pr diff",
hint="review the PR diff on GitHub directly",
task_id=task_id,
gaps=evidence_gaps,
)
files_changed = await run_bounded_leg(
self.git.list_changed_files(branch_name=task.branch_name),
default=[],
budget=budget,
leg="files_changed",
hint="review the PR diff on GitHub directly",
task_id=task_id,
gaps=evidence_gaps,
) )
journal_highlights = await self.evidence_repo.journal_highlights_for_task( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id task_id
@@ -237,6 +287,7 @@ class DocMixin(_Base):
files_changed=files_changed, files_changed=files_changed,
pr_diff_summary=diff, pr_diff_summary=diff,
revision_findings=open_findings, revision_findings=open_findings,
evidence_gaps=evidence_gaps,
).as_dict() ).as_dict()
async def _verify_doc_owner( async def _verify_doc_owner(
@@ -0,0 +1,133 @@
"""Bounded advisory-evidence legs for claim_review / claim_doc_task /
claim_gate_review / evidence() / i_am_done's success envelope.
Claim-time (and i_am_done's post-transition) evidence assembly is advisory,
not gating: a slow branch fetch, diff, or list_changed_files must degrade
the evidence rather than hold the whole flow-verb request (and its
transaction's row locks) hostage for the full ``flow_verb_timeout_seconds``
budget. ``run_bounded_leg`` wraps one such awaitable in ``asyncio.wait_for``;
on a timeout it appends a human-readable entry to the caller's
``evidence_gaps`` list, logs one structured warning, and returns the
caller's ``default`` instead of propagating. Fail-closed paths (``i_am_done``
/ ``pr_pass`` conventions enforcement) do not use this they keep their
existing hard cap.
Two distinct timeout shapes get caught, because they fire from different
layers:
- ``TimeoutError`` asyncio's own cancellation-converted timeout, raised by
``asyncio.wait_for`` itself when ``coro`` is still running at the deadline
(e.g. a slow DB read, a lock wait, or a git op that outlives its own
internal bound).
- ``GitTimeoutError`` ``GitService._run_git``'s own internal subprocess
bound (``settings.git_command_timeout_seconds``, 30s by default usually
SHORTER than a leg's own budget, making this the most common real-world
timeout shape for a single hung git call). It is a ``GitError`` /
``RobocoError`` subclass, NOT a ``TimeoutError`` subclass, and is raised
from INSIDE the coroutine (the subprocess itself gave up), not by
``wait_for``'s cancellation. Any OTHER ``GitError`` (a real command
failure bad ref, auth, network refusal, not a timeout) still propagates
uncaught; only a timeout degrades.
The conventions-validator subprocess leg does NOT go through
``run_bounded_leg`` see ``QAMixin._qa_convention_findings``'s docstring
for why nesting an outer wait_for around a coroutine with its own inner
wait_for-based cleanup (``proc.kill()`` / ``proc.wait()``) can orphan the
child process.
"""
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any
import structlog
from roboco.exceptions import GitTimeoutError
if TYPE_CHECKING:
from collections.abc import Coroutine
logger = structlog.get_logger()
# Floor so a nearly-exhausted budget still gives the next leg a real chance
# to run (and a real wait_for timeout > 0) instead of skipping it outright.
_MIN_LEG_SECONDS = 1.0
class LegBudget:
"""A shared, once-per-evidence-build deadline for a sequence of legs.
Per-leg budgets summed naively can exceed the flow-verb wall (e.g. three
45s legs = 135s, well past the 120s verb timeout). One ``LegBudget``
instance per evidence build gives each leg only what's left of the
TOTAL assembly budget shrinking as legs consume it, never resetting
per leg so the whole build's wall time is capped near the configured
total regardless of how many legs it runs.
"""
__slots__ = ("_deadline",)
def __init__(self, total_seconds: float) -> None:
self._deadline = time.monotonic() + total_seconds
def remaining(self) -> float:
"""Seconds left before the deadline, floored at ``_MIN_LEG_SECONDS``."""
return max(_MIN_LEG_SECONDS, self._deadline - time.monotonic())
async def run_bounded_leg[T](
coro: Coroutine[Any, Any, T],
*,
default: T,
budget: LegBudget,
leg: str,
hint: str,
task_id: Any,
gaps: list[str],
) -> T:
"""Await ``coro`` bounded by ``budget``'s remaining time.
Degrades to ``default`` and appends one entry to ``gaps`` on either
timeout shape (see module docstring); the entry names which bound
tripped so a reader can tell a slow-but-alive leg (the assembly budget)
from a git subprocess that gave up on its own shorter bound.
Cancelling a ``run_in_executor``/``asyncio.to_thread``-backed git call
abandons the worker thread this only stops AWAITING it, not the
underlying subprocess. Each git-touching leg's own subprocess call is
independently bounded (``git_command_timeout_seconds`` inside
``_run_git``, or an explicit ``subprocess_timeout`` on
``fetch_branch_for_inspection``) so the thread still self-terminates
near its own window rather than running to completion unbounded.
"""
timeout = budget.remaining()
try:
return await asyncio.wait_for(coro, timeout=timeout)
except TimeoutError:
gaps.append(
f"{leg} unavailable: timed out after {timeout:.0f}s "
f"(evidence-assembly budget) — {hint}"
)
logger.warning(
"evidence_leg_timeout",
leg=leg,
timeout=timeout,
task_id=str(task_id),
bound="assembly_budget",
)
return default
except GitTimeoutError as exc:
gaps.append(
f"{leg} unavailable: a git command timed out after {exc.timeout}s "
f"(git_command_timeout_seconds) — {hint}"
)
logger.warning(
"evidence_leg_timeout",
leg=leg,
timeout=exc.timeout,
task_id=str(task_id),
bound="git_command_timeout",
)
return default
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any
import structlog import structlog
from roboco.config import settings
from roboco.foundation.policy import lifecycle as spec_module from roboco.foundation.policy import lifecycle as spec_module
from roboco.foundation.policy import tracing as _tr from roboco.foundation.policy import tracing as _tr
from roboco.foundation.policy.batch import is_batch_root_subtask from roboco.foundation.policy.batch import is_batch_root_subtask
@@ -29,6 +30,10 @@ from roboco.foundation.policy.content import (
) )
from roboco.services.gateway.choreographer import findings as findings_lib from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer.collision import build_collision_context from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.gateway.choreographer.evidence_legs import (
LegBudget,
run_bounded_leg,
)
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import render_findings from roboco.services.gateway.evidence_builder import render_findings
from roboco.services.gateway.merge_chain import resolve_parent_branch from roboco.services.gateway.merge_chain import resolve_parent_branch
@@ -1243,16 +1248,40 @@ class PRGateMixin(_Base):
criteria + the task's OPEN findings (so they aren't crowded out by criteria + the task's OPEN findings (so they aren't crowded out by
the full ledger's cap) + the full findings ledger (every status, the full ledger's cap) + the full findings ledger (every status,
newest round first) so the reviewer verifies prior rounds newest round first) so the reviewer verifies prior rounds
item-by-item parity with QA's ``_build_qa_claim_evidence``.""" item-by-item parity with QA's ``_build_qa_claim_evidence``.
The diff + changed-files legs run bounded via ``run_bounded_leg``
against ONE shared ``LegBudget`` for this build a timeout skips
that piece and records a note in ``evidence_gaps`` instead of
hanging this advisory (non-gating) verb.
"""
diff = "" diff = ""
files_changed: list[str] = [] files_changed: list[str] = []
evidence_gaps: list[str] = []
if t.branch_name: if t.branch_name:
gate_parent = await self._gate_diff_parent(t) gate_parent = await self._gate_diff_parent(t)
diff = await self.git.diff( budget = LegBudget(settings.evidence_assembly_timeout_seconds)
branch_name=t.branch_name, diff = await run_bounded_leg(
preferred_parent=gate_parent, self.git.diff(
branch_name=t.branch_name,
preferred_parent=gate_parent,
),
default="",
budget=budget,
leg="pr diff",
hint="review the PR diff on GitHub directly",
task_id=t.id,
gaps=evidence_gaps,
)
files_changed = await run_bounded_leg(
self._gate_changed_files(t, gate_parent),
default=[],
budget=budget,
leg="files_changed",
hint="review the PR diff on GitHub directly",
task_id=t.id,
gaps=evidence_gaps,
) )
files_changed = await self._gate_changed_files(t, gate_parent)
open_findings = await findings_lib.open_findings_for_task( open_findings = await findings_lib.open_findings_for_task(
self.task.session, t.id self.task.session, t.id
) )
@@ -1287,4 +1316,6 @@ class PRGateMixin(_Base):
collision = await self._gate_collision_evidence(t, files_changed) collision = await self._gate_collision_evidence(t, files_changed)
if collision: if collision:
evidence["collision_context"] = collision evidence["collision_context"] = collision
if evidence_gaps:
evidence["evidence_gaps"] = evidence_gaps
return evidence return evidence
+79 -5
View File
@@ -52,6 +52,10 @@ from roboco.services.content_notes import apply_structured_note
from roboco.services.gateway.choreographer import findings as findings_lib from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer._protocol import actor_context_fields from roboco.services.gateway.choreographer._protocol import actor_context_fields
from roboco.services.gateway.choreographer.collision import build_collision_context from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.gateway.choreographer.evidence_legs import (
LegBudget,
run_bounded_leg,
)
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task from roboco.services.gateway.evidence_builder import build_evidence_for_task
@@ -190,19 +194,54 @@ class QAMixin(_Base):
).with_introspection(task=t, role=role_str) ).with_introspection(task=t, role=role_str)
async def _qa_convention_findings( async def _qa_convention_findings(
self, qa_agent_id: UUID, t: Any self, qa_agent_id: UUID, t: Any, *, timeout: float, gaps: list[str]
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Convention-validator findings on the task's changed files (flag-gated). """Convention-validator findings on the task's changed files (flag-gated).
Empty when the subsystem is off; a validator that could not run surfaces Empty when the subsystem is off; a validator that could not run surfaces
a single explicit ``could_not_run`` entry rather than being dropped, so a single explicit ``could_not_run`` entry rather than being dropped, so
QA never mistakes a silent failure for a clean diff. QA never mistakes a silent failure for a clean diff.
Deliberately NOT wrapped in ``run_bounded_leg``. Nesting an outer
``asyncio.wait_for`` around this call raced
``conventions_check_for_task``'s own inner ``wait_for`` (which
starts LATER, after setup) the outer cancellation could fire
first and land INSIDE the inner ``except TimeoutError:``'s
``proc.kill()``/``proc.wait()`` cleanup, skipping it and leaking the
validator subprocess (reproduced live). ``conventions_check_for_task``
already self-bounds the subprocess correctly via its own ``timeout``
kwarg; this method just reads the result and, on a detected
timeout, records the gap itself instead of relying on an outer
wrapper to notice.
``timeout`` is this build's remaining share of the shared
evidence-assembly budget (``LegBudget``), already capped by the
caller at ``conventions_validator_advisory_timeout_seconds`` so
this never exceeds its own designed ceiling but shrinks if earlier
legs (diff, list_changed_files) consumed most of the shared budget.
The setup phase inside ``conventions_check_for_task`` (workspace
resolution + its own ``list_changed_files`` call) is not separately
wrapped here: every individual git op there is already bounded by
``git_command_timeout_seconds`` inside ``_run_git`` (raising
``GitTimeoutError`` on expiry, caught by ``conventions_check_for_task``'s
own resolution try/except), and by the time this runs the workspace
was already resolved (warmed) by the diff/list_changed_files legs
above in the same evidence build so the one theoretically-unbounded
piece (a cold clone) never actually triggers here in practice.
""" """
if not settings.conventions_enabled: if not settings.conventions_enabled:
return [] return []
result = await self.git.conventions_check_for_task(qa_agent_id, t) result = await self.git.conventions_check_for_task(
qa_agent_id, t, timeout=timeout
)
if result.get("could_not_run"): if result.get("could_not_run"):
reason = result.get("reason") or "validator could not run" reason = result.get("reason") or "validator could not run"
if "timed out" in reason:
gaps.append(
f"conventions findings unavailable: {reason} — review the "
"diff manually for architecture-convention issues"
)
return [{"could_not_run": True, "reason": reason}] return [{"could_not_run": True, "reason": reason}]
return list(result.get("findings", [])) return list(result.get("findings", []))
@@ -242,12 +281,38 @@ class QAMixin(_Base):
``add_files_modified`` HTTP path that populated files_modified ``add_files_modified`` HTTP path that populated files_modified
is not called by the gateway ``commit()``, so the work_session is not called by the gateway ``commit()``, so the work_session
list was always empty QA saw no files even on real PRs. list was always empty QA saw no files even on real PRs.
The slow legs (branch-fetch-backed diff, list_changed_files) run
bounded via ``run_bounded_leg`` against ONE shared ``LegBudget`` for
this whole build a timeout skips that piece and records a note in
``evidence_gaps`` instead of hanging this advisory (non-gating) verb
for the whole ``flow_verb_timeout_seconds`` budget. The conventions
leg self-bounds separately (see ``_qa_convention_findings``) but
still draws its ceiling from the same shared budget.
""" """
files_changed: list[str] = [] files_changed: list[str] = []
diff_summary = "" diff_summary = ""
evidence_gaps: list[str] = []
budget = LegBudget(settings.evidence_assembly_timeout_seconds)
if t.branch_name: if t.branch_name:
diff_summary = await self.git.diff(branch_name=t.branch_name) diff_summary = await run_bounded_leg(
files_changed = await self.git.list_changed_files(branch_name=t.branch_name) self.git.diff(branch_name=t.branch_name),
default="",
budget=budget,
leg="pr diff",
hint="review the PR diff on GitHub directly",
task_id=task_id,
gaps=evidence_gaps,
)
files_changed = await run_bounded_leg(
self.git.list_changed_files(branch_name=t.branch_name),
default=[],
budget=budget,
leg="files_changed",
hint="review the PR diff on GitHub directly",
task_id=task_id,
gaps=evidence_gaps,
)
journal_highlights = await self.evidence_repo.journal_highlights_for_task( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id task_id
) )
@@ -256,7 +321,15 @@ class QAMixin(_Base):
# Leaf-only journals stay (include_ancestors defaults False above); # Leaf-only journals stay (include_ancestors defaults False above);
# ancestor *descriptions* are the ask, not work-so-far. # ancestor *descriptions* are the ask, not work-so-far.
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id) parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
convention_findings = await self._qa_convention_findings(qa_agent_id, t) convention_findings = await self._qa_convention_findings(
qa_agent_id,
t,
timeout=min(
settings.conventions_validator_advisory_timeout_seconds,
budget.remaining(),
),
gaps=evidence_gaps,
)
open_findings = await findings_lib.open_findings_for_task( open_findings = await findings_lib.open_findings_for_task(
self.task.session, t.id self.task.session, t.id
) )
@@ -291,6 +364,7 @@ class QAMixin(_Base):
parent_context=parent_context, parent_context=parent_context,
collision_context=collision_context, collision_context=collision_context,
video_context=self._qa_video_context(t), video_context=self._qa_video_context(t),
evidence_gaps=evidence_gaps,
) )
async def _verify_qa_owner( async def _verify_qa_owner(
+53 -6
View File
@@ -35,6 +35,10 @@ from roboco.foundation.policy.journaling import Scope as _Scope
from roboco.models.base import TaskStatus from roboco.models.base import TaskStatus
from roboco.services.content_notes import content_type_for_role from roboco.services.content_notes import content_type_for_role
from roboco.services.gateway.choreographer import findings as findings_lib from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer.evidence_legs import (
LegBudget,
run_bounded_leg,
)
from roboco.services.gateway.commit_validator import validate_commit_message from roboco.services.gateway.commit_validator import validate_commit_message
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task from roboco.services.gateway.evidence_builder import build_evidence_for_task
@@ -5387,6 +5391,13 @@ class ContentActions:
``files_changed`` and ``pr_diff_summary`` are pulled from git (against ``files_changed`` and ``pr_diff_summary`` are pulled from git (against
the branch's parent — the authoritative source) rather than the latest the branch's parent — the authoritative source) rather than the latest
commit's delta, so reviewers see the full multi-commit change set. commit's delta, so reviewers see the full multi-commit change set.
The three slow legs (workspace branch fetch, diff, list_changed_files)
each run bounded via ``run_bounded_leg`` against ONE shared
``LegBudget`` for this call a timeout skips that piece and records
a note in ``evidence_gaps`` instead of hanging this advisory
(read-only, non-gating) verb for the whole ``flow_verb_timeout_seconds``
budget.
""" """
t = await self.task.get(task_id) t = await self.task.get(task_id)
if t is None: if t is None:
@@ -5416,18 +5427,53 @@ class ContentActions:
await self.task.session.commit() await self.task.session.commit()
except PendingRollbackError: except PendingRollbackError:
await self.task.session.rollback() await self.task.session.rollback()
evidence_gaps: list[str] = []
budget = LegBudget(settings.evidence_assembly_timeout_seconds)
if t.branch_name and t.work_session_id: if t.branch_name and t.work_session_id:
await self.workspace.fetch_branch_for_inspection( # subprocess_timeout self-bounds the underlying git-fetch
agent_id=agent_id, branch_name=t.branch_name # subprocess (on the shared DEFAULT asyncio executor, not
# git.py's dedicated pool) to roughly this leg's own share of
# the budget, so an abandoned wait_for doesn't leave the
# subprocess occupying a thread for up to workspace_clone_timeout
# (300s) after we've already given up on it.
await run_bounded_leg(
self.workspace.fetch_branch_for_inspection(
agent_id=agent_id,
branch_name=t.branch_name,
subprocess_timeout=budget.remaining(),
),
default=None,
budget=budget,
leg="branch fetch",
hint=(
"the diff below may reflect a stale workspace; review "
"the PR diff on GitHub directly"
),
task_id=task_id,
gaps=evidence_gaps,
) )
diff = "" diff = ""
files_changed: list[str] = [] files_changed: list[str] = []
if t.branch_name: if t.branch_name:
diff = await self.git.diff( diff = await run_bounded_leg(
branch_name=t.branch_name, actor_agent_id=agent_id self.git.diff(branch_name=t.branch_name, actor_agent_id=agent_id),
default="",
budget=budget,
leg="pr diff",
hint="review the PR diff on GitHub directly",
task_id=task_id,
gaps=evidence_gaps,
) )
files_changed = await self.git.list_changed_files( files_changed = await run_bounded_leg(
branch_name=t.branch_name, actor_agent_id=agent_id self.git.list_changed_files(
branch_name=t.branch_name, actor_agent_id=agent_id
),
default=[],
budget=budget,
leg="files_changed",
hint="review the PR diff on GitHub directly",
task_id=task_id,
gaps=evidence_gaps,
) )
journal_highlights = await self.evidence_repo.journal_highlights_for_task( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id, include_ancestors=True task_id, include_ancestors=True
@@ -5443,6 +5489,7 @@ class ContentActions:
pr_diff_summary=diff, pr_diff_summary=diff,
revision_findings=open_findings, revision_findings=open_findings,
parent_context=parent_context, parent_context=parent_context,
evidence_gaps=evidence_gaps,
) )
return Envelope.ok( return Envelope.ok(
status=str(t.status), status=str(t.status),
@@ -27,6 +27,7 @@ _EVIDENCE_OMIT_WHEN_EMPTY = (
"description", "description",
"collision_context", "collision_context",
"video_context", "video_context",
"evidence_gaps",
) )
@@ -69,6 +70,11 @@ class EvidencePayload:
# the latest request_render preview + a verification instruction). None # the latest request_render preview + a verification instruction). None
# for every non-video task; omitted from the dict when empty. # for every non-video task; omitted from the dict when empty.
video_context: dict[str, Any] | None = None video_context: dict[str, Any] | None = None
# Human-readable notes for an advisory-evidence leg (branch fetch, diff,
# conventions validator) that timed out and was skipped rather than
# hanging the verb — claim_review / claim_doc_task / claim_gate_review
# only. Empty (and omitted) on the normal path.
evidence_gaps: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
data = asdict(self) data = asdict(self)
@@ -173,6 +179,7 @@ def build_evidence_for_task(
parent_context: list[dict[str, Any]] | None = None, parent_context: list[dict[str, Any]] | None = None,
collision_context: list[dict[str, Any]] | None = None, collision_context: list[dict[str, Any]] | None = None,
video_context: dict[str, Any] | None = None, video_context: dict[str, Any] | None = None,
evidence_gaps: list[str] | None = None,
) -> EvidencePayload: ) -> EvidencePayload:
"""Compose an EvidencePayload from a Task model + supplemental data. """Compose an EvidencePayload from a Task model + supplemental data.
@@ -186,6 +193,9 @@ def build_evidence_for_task(
verbatim so this module stays DB-free. ``video_context`` is the verbatim so this module stays DB-free. ``video_context`` is the
prebuilt video-artifact block (caller-assembled from the task's prebuilt video-artifact block (caller-assembled from the task's
``video_draft`` marker + render preview); passed through verbatim. ``video_draft`` marker + render preview); passed through verbatim.
``evidence_gaps`` carries the caller's bounded-leg timeout notes
(``choreographer.evidence_legs.run_bounded_leg``); empty on the normal
path.
""" """
return EvidencePayload( return EvidencePayload(
pr_number=task.pr_number, pr_number=task.pr_number,
@@ -203,6 +213,7 @@ def build_evidence_for_task(
prior_findings=render_findings(prior_findings), prior_findings=render_findings(prior_findings),
collision_context=list(collision_context or []), collision_context=list(collision_context or []),
video_context=video_context, video_context=video_context,
evidence_gaps=list(evidence_gaps or []),
) )
+17 -7
View File
@@ -6151,6 +6151,7 @@ class GitService(BaseService):
task: Any, task: Any,
*, *,
preferred_parent: str | None = None, preferred_parent: str | None = None,
timeout: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run the conventions validator on a task's changed files. """Run the conventions validator on a task's changed files.
@@ -6166,6 +6167,13 @@ class GitService(BaseService):
``preferred_parent`` threads to ``list_changed_files`` the in-path ``preferred_parent`` threads to ``list_changed_files`` the in-path
PR-review gate's cross-team parent (see ``diff``'s docstring). PR-review gate's cross-team parent (see ``diff``'s docstring).
``timeout`` overrides the validator subprocess's default budget
(``_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS``) the advisory
``claim_review`` path passes ``settings.
conventions_validator_advisory_timeout_seconds`` here; every
fail-closed caller (``i_am_done``, ``pr_pass``) omits it and keeps
the longer hardcoded cap.
The changed-file LIST above comes from git objects (``list_changed_files`` The changed-file LIST above comes from git objects (``list_changed_files``
fetches + diffs ``origin/<branch>``); the validator below reads CONTENT fetches + diffs ``origin/<branch>``); the validator below reads CONTENT
off the physical worktree, which only ``_ensure_worktree_for_commit`` off the physical worktree, which only ``_ensure_worktree_for_commit``
@@ -6214,11 +6222,16 @@ class GitService(BaseService):
# content and false-passes on newly-added files. # content and false-passes on newly-added files.
workspace = self._worktree_for_task(clone_root, require_uuid(task.id)) workspace = self._worktree_for_task(clone_root, require_uuid(task.id))
await self._ensure_worktree_for_commit(clone_root, workspace, branch) await self._ensure_worktree_for_commit(clone_root, workspace, branch)
return await self._run_conventions_validator(workspace, changed) return await self._run_conventions_validator(
workspace, changed, timeout=timeout
)
async def _run_conventions_validator( async def _run_conventions_validator(
self, workspace: Path, files: list[str] self, workspace: Path, files: list[str], *, timeout: float | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
effective_timeout = (
timeout if timeout is not None else _CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS
)
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
sys.executable, sys.executable,
"-m", "-m",
@@ -6234,7 +6247,7 @@ class GitService(BaseService):
try: try:
out, err = await asyncio.wait_for( out, err = await asyncio.wait_for(
proc.communicate(), proc.communicate(),
timeout=_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS, timeout=effective_timeout,
) )
except TimeoutError: except TimeoutError:
# Fail closed (could_not_run=True → block gate refuses the submit), # Fail closed (could_not_run=True → block gate refuses the submit),
@@ -6245,10 +6258,7 @@ class GitService(BaseService):
return { return {
"findings": [], "findings": [],
"could_not_run": True, "could_not_run": True,
"reason": ( "reason": (f"validator timed out after {effective_timeout}s"),
f"validator timed out after "
f"{_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS}s"
),
} }
if proc.returncode != 0: if proc.returncode != 0:
reason = err.decode(errors="replace").strip() or "validator crashed" reason = err.decode(errors="replace").strip() or "validator crashed"
+1 -1
View File
@@ -509,7 +509,7 @@ class PrompterService:
if not raw: if not raw:
return () return ()
deps: list[int] = [] deps: list[int] = []
for item in raw if isinstance(raw, (list, tuple)) else [raw]: for item in raw if isinstance(raw, list | tuple) else [raw]:
try: try:
deps.append(int(str(item).strip())) deps.append(int(str(item).strip()))
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
+31 -3
View File
@@ -40,6 +40,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from roboco.config import settings from roboco.config import settings
from roboco.db.tables import AgentTable from roboco.db.tables import AgentTable
from roboco.exceptions import GitTimeoutError
from roboco.logging import get_logger from roboco.logging import get_logger
from roboco.models.base import Team from roboco.models.base import Team
from roboco.models.env_branches import head_branch from roboco.models.env_branches import head_branch
@@ -2260,6 +2261,7 @@ class WorkspaceService:
*, *,
agent_id: UUID, agent_id: UUID,
branch_name: str, branch_name: str,
subprocess_timeout: float | None = None,
) -> Path: ) -> Path:
"""Fetch `branch_name` into the inspecting agent's workspace. """Fetch `branch_name` into the inspecting agent's workspace.
@@ -2268,10 +2270,22 @@ class WorkspaceService:
1. Resolves the project from the branch (via the owning task). 1. Resolves the project from the branch (via the owning task).
2. Ensures a healthy workspace for `agent_id` on that project 2. Ensures a healthy workspace for `agent_id` on that project
(clones if missing same path as the agent's first claim). (clones if missing same path as the agent's first claim). This
CLONE-CREATION step always keeps its own `workspace_clone_timeout`
(300s) budget regardless of `subprocess_timeout` below a fresh
clone genuinely needs that much room.
3. Runs `git fetch origin <branch>` with the project token so the 3. Runs `git fetch origin <branch>` with the project token so the
branch ref is locally available for `git diff`. branch ref is locally available for `git diff`.
`subprocess_timeout` overrides ONLY the fetch subprocess in step 3
(default `settings.workspace_clone_timeout`, 300s, for every
existing caller that omits it). The advisory `evidence()` call site
passes its own leg budget so a hung fetch self-terminates near that
window instead of occupying a thread on the shared DEFAULT asyncio
executor (`asyncio.to_thread` here, NOT git.py's dedicated
`_GIT_EXECUTOR`) for up to 300s after the caller already gave up
waiting on it.
Returns the workspace path so the caller can chain checkout/diff Returns the workspace path so the caller can chain checkout/diff
operations if needed. operations if needed.
""" """
@@ -2306,17 +2320,31 @@ class WorkspaceService:
basic = base64.b64encode(f"x-access-token:{git_token}".encode()).decode() basic = base64.b64encode(f"x-access-token:{git_token}".encode()).decode()
prefix = ["-c", f"http.extraheader=Authorization: Basic {basic}"] prefix = ["-c", f"http.extraheader=Authorization: Basic {basic}"]
effective_timeout = (
subprocess_timeout
if subprocess_timeout is not None
else settings.workspace_clone_timeout
)
def _do_fetch() -> subprocess.CompletedProcess[str]: def _do_fetch() -> subprocess.CompletedProcess[str]:
return subprocess.run( return subprocess.run(
["git", *prefix, "fetch", "origin", branch_name], ["git", *prefix, "fetch", "origin", branch_name],
cwd=str(workspace), cwd=str(workspace),
capture_output=True, capture_output=True,
text=True, text=True,
timeout=settings.workspace_clone_timeout, timeout=effective_timeout,
check=False, check=False,
) )
result = await asyncio.to_thread(_do_fetch) try:
result = await asyncio.to_thread(_do_fetch)
except subprocess.TimeoutExpired as exc:
# Mirrors _run_git's own TimeoutExpired -> GitTimeoutError
# conversion (git.py) so a bounded caller (run_bounded_leg)
# catches this the same way as every other git-touching leg.
raise GitTimeoutError(
f"fetch origin {branch_name}", int(effective_timeout)
) from exc
if result.returncode != 0: if result.returncode != 0:
logger.warning( logger.warning(
"fetch_branch_for_inspection: fetch returned non-zero", "fetch_branch_for_inspection: fetch returned non-zero",
@@ -989,6 +989,19 @@ async def test_escalate_up_matches_spec(role: str, status: str) -> None:
id=agent_id, role=role, team="backend", slug=None, escalation_target="main-pm" id=agent_id, role=role, team="backend", slug=None, escalation_target="main-pm"
) )
task_svc.escalate.return_value = after task_svc.escalate.return_value = after
# escalate_up's own _ensure_pm_decision write-then-gate opens a
# session.begin_nested() savepoint unconditionally (even on the
# fresh-decision no-write path below) — an unshaped AsyncMock's
# auto-attribute return doesn't support `async with`, which orphans
# the mock's internal coroutine (AsyncMockMixin._execute_mock_call
# never awaited).
task_svc.session = MagicMock()
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
journal_svc = AsyncMock() journal_svc = AsyncMock()
# journal:decision is not on the spec — satisfy it so the verb-specific # journal:decision is not on the spec — satisfy it so the verb-specific
# preflight does not surface a non-spec tracing_gap on the allowed branch. # preflight does not surface a non-spec tracing_gap on the allowed branch.
-21
View File
@@ -325,7 +325,6 @@ async def test_send_message_with_task_id_response(a2a_route_client: dict) -> Non
async def test_send_message_response_invalid_task_id( async def test_send_message_response_invalid_task_id(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -352,7 +351,6 @@ async def test_send_message_response_invalid_task_id(
async def test_send_message_response_task_not_found( async def test_send_message_response_task_not_found(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -379,7 +377,6 @@ async def test_send_message_response_task_not_found(
async def test_send_message_create_notification_success( async def test_send_message_create_notification_success(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -403,7 +400,6 @@ async def test_send_message_create_notification_success(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_message_permission_error(a2a_route_client: dict) -> None: async def test_send_message_permission_error(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -427,7 +423,6 @@ async def test_send_message_permission_error(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_message_value_error(a2a_route_client: dict) -> None: async def test_send_message_value_error(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -454,7 +449,6 @@ async def test_send_message_value_error(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_task_success(a2a_route_client: dict) -> None: async def test_cancel_task_success(a2a_route_client: dict) -> None:
a2a_task = A2ATask.model_validate( a2a_task = A2ATask.model_validate(
{ {
"id": str(a2a_route_client["task"].id), "id": str(a2a_route_client["task"].id),
@@ -484,7 +478,6 @@ async def test_cancel_task_success(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_task_already_terminal(a2a_route_client: dict) -> None: async def test_cancel_task_already_terminal(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
_set_pm_context(a2a_route_client["app"], a2a_route_client["dev"]) _set_pm_context(a2a_route_client["app"], a2a_route_client["dev"])
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
@@ -502,7 +495,6 @@ async def test_cancel_task_already_terminal(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_task_not_found(a2a_route_client: dict) -> None: async def test_cancel_task_not_found(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
_set_pm_context(a2a_route_client["app"], a2a_route_client["dev"]) _set_pm_context(a2a_route_client["app"], a2a_route_client["dev"])
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
@@ -638,7 +630,6 @@ async def test_send_message_uses_authenticated_identity_not_client_from_agent(
async def test_chat_create_conversation_access_denied( async def test_chat_create_conversation_access_denied(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -667,7 +658,6 @@ async def test_chat_create_conversation_access_denied(
async def test_chat_create_conversation_success( async def test_chat_create_conversation_success(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
conv_id = uuid4() conv_id = uuid4()
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
@@ -707,7 +697,6 @@ async def test_chat_create_conversation_success(
async def test_chat_create_conversation_refresh_failed( async def test_chat_create_conversation_refresh_failed(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -744,7 +733,6 @@ async def test_chat_create_conversation_refresh_failed(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_conversation_not_found(a2a_route_client: dict) -> None: async def test_get_conversation_not_found(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -758,7 +746,6 @@ async def test_get_conversation_not_found(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_conversation_success(a2a_route_client: dict) -> None: async def test_get_conversation_success(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
conv_id = uuid4() conv_id = uuid4()
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
@@ -790,7 +777,6 @@ async def test_get_conversation_success(a2a_route_client: dict) -> None:
async def test_close_conversation_value_error( async def test_close_conversation_value_error(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -806,7 +792,6 @@ async def test_close_conversation_value_error(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_conversation_success(a2a_route_client: dict) -> None: async def test_close_conversation_success(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -822,7 +807,6 @@ async def test_close_conversation_success(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_chat_messages(a2a_route_client: dict) -> None: async def test_list_chat_messages(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
msg = SimpleNamespace( msg = SimpleNamespace(
id=uuid4(), id=uuid4(),
@@ -853,7 +837,6 @@ async def test_list_chat_messages(a2a_route_client: dict) -> None:
async def test_send_chat_message_value_error( async def test_send_chat_message_value_error(
a2a_route_client: dict, a2a_route_client: dict,
) -> None: ) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -869,7 +852,6 @@ async def test_send_chat_message_value_error(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_chat_message_success(a2a_route_client: dict) -> None: async def test_send_chat_message_success(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
msg = SimpleNamespace( msg = SimpleNamespace(
id=uuid4(), id=uuid4(),
@@ -926,7 +908,6 @@ async def test_send_chat_message_over_budget_returns_403(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mark_read(a2a_route_client: dict) -> None: async def test_mark_read(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -941,7 +922,6 @@ async def test_mark_read(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_conversations(a2a_route_client: dict) -> None: async def test_get_task_conversations(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -956,7 +936,6 @@ async def test_get_task_conversations(a2a_route_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_list_with_status_filter(a2a_route_client: dict) -> None: async def test_chat_list_with_status_filter(a2a_route_client: dict) -> None:
client = a2a_route_client["client"] client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls: with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock() instance = AsyncMock()
@@ -18,7 +18,7 @@ def _enclosing_function(tree: ast.AST, lineno: int) -> str | None:
candidate: str | None = None candidate: str | None = None
candidate_start = -1 candidate_start = -1
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)): if isinstance(node, ast.AsyncFunctionDef | ast.FunctionDef):
start = node.lineno start = node.lineno
end = getattr(node, "end_lineno", None) or start end = getattr(node, "end_lineno", None) or start
if start <= lineno <= end and start > candidate_start: if start <= lineno <= end and start > candidate_start:
@@ -884,7 +884,6 @@ async def test_soft_block_task_for_agent_full_flow(
db_session: AsyncSession, db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
svc = task_setup["svc"] svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) task = await svc.create(_req(task_setup))
task.assigned_to = task_setup["agent_id"] task.assigned_to = task_setup["agent_id"]
@@ -929,7 +928,6 @@ async def test_soft_block_task_for_agent_full_flow(
async def test_docs_complete_for_task_invokes_notification( async def test_docs_complete_for_task_invokes_notification(
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
svc = task_setup["svc"] svc = task_setup["svc"]
doc = AgentTable( doc = AgentTable(
id=uuid4(), id=uuid4(),
@@ -988,7 +986,6 @@ async def test_docs_complete_for_task_invokes_notification(
async def test_escalate_to_ceo_for_agent_invokes_notification( async def test_escalate_to_ceo_for_agent_invokes_notification(
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
svc = task_setup["svc"] svc = task_setup["svc"]
pm = AgentTable( pm = AgentTable(
id=uuid4(), id=uuid4(),
@@ -1054,7 +1051,6 @@ async def test_escalate_to_ceo_for_agent_invokes_notification(
async def test_claim_task_for_agent_commits_and_returns( async def test_claim_task_for_agent_commits_and_returns(
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
svc = task_setup["svc"] svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) task = await svc.create(_req(task_setup))
task.branch_name = "feature/backend/x" task.branch_name = "feature/backend/x"
@@ -1088,7 +1084,6 @@ async def test_claim_task_for_agent_commits_and_returns(
async def test_complete_task_for_agent_commits( async def test_complete_task_for_agent_commits(
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
svc = task_setup["svc"] svc = task_setup["svc"]
pm = AgentTable( pm = AgentTable(
id=uuid4(), id=uuid4(),
@@ -1138,7 +1133,6 @@ async def test_complete_task_for_agent_commits(
async def test_substitute_task_for_agent_runs_update( async def test_substitute_task_for_agent_runs_update(
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
svc = task_setup["svc"] svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) task = await svc.create(_req(task_setup))
task.assigned_to = task_setup["agent_id"] task.assigned_to = task_setup["agent_id"]
-4
View File
@@ -1182,7 +1182,6 @@ async def test_unblock_task_not_blocked_returns_400(task_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unblock_task_forbidden(task_client: dict) -> None: async def test_unblock_task_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client) other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.BLOCKED, assigned_to=other.id) task = _seed_task(task_client, status=TaskStatus.BLOCKED, assigned_to=other.id)
await task_client["db"].flush() await task_client["db"].flush()
@@ -1615,7 +1614,6 @@ async def test_activate_unknown_returns_4xx(task_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_activate_developer_forbidden(task_client: dict) -> None: async def test_activate_developer_forbidden(task_client: dict) -> None:
app = task_client["client"]._transport.app app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext: async def _override_agent() -> AgentContext:
@@ -1668,7 +1666,6 @@ async def test_get_team_tasks_unauthorized(task_client: dict) -> None:
async def test_get_task_stats_by_team_developer_forbidden( async def test_get_task_stats_by_team_developer_forbidden(
task_client: dict, task_client: dict,
) -> None: ) -> None:
app = task_client["client"]._transport.app app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext: async def _override_agent() -> AgentContext:
@@ -1779,7 +1776,6 @@ async def test_delete_task_forbidden_non_creator(task_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_developer_forbidden(task_client: dict) -> None: async def test_cancel_developer_forbidden(task_client: dict) -> None:
task = _seed_task(task_client) task = _seed_task(task_client)
await task_client["db"].flush() await task_client["db"].flush()
app = task_client["client"]._transport.app app = task_client["client"]._transport.app
@@ -84,7 +84,6 @@ async def test_system_stream_disconnects_on_cancelled_error() -> None:
async def test_notification_stream_disconnects_on_non_disconnect_exception( async def test_notification_stream_disconnects_on_non_disconnect_exception(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
agent_id = uuid4() agent_id = uuid4()
mgr = ConnectionManager() mgr = ConnectionManager()
ws = _mock_ws_for_receive(RuntimeError("transport reset")) ws = _mock_ws_for_receive(RuntimeError("transport reset"))
@@ -109,7 +108,6 @@ async def test_notification_stream_disconnects_on_non_disconnect_exception(
async def test_agent_stream_disconnects_on_non_disconnect_exception( async def test_agent_stream_disconnects_on_non_disconnect_exception(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
target_id = uuid4() target_id = uuid4()
viewer_id = uuid4() viewer_id = uuid4()
mgr = ConnectionManager() mgr = ConnectionManager()
@@ -89,7 +89,6 @@ async def test_system_stream_reaps_silent_socket_after_idle_timeout() -> None:
async def test_notification_stream_reaps_silent_socket_after_idle_timeout( async def test_notification_stream_reaps_silent_socket_after_idle_timeout(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
agent_id = uuid4() agent_id = uuid4()
mgr = ConnectionManager() mgr = ConnectionManager()
hang_future: asyncio.Future[str] = asyncio.Future() hang_future: asyncio.Future[str] = asyncio.Future()
@@ -109,7 +108,6 @@ async def test_notification_stream_reaps_silent_socket_after_idle_timeout(
async def test_agent_stream_reaps_silent_socket_after_idle_timeout( async def test_agent_stream_reaps_silent_socket_after_idle_timeout(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
target_id = uuid4() target_id = uuid4()
viewer_id = uuid4() viewer_id = uuid4()
mgr = ConnectionManager() mgr = ConnectionManager()
+17 -8
View File
@@ -978,18 +978,27 @@ async def test_escalate_up_survives_journal_write_lock_timeout() -> None:
with no rollback/savepoint, poisoning the session so the very next with no rollback/savepoint, poisoning the session so the very next
attribute touch (``_escalate_up_preflight`` reading ``t.id``) raised an attribute touch (``_escalate_up_preflight`` reading ``t.id``) raised an
unhandled ``PendingRollbackError``. The write is now savepoint-guarded unhandled ``PendingRollbackError``. The write is now savepoint-guarded
(``begin_nested()``): the failure is contained, the verb falls through (``begin_nested()``): the failure is contained and no unhandled
cleanly to the normal tracing_gap rejection (no decision was actually exception escapes ``escalate_up``.
persisted), and the task stays fully readable no unhandled exception
escapes ``escalate_up``.""" Round-2 fix (transient-failure gate bypass): a lock-timeout with a
non-empty rationale already in hand (``reason``) no longer falls through
to a tracing_gap rejection either the PM answered the gate's actual
question (why); the write was only ever a convenience. Laundering
transient DB congestion into a rejected/blocked PM verb is exactly the
bug this closes see test_pm_decision_transient_failure_* below for the
gate-helper-level unit coverage.
"""
pm_id = uuid4() pm_id = uuid4()
task_id = uuid4() task_id = uuid4()
t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend") t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
after = MagicMock(**{**t.__dict__, "assigned_to": uuid4()})
task_svc = AsyncMock() task_svc = AsyncMock()
task_svc.get.return_value = t task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock( task_svc.agent_for.return_value = MagicMock(
role="cell_pm", escalation_target="main-pm" role="cell_pm", escalation_target="main-pm"
) )
task_svc.escalate.return_value = after
journal_svc = AsyncMock() journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False journal_svc.has_decision_for_task.return_value = False
journal_svc.latest_decision_at.return_value = None journal_svc.latest_decision_at.return_value = None
@@ -1006,11 +1015,11 @@ async def test_escalate_up_survives_journal_write_lock_timeout() -> None:
# The savepoint was actually engaged — proves the fix is wired in, not # The savepoint was actually engaged — proves the fix is wired in, not
# merely that AsyncMock happened to swallow the raise on its own. # merely that AsyncMock happened to swallow the raise on its own.
task_svc.session.begin_nested.assert_called() task_svc.session.begin_nested.assert_called()
# No unhandled exception escaped escalate_up: the gate falls through to # No unhandled exception escaped escalate_up, AND the gate is satisfied
# its normal clean rejection since the decision write never landed. # by the verb's own rationale instead of rejecting a DB hiccup.
body = env.as_dict() body = env.as_dict()
assert body["error"] == "tracing_gap" assert body["error"] is None, body
assert "journal:decision" in body["missing"] task_svc.escalate.assert_awaited_once()
# The task is still fully readable afterward — this is exactly where # The task is still fully readable afterward — this is exactly where
# the production trace crashed with PendingRollbackError on t.id. # the production trace crashed with PendingRollbackError on t.id.
assert t.id == task_id assert t.id == task_id
@@ -109,3 +109,18 @@ async def test_gate_records_findings_even_when_blocking(
env = await c._conventions_gate(_ctx()) env = await c._conventions_gate(_ctx())
assert env is not None # still blocks assert env is not None # still blocks
assert recorded and recorded[0] is _BLOCK_RESULT assert recorded and recorded[0] is _BLOCK_RESULT
@pytest.mark.asyncio
async def test_gate_never_overrides_the_fail_closed_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""i_am_done's conventions gate is fail-closed and must keep the
validator's hardcoded 120s cap — unlike claim_review's advisory path, it
must never pass a ``timeout`` override down to ``conventions_check_for_task``."""
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result={"findings": [], "could_not_run": False})
await c._conventions_gate(_ctx())
check = c.git.conventions_check_for_task
check.assert_awaited_once()
assert "timeout" not in check.await_args.kwargs
@@ -125,3 +125,18 @@ async def test_pr_pass_guard_inert_when_flag_off(
monkeypatch.setattr(settings, "conventions_enabled", False) monkeypatch.setattr(settings, "conventions_enabled", False)
c = _make_choreographer(check_result=_BLOCK_RESULT) c = _make_choreographer(check_result=_BLOCK_RESULT)
assert await c._conventions_guard(uuid4(), MagicMock(), {}) is None assert await c._conventions_guard(uuid4(), MagicMock(), {}) is None
@pytest.mark.asyncio
async def test_pr_pass_guard_never_overrides_the_fail_closed_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The pr_pass gate is fail-closed and must keep the validator's
hardcoded 120s cap unlike claim_review's advisory path, it must
never pass a ``timeout`` override down to ``conventions_check_for_task``."""
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result={"findings": [], "could_not_run": False})
await c._conventions_guard(uuid4(), MagicMock(), {})
check = c.git.conventions_check_for_task
check.assert_awaited_once()
assert "timeout" not in check.await_args.kwargs
@@ -31,7 +31,12 @@ async def test_findings_surfaced_when_flag_on(monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setattr(settings, "conventions_enabled", True) monkeypatch.setattr(settings, "conventions_enabled", True)
findings = [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}] findings = [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}]
c = _make_choreographer(check_result={"findings": findings, "could_not_run": False}) c = _make_choreographer(check_result={"findings": findings, "could_not_run": False})
assert await c._qa_convention_findings(uuid4(), MagicMock()) == findings gaps: list[str] = []
assert (
await c._qa_convention_findings(uuid4(), MagicMock(), timeout=30.0, gaps=gaps)
== findings
)
assert gaps == []
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -40,21 +45,32 @@ async def test_empty_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
c = _make_choreographer( c = _make_choreographer(
check_result={"findings": [{"file": "x"}], "could_not_run": False} check_result={"findings": [{"file": "x"}], "could_not_run": False}
) )
assert await c._qa_convention_findings(uuid4(), MagicMock()) == [] gaps: list[str] = []
assert (
await c._qa_convention_findings(uuid4(), MagicMock(), timeout=30.0, gaps=gaps)
== []
)
assert gaps == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_could_not_run_surfaced_as_single_entry( async def test_could_not_run_surfaced_as_single_entry(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""A non-timeout could_not_run reason ("boom") stays fail-open in
convention_findings but must NOT also spam evidence_gaps only a
detected timeout does (see test_claim_review_conventions_timeout_
degrades_with_gap in test_evidence_assembly_bounded_legs.py)."""
monkeypatch.setattr(settings, "conventions_enabled", True) monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer( c = _make_choreographer(
check_result={"findings": [], "could_not_run": True, "reason": "boom"} check_result={"findings": [], "could_not_run": True, "reason": "boom"}
) )
out = await c._qa_convention_findings(uuid4(), MagicMock()) gaps: list[str] = []
out = await c._qa_convention_findings(uuid4(), MagicMock(), timeout=30.0, gaps=gaps)
assert len(out) == 1 assert len(out) == 1
assert out[0]["could_not_run"] is True assert out[0]["could_not_run"] is True
assert out[0]["reason"] == "boom" assert out[0]["reason"] == "boom"
assert gaps == []
def _stub_task() -> MagicMock: def _stub_task() -> MagicMock:
@@ -0,0 +1,763 @@
"""Bounded advisory-evidence legs on claim_review / claim_doc_task /
claim_gate_review / evidence() / i_am_done's success envelope.
Live bug: claim-evidence assembly's slow legs (branch-fetch-backed diff,
list_changed_files, the conventions-validator subprocess) had no per-leg
budget, so a hung leg silently ate the whole ``flow_verb_timeout_seconds``
(120s) and died as a FlowVerbTimeout 504 holding every row the request
touched for the duration. Fix: each slow leg runs bounded via
``run_bounded_leg`` (``asyncio.wait_for``) against a SHARED ``LegBudget`` per
build; a timeout skips that piece, records a human-readable note in the
evidence's ``evidence_gaps``, and lets the claim verb succeed with partial
evidence instead of hanging.
Adversarial-review follow-up (round 2) covers four confirmed gaps:
1. ``run_bounded_leg`` must catch ``GitTimeoutError`` too (``_run_git``'s own
internal subprocess bound NOT a ``TimeoutError`` subclass, and usually
the FIRST bound to trip since it defaults to 30s, shorter than a leg's
own budget) every timeout-shaped test below is parametrized over both
exception shapes.
2. The conventions leg no longer wraps ``_qa_convention_findings`` in an
outer ``run_bounded_leg`` that raced ``conventions_check_for_task``'s
own inner timeout+cleanup and leaked the validator subprocess. It now
self-bounds via the ``timeout`` kwarg alone and reports its own gap.
3. ``fetch_branch_for_inspection`` takes an optional ``subprocess_timeout``
so its fetch subprocess self-bounds near the leg's own budget instead of
occupying a thread on the shared default executor for up to 300s.
4. A shared ``LegBudget`` (one per evidence build) makes every leg's
``wait_for`` draw from one TOTAL budget instead of getting its own full
allotment summed per-leg budgets can no longer exceed the total.
"""
from __future__ import annotations
import asyncio
import time
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.exceptions import GitCommandError, GitTimeoutError
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.choreographer.evidence_legs import (
LegBudget,
run_bounded_leg,
)
# Every timeout-shaped test is parametrized over both real timeout shapes:
# asyncio's own cancellation-converted TimeoutError, and GitTimeoutError
# (GitService._run_git's own internal subprocess bound — a GitError/
# RobocoError subclass, NOT a TimeoutError subclass, and the most common
# real-world single-hung-git-call shape since it defaults to a SHORTER
# window, 30s, than a leg's own budget).
_TIMEOUT_EXCEPTIONS = (
TimeoutError("hung"),
GitTimeoutError("git diff", 30),
)
_TIMEOUT_IDS = ("asyncio_timeout", "git_timeout")
# ---------------------------------------------------------------------------
# run_bounded_leg / LegBudget themselves
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_bounded_leg_passes_through_on_success() -> None:
async def fast() -> str:
return "value"
gaps: list[str] = []
result = await run_bounded_leg(
fast(),
default="fallback",
budget=LegBudget(5.0),
leg="unit leg",
hint="check manually",
task_id=uuid4(),
gaps=gaps,
)
assert result == "value"
assert gaps == []
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_run_bounded_leg_degrades_to_default_on_timeout(exc: Exception) -> None:
async def hangs() -> str:
raise exc
gaps: list[str] = []
result = await run_bounded_leg(
hangs(),
default="fallback",
budget=LegBudget(5.0),
leg="unit leg",
hint="check manually",
task_id=uuid4(),
gaps=gaps,
)
assert result == "fallback"
assert len(gaps) == 1
assert "unit leg unavailable" in gaps[0]
assert "check manually" in gaps[0]
@pytest.mark.asyncio
async def test_run_bounded_leg_actually_bounds_a_slow_coroutine() -> None:
"""A genuinely slow (not pre-raised) coroutine is cancelled at the
budget's deadline, not awaited to completion."""
async def slow() -> str:
await asyncio.sleep(10)
return "too late"
gaps: list[str] = []
result = await run_bounded_leg(
slow(),
default="fallback",
budget=LegBudget(0.05),
leg="unit leg",
hint="check manually",
task_id=uuid4(),
gaps=gaps,
)
assert result == "fallback"
assert len(gaps) == 1
@pytest.mark.asyncio
async def test_run_bounded_leg_other_git_error_still_propagates() -> None:
"""A real command failure (not a timeout) is NOT a degrade case — it
must still propagate uncaught, same as any other unexpected exception."""
async def fails() -> str:
raise GitCommandError("git diff", "fatal: bad revision")
gaps: list[str] = []
with pytest.raises(GitCommandError):
await run_bounded_leg(
fails(),
default="fallback",
budget=LegBudget(5.0),
leg="unit leg",
hint="check manually",
task_id=uuid4(),
gaps=gaps,
)
assert gaps == []
def test_leg_budget_remaining_shrinks_over_time() -> None:
# Total well above _MIN_LEG_SECONDS (1.0) so the floor never engages
# here — otherwise both readings would clamp to 1.0 and look equal.
budget = LegBudget(3.0)
first = budget.remaining()
time.sleep(0.1)
second = budget.remaining()
assert second < first
assert first == pytest.approx(3.0, abs=0.05)
assert (first - second) == pytest.approx(0.1, abs=0.05)
def test_leg_budget_floors_at_minimum() -> None:
"""A budget already past its deadline still yields a positive window
(the floor) rather than 0 or a negative timeout a leg always gets a
real chance to run, even a badly-exhausted one."""
budget = LegBudget(0.01)
time.sleep(0.05)
assert budget.remaining() == pytest.approx(1.0, abs=0.05)
@pytest.mark.asyncio
async def test_run_bounded_leg_shares_one_shrinking_budget_across_legs() -> None:
"""Three legs sharing ONE LegBudget: the first two finish fast and
consume real budget; the last two never finish on their own and get
progressively SMALLER windows (shrinking, not each getting the full
total) both record their own gap, and total wall time stays bounded
near the shared total instead of the naive per-leg sum (0.1+0.1+5+5s).
"""
budget = LegBudget(1.2)
gaps: list[str] = []
async def _takes(seconds: float) -> str:
await asyncio.sleep(seconds)
return "done"
start = time.monotonic()
remaining_before_1 = budget.remaining()
r1 = await run_bounded_leg(
_takes(0.1),
default="gap1",
budget=budget,
leg="leg1",
hint="h",
task_id="t",
gaps=gaps,
)
remaining_before_2 = budget.remaining()
r2 = await run_bounded_leg(
_takes(5.0),
default="gap2",
budget=budget,
leg="leg2",
hint="h",
task_id="t",
gaps=gaps,
)
remaining_before_3 = budget.remaining()
r3 = await run_bounded_leg(
_takes(5.0),
default="gap3",
budget=budget,
leg="leg3",
hint="h",
task_id="t",
gaps=gaps,
)
elapsed = time.monotonic() - start
expected_gap_count = 2 # leg2 + leg3 both timed out; leg1 completed
assert r1 == "done"
assert r2 == "gap2"
assert r3 == "gap3"
assert len(gaps) == expected_gap_count
assert "leg2" in gaps[0]
assert "leg3" in gaps[1]
# Each leg's own remaining() reading is strictly smaller than the last
# — the shared deadline never resets.
assert remaining_before_1 > remaining_before_2 > remaining_before_3
# ponytail: the floor (max 1.0s) can inflate the LAST leg's window past
# what a naive "budget minus elapsed" would give once the deadline is
# already exhausted — a single floor engagement caps the worst-case
# overage at _MIN_LEG_SECONDS (1.0s), so budget total + ~1.3s is a safe,
# honest ceiling rather than a strict `<= budget` bound. Upgrade path:
# make the floor configurable if a caller ever needs a tighter cap.
budget_total_seconds = 1.2
floor_overage_tolerance_seconds = 1.3
assert elapsed <= budget_total_seconds + floor_overage_tolerance_seconds
# And it's nowhere near the naive per-leg-gets-its-own-full-timeout sum
# (0.1 + 5.0 + 5.0 = 10.1s) that pre-LegBudget behavior would produce.
naive_per_leg_sum_seconds = 5.0
assert elapsed < naive_per_leg_sum_seconds
# ---------------------------------------------------------------------------
# Shared choreographer test harness (mirrors test_choreographer_qa.py /
# test_claim_doc_task_checkout.py / test_claim_gate_review_guards.py)
# ---------------------------------------------------------------------------
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
_ldef = base["journal"].latest_decision_at.return_value
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
return ChoreographerDeps(**base)
def _stub_empty_ledger(session: MagicMock) -> None:
session.execute = AsyncMock(
return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
)
)
# ---------------------------------------------------------------------------
# claim_review (qa.py)
# ---------------------------------------------------------------------------
_PR_NUMBER = 8
_PR_URL = "https://github.com/x/y/pull/8"
def _qa_task(task_id: Any) -> MagicMock:
return MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
pr_number=_PR_NUMBER,
pr_url=_PR_URL,
commits=[{"sha": "abc123", "message": "feat: x"}],
team="backend",
branch_name="feature/backend/abc--def",
work_session_id=uuid4(),
documents=[],
dev_notes="implemented x",
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "abc123"},
],
parent_task_id=None,
)
def _qa_harness(git_svc: AsyncMock) -> tuple[Choreographer, Any, Any]:
"""Does NOT touch ``settings.conventions_enabled`` — callers that care
set it themselves via their own ``monkeypatch`` fixture; a shared
forced-False here would silently clobber a caller's forced-True set
moments earlier (``monkeypatch.setattr`` doesn't stack, last write
wins), which is exactly what broke the conventions-specific tests."""
qa_id = uuid4()
task_id = uuid4()
t_initial = _qa_task(task_id)
t_claimed = MagicMock(**{**t_initial.__dict__, "assigned_to": qa_id})
task_svc = AsyncMock()
task_svc.get.return_value = t_initial
task_svc.agent_for.return_value = MagicMock(role="qa", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.qa_claim.return_value = t_claimed
_stub_empty_ledger(task_svc.session)
deps = _make_deps(task=task_svc, git=git_svc)
return Choreographer(deps), qa_id, task_id
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_claim_review_diff_timeout_degrades_with_gap(
monkeypatch: pytest.MonkeyPatch, exc: Exception
) -> None:
"""A hung git.diff on claim_review must not hang the verb: it degrades
to an empty diff, records the gap, and the OTHER leg (list_changed_files)
still comes through untouched."""
monkeypatch.setattr(settings, "conventions_enabled", False)
git_svc = AsyncMock()
git_svc.diff.side_effect = exc
git_svc.list_changed_files.return_value = ["README.md"]
c, qa_id, task_id = _qa_harness(git_svc)
env = await c.claim_review(qa_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == ""
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" in ev
assert len(ev["evidence_gaps"]) == 1
assert "pr diff unavailable" in ev["evidence_gaps"][0]
@pytest.mark.asyncio
async def test_claim_review_conventions_timeout_degrades_with_gap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""conventions_check_for_task's OWN internal timeout (proc.kill()'d and
reaped inside git.py, never raising) surfaces as could_not_run=True with
a "timed out" reason not an exception. The advisory call site
(_qa_convention_findings) detects that shape and records the gap
itself; NO outer run_bounded_leg wraps this leg (that's the fix — see
module docstring point 2)."""
monkeypatch.setattr(settings, "conventions_enabled", True)
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
git_svc.conventions_check_for_task.return_value = {
"findings": [],
"could_not_run": True,
"reason": "validator timed out after 30.0s",
}
c, qa_id, task_id = _qa_harness(git_svc)
env = await c.claim_review(qa_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == "diff content"
assert ev["files_changed"] == ["README.md"]
assert ev["convention_findings"] == [
{"could_not_run": True, "reason": "validator timed out after 30.0s"}
]
assert "evidence_gaps" in ev
assert any("conventions findings unavailable" in g for g in ev["evidence_gaps"])
# The advisory (shorter) ceiling reached the validator call, not the
# fail-closed i_am_done/pr_pass default (None -> hardcoded 120s).
git_svc.conventions_check_for_task.assert_awaited_once()
call_kwargs = git_svc.conventions_check_for_task.await_args.kwargs
assert (
call_kwargs["timeout"]
<= settings.conventions_validator_advisory_timeout_seconds
)
assert call_kwargs["timeout"] > 0
@pytest.mark.asyncio
async def test_claim_review_conventions_non_timeout_could_not_run_no_gap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A genuine resolution failure (not a timeout) still surfaces in
convention_findings (existing fail-open shape) but must NOT also spam
evidence_gaps that's reserved for actual degraded-advisory-leg notes."""
monkeypatch.setattr(settings, "conventions_enabled", True)
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
git_svc.conventions_check_for_task.return_value = {
"findings": [],
"could_not_run": True,
"reason": "resolution failed: NotFoundError: Branch not found",
}
c, qa_id, task_id = _qa_harness(git_svc)
env = await c.claim_review(qa_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["convention_findings"][0]["could_not_run"] is True
assert "evidence_gaps" not in ev
@pytest.mark.asyncio
async def test_qa_convention_findings_not_cancelled_by_outer_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression for the orphaned-subprocess bug: even with a tiny shared
evidence-assembly budget, _qa_convention_findings must NOT be cut short
by an outer wait_for it awaits conventions_check_for_task to
completion. A slow-but-real mock (0.15s) run against a budget whose
total is far smaller (0.02s) proves there is no outer wrap: if there
still were one, this would return the default/empty shape instead of
the real result."""
monkeypatch.setattr(settings, "conventions_enabled", True)
async def _slow_check(*_args: object, **_kwargs: object) -> dict[str, Any]:
await asyncio.sleep(0.15)
return {"findings": [{"file": "x.py", "line": 1}], "could_not_run": False}
git_svc = AsyncMock()
git_svc.conventions_check_for_task.side_effect = _slow_check
c, _qa_id, _task_id = _qa_harness(git_svc)
cc: Any = c
gaps: list[str] = []
result = await cc._qa_convention_findings(
uuid4(), MagicMock(), timeout=0.02, gaps=gaps
)
assert result == [{"file": "x.py", "line": 1}]
assert gaps == []
@pytest.mark.asyncio
async def test_claim_review_normal_path_has_no_evidence_gaps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Byte-for-byte unchanged normal path: no evidence_gaps key at all when
nothing times out."""
monkeypatch.setattr(settings, "conventions_enabled", False)
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
c, qa_id, task_id = _qa_harness(git_svc)
env = await c.claim_review(qa_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == "diff content"
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" not in ev
# ---------------------------------------------------------------------------
# claim_doc_task (doc.py)
# ---------------------------------------------------------------------------
def _doc_task(task_id: Any, branch: str) -> MagicMock:
return MagicMock(
id=task_id,
status="awaiting_documentation",
assigned_to=None,
task_type="documentation",
team="backend",
branch_name=branch,
quick_context=None,
documents=[],
commits=[{"sha": "abc123", "message": "[x] work"}],
pr_number=7,
pr_url="https://github.com/x/y/pull/7",
dev_notes="done",
acceptance_criteria_status=[],
work_session_id=uuid4(),
)
def _doc_harness(git_svc: AsyncMock) -> tuple[Choreographer, Any, Any]:
doc_id = uuid4()
task_id = uuid4()
branch = "feature/backend/root1234--cellpm56--dev78901"
t_initial = _doc_task(task_id, branch)
t_claimed = MagicMock(**{**t_initial.__dict__, "assigned_to": doc_id})
task_svc = AsyncMock()
task_svc.get.return_value = t_initial
task_svc.agent_for.return_value = MagicMock(role="documenter", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.doc_claim.return_value = t_claimed
_stub_empty_ledger(task_svc.session)
deps = _make_deps(task=task_svc, git=git_svc)
return Choreographer(deps), doc_id, task_id
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_claim_doc_task_diff_timeout_degrades_with_gap(exc: Exception) -> None:
git_svc = AsyncMock()
git_svc.diff.side_effect = exc
git_svc.list_changed_files.return_value = ["README.md"]
c, doc_id, task_id = _doc_harness(git_svc)
env = await c.claim_doc_task(doc_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == ""
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" in ev
assert any("pr diff unavailable" in g for g in ev["evidence_gaps"])
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_claim_doc_task_checkout_timeout_degrades_with_gap(
exc: Exception,
) -> None:
"""The checkout leg (run before evidence assembly) also degrades bounded
instead of an unbounded suppress(Exception) its gap folds into the
same evidence_gaps list the diff/list_changed_files legs use, drawing
from the SAME shared LegBudget."""
git_svc = AsyncMock()
git_svc.checkout_branch_in_agent_workspace.side_effect = exc
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
c, doc_id, task_id = _doc_harness(git_svc)
env = await c.claim_doc_task(doc_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
# The other legs are untouched by the checkout's own timeout.
assert ev["pr_diff_summary"] == "diff content"
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" in ev
assert any("workspace checkout unavailable" in g for g in ev["evidence_gaps"])
@pytest.mark.asyncio
async def test_claim_doc_task_normal_path_has_no_evidence_gaps() -> None:
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
c, doc_id, task_id = _doc_harness(git_svc)
env = await c.claim_doc_task(doc_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == "diff content"
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" not in ev
# ---------------------------------------------------------------------------
# claim_gate_review (pr_gate.py)
# ---------------------------------------------------------------------------
def _gate_task() -> MagicMock:
return MagicMock(
id=uuid4(),
status="awaiting_pr_review",
assigned_to=uuid4(),
parent_task_id=None,
task_type="planning",
dependency_ids=[],
team="main_pm",
pr_number=139,
pr_url="https://example/pr/139",
branch_name="feature/main_pm/root",
batch_id=None,
description=None,
acceptance_criteria=[],
)
def _gate_harness(git_svc: AsyncMock) -> tuple[Choreographer, Any, Any]:
task_svc = AsyncMock()
t = _gate_task()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
role="pr_reviewer", slug="be-pr-reviewer"
)
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.unmet_dependency_ids = AsyncMock(return_value=[])
task_svc.has_earlier_incomplete_code_sibling.return_value = False
task_svc.pr_gate_claim = AsyncMock(return_value=t)
_stub_empty_ledger(task_svc.session)
deps = _make_deps(task=task_svc, git=git_svc)
return Choreographer(deps), t, uuid4()
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_claim_gate_review_diff_timeout_degrades_with_gap(exc: Exception) -> None:
git_svc = AsyncMock()
git_svc.diff.side_effect = exc
git_svc.list_changed_files.return_value = ["README.md"]
c, t, reviewer_id = _gate_harness(git_svc)
env = await c.claim_gate_review(reviewer_id, t.id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff"] == ""
assert "evidence_gaps" in ev
assert any("pr diff unavailable" in g for g in ev["evidence_gaps"])
@pytest.mark.asyncio
async def test_claim_gate_review_files_changed_timeout_degrades_with_gap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""list_changed_files hanging must not sink the whole gate claim, and the
diff leg (which succeeded) must remain intact in the evidence.
``_gate_changed_files`` has its own internal ``except Exception`` (an
existing hard-failure fail-open, untouched by this fix) that would
swallow a synchronously-raised exception (of either timeout shape)
before the outer ``run_bounded_leg`` ever saw it so this uses a
genuinely slow coroutine + a monkeypatched short budget to exercise the
real cancel-at-the-wall path (``asyncio.wait_for`` cancelling the
awaited task via ``CancelledError``, which that ``except Exception``
does NOT catch), matching what a real hang does in production.
"""
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.02)
async def _hangs(*_args: object, **_kwargs: object) -> list[str]:
await asyncio.sleep(5)
return ["should-not-be-reached"]
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.side_effect = _hangs
c, t, reviewer_id = _gate_harness(git_svc)
env = await c.claim_gate_review(reviewer_id, t.id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff"] == "diff content"
assert "evidence_gaps" in ev
assert any("files_changed unavailable" in g for g in ev["evidence_gaps"])
@pytest.mark.asyncio
async def test_claim_gate_review_normal_path_has_no_evidence_gaps() -> None:
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
c, t, reviewer_id = _gate_harness(git_svc)
env = await c.claim_gate_review(reviewer_id, t.id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff"] == "diff content"
assert "evidence_gaps" not in ev
# ---------------------------------------------------------------------------
# _build_i_am_done_ok (i_am_done's success-envelope evidence). Runs strictly
# AFTER the composed transition already committed — advisory, not gating —
# so its list_changed_files leg is bounded exactly like the claim paths.
# ---------------------------------------------------------------------------
def _done_task(task_id: Any) -> MagicMock:
return MagicMock(
id=task_id,
branch_name="feature/backend/abc",
commits=[{"sha": "abc123", "message": "x"}],
dev_notes="done",
acceptance_criteria_status=[],
pr_number=5,
pr_url="https://github.com/x/y/pull/5",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_build_i_am_done_ok_files_changed_timeout_degrades_with_gap(
exc: Exception,
) -> None:
"""A hung list_changed_files leg in i_am_done's already-committed
success-envelope builder must not hang the dev's response — it degrades
to an empty files_changed and records the gap."""
agent_id = uuid4()
task_id = uuid4()
t = _done_task(task_id)
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
_stub_empty_ledger(task_svc.session)
git_svc = AsyncMock()
git_svc.list_changed_files.side_effect = exc
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
env = await c._build_i_am_done_ok(agent_id, task_id, t)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["files_changed"] == []
assert "evidence_gaps" in ev
assert any("files_changed unavailable" in g for g in ev["evidence_gaps"])
@pytest.mark.asyncio
async def test_build_i_am_done_ok_normal_path_has_no_evidence_gaps() -> None:
agent_id = uuid4()
task_id = uuid4()
t = _done_task(task_id)
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
_stub_empty_ledger(task_svc.session)
git_svc = AsyncMock()
git_svc.list_changed_files.return_value = ["README.md"]
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
env = await c._build_i_am_done_ok(agent_id, task_id, t)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" not in ev
@@ -20,6 +20,8 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from roboco.config import settings
from roboco.exceptions import GitTimeoutError
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
@@ -223,3 +225,143 @@ async def test_evidence_no_branch_skips_git_calls() -> None:
assert body["evidence"]["pr_diff_summary"] == "" assert body["evidence"]["pr_diff_summary"] == ""
git_svc.diff.assert_not_awaited() git_svc.diff.assert_not_awaited()
git_svc.list_changed_files.assert_not_awaited() git_svc.list_changed_files.assert_not_awaited()
# ---------------------------------------------------------------------------
# Bounded advisory-evidence legs: evidence() must not hang on a slow branch
# fetch / diff / list_changed_files leg — it degrades and records a note in
# evidence_gaps instead (same run_bounded_leg treatment as claim_review /
# claim_doc_task / claim_gate_review). Every timeout-shaped test is
# parametrized over both real timeout shapes: asyncio's own
# cancellation-converted TimeoutError, and GitTimeoutError (_run_git's own
# internal subprocess bound — a GitError/RobocoError subclass, NOT a
# TimeoutError subclass, and the most common real-world single-hung-git-call
# shape since it defaults to a SHORTER window than a leg's own budget).
# ---------------------------------------------------------------------------
_TIMEOUT_EXCEPTIONS = (
TimeoutError("hung"),
GitTimeoutError("git diff", 30),
)
_TIMEOUT_IDS = ("asyncio_timeout", "git_timeout")
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_evidence_diff_timeout_degrades_with_gap(exc: Exception) -> None:
"""A hung git.diff must not hang evidence(): it degrades to an empty
diff, records the gap, and list_changed_files (the other leg) still
comes through untouched."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc"])
git_svc = AsyncMock()
git_svc.diff.side_effect = exc
git_svc.list_changed_files.return_value = ["README.md"]
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == ""
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" in ev
assert any("pr diff unavailable" in g for g in ev["evidence_gaps"])
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
async def test_evidence_branch_fetch_timeout_degrades_with_gap(exc: Exception) -> None:
"""A hung workspace branch-fetch must not hang evidence() either — the
subsequent diff/list_changed_files legs still run (against whatever the
workspace already has) and the gap is recorded."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc"])
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
workspace_svc = AsyncMock()
workspace_svc.fetch_branch_for_inspection.side_effect = exc
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == "diff content"
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" in ev
assert any("branch fetch unavailable" in g for g in ev["evidence_gaps"])
@pytest.mark.asyncio
async def test_evidence_branch_fetch_passes_subprocess_timeout_from_budget() -> None:
"""The branch-fetch leg passes its own remaining LegBudget share down as
fetch_branch_for_inspection's subprocess_timeout, so a hung fetch
subprocess self-terminates near the leg's own budget instead of
occupying a thread on the shared default executor for up to
workspace_clone_timeout (300s) after evidence() already gave up on it."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc"])
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
workspace_svc = AsyncMock()
workspace_svc.fetch_branch_for_inspection.return_value = None
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
assert env.as_dict()["error"] is None
workspace_svc.fetch_branch_for_inspection.assert_awaited_once()
call_kwargs = workspace_svc.fetch_branch_for_inspection.await_args.kwargs
assert (
call_kwargs["subprocess_timeout"] <= settings.evidence_assembly_timeout_seconds
)
assert call_kwargs["subprocess_timeout"] > 0
@pytest.mark.asyncio
async def test_evidence_normal_path_has_no_evidence_gaps() -> None:
"""Byte-for-byte unchanged normal path: no evidence_gaps key at all when
nothing times out."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc"])
git_svc = AsyncMock()
git_svc.diff.return_value = "diff content"
git_svc.list_changed_files.return_value = ["README.md"]
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] is None, body
ev = body["evidence"]
assert ev["pr_diff_summary"] == "diff content"
assert ev["files_changed"] == ["README.md"]
assert "evidence_gaps" not in ev
@@ -7,6 +7,12 @@ gate runs. This removes the dominant stall where a loaded/weak-model PM
forgot the separate note(scope='decision') call and looped on a forgot the separate note(scope='decision') call and looped on a
tracing_gap respawn. The gate itself is unchanged (see tracing_gap respawn. The gate itself is unchanged (see
test_pm_decision_window.py) this only ensures a fresh decision exists. test_pm_decision_window.py) this only ensures a fresh decision exists.
``_ensure_pm_decision`` returns a ``PmDecisionOutcome`` ("fresh" / "wrote" /
"transient_failure" / "absent") the caller threads into the gate helper that
runs right after (see test_pm_decision_transient_failure.py for the
gate-satisfaction behavior itself) these tests pin the outcome value for
each branch.
""" """
from __future__ import annotations from __future__ import annotations
@@ -54,8 +60,11 @@ async def test_writes_decision_when_none_exists() -> None:
journal.latest_decision_at.return_value = None journal.latest_decision_at.return_value = None
c = Choreographer(_make_deps(journal=journal)) c = Choreographer(_make_deps(journal=journal))
await c._ensure_pm_decision(agent_id, task_id, "Merging PR #120; all ACs verified") outcome = await c._ensure_pm_decision(
agent_id, task_id, "Merging PR #120; all ACs verified"
)
assert outcome == "wrote"
journal.write_decision.assert_awaited_once() journal.write_decision.assert_awaited_once()
_args, kwargs = journal.write_decision.call_args _args, kwargs = journal.write_decision.call_args
assert kwargs["agent_id"] == agent_id assert kwargs["agent_id"] == agent_id
@@ -69,8 +78,9 @@ async def test_skips_when_fresh_decision_already_exists() -> None:
journal.latest_decision_at.return_value = datetime.now(UTC) - timedelta(seconds=60) journal.latest_decision_at.return_value = datetime.now(UTC) - timedelta(seconds=60)
c = Choreographer(_make_deps(journal=journal)) c = Choreographer(_make_deps(journal=journal))
await c._ensure_pm_decision(uuid4(), uuid4(), "rationale text here") outcome = await c._ensure_pm_decision(uuid4(), uuid4(), "rationale text here")
assert outcome == "fresh"
journal.write_decision.assert_not_awaited() journal.write_decision.assert_not_awaited()
@@ -82,8 +92,11 @@ async def test_writes_when_existing_decision_is_stale() -> None:
) )
c = Choreographer(_make_deps(journal=journal)) c = Choreographer(_make_deps(journal=journal))
await c._ensure_pm_decision(uuid4(), uuid4(), "fresh rationale around this point") outcome = await c._ensure_pm_decision(
uuid4(), uuid4(), "fresh rationale around this point"
)
assert outcome == "wrote"
journal.write_decision.assert_awaited_once() journal.write_decision.assert_awaited_once()
@@ -92,21 +105,29 @@ async def test_noop_on_empty_rationale() -> None:
journal = AsyncMock() journal = AsyncMock()
c = Choreographer(_make_deps(journal=journal)) c = Choreographer(_make_deps(journal=journal))
await c._ensure_pm_decision(uuid4(), uuid4(), " ") outcome_blank = await c._ensure_pm_decision(uuid4(), uuid4(), " ")
await c._ensure_pm_decision(uuid4(), uuid4(), None) outcome_none = await c._ensure_pm_decision(uuid4(), uuid4(), None)
assert outcome_blank == "absent"
assert outcome_none == "absent"
journal.latest_decision_at.assert_not_awaited() journal.latest_decision_at.assert_not_awaited()
journal.write_decision.assert_not_awaited() journal.write_decision.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_swallows_write_failure_best_effort() -> None: async def test_swallows_write_failure_best_effort() -> None:
"""A journal write failure must not crash the verb — the gate then """A journal write failure must not crash the verb. Round-2 fix: the
rejects normally (the pre-fix behaviour), never a 500.""" outcome is "transient_failure" (not swallowed into a bare None) so the
caller's gate can treat a DB hiccup as satisfied by the rationale
already in hand see test_pm_decision_transient_failure.py for the
gate-satisfaction behavior itself."""
journal = AsyncMock() journal = AsyncMock()
journal.latest_decision_at.return_value = None journal.latest_decision_at.return_value = None
journal.write_decision.side_effect = RuntimeError("db down") journal.write_decision.side_effect = RuntimeError("db down")
c = Choreographer(_make_deps(journal=journal)) c = Choreographer(_make_deps(journal=journal))
# Must not raise. # Must not raise.
await c._ensure_pm_decision(uuid4(), uuid4(), "rationale that triggers a write") outcome = await c._ensure_pm_decision(
uuid4(), uuid4(), "rationale that triggers a write"
)
assert outcome == "transient_failure"
@@ -0,0 +1,285 @@
"""PM-decision write-then-gate: transient DB failure must not launder into a
durable rejection/block.
Live bug: every PM verb (complete / submit_up / submit_root / unblock /
escalate_up / escalate_to_ceo / delegate) runs ``_ensure_pm_decision`` to
auto-record its own rationale as a journal:decision before the freshness
gate (``_check_pm_decision_required`` / ``_check_complete_gates`` /
``_check_submit_up_gates``) runs. The write can lock-timeout under DB
contention (a concurrent claim holding the task row's FK share lock); the
old contract swallowed that and let the gate reject a "missing decision"
the PM's own rationale already answered — a PM retries, keeps getting
rejected while contention lasts, then escalates, and the task ends up
BLOCKED. Transient congestion laundered into a durable blocked task.
Fix: ``_ensure_pm_decision`` returns a ``PmDecisionOutcome`` "fresh" /
"wrote" / "transient_failure" / "absent". Every gate helper accepts
``pm_decision_outcome`` (default ``None`` = legacy behavior unchanged) and
treats "transient_failure" as gate-satisfied for THIS call the rationale
is in the verb payload; the write was only ever a convenience. "absent" (no
rationale at all) still rejects exactly as before.
These tests exercise the gate helpers directly (the leanest harness that
reaches the actual decision-point) for precise, fast coverage of the
mechanism, plus one near-real-call-site test per verb family
(``_cell_pm_complete_guard``, ``escalate_up``) proving the real wiring.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.choreographer import _impl as _impl_module
from sqlalchemy.exc import OperationalError
def _make_choreographer(**overrides: Any) -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
return Choreographer(ChoreographerDeps(**base))
_TRANSIENT_WARNING_SUBSTRING = "gate satisfied by verb rationale"
# ---------------------------------------------------------------------------
# _check_pm_decision_required (unblock / escalate_up / escalate_to_ceo / delegate)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_check_pm_decision_required_transient_failure_satisfies_gate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A "transient_failure" outcome satisfies the gate for this call even
though no fresh decision exists the verb's own rationale is the
substance, the write was a convenience.
``_impl.py`` logs via ``structlog.get_logger()`` directly; absent this
process having called ``roboco.logging.setup_logging()`` (never true in
a bare unit-test run), structlog uses its own default global config and
never touches stdlib ``logging`` so ``caplog`` cannot see it. Patching
the module-level ``logger`` object is the reliable way to assert a
structlog call in this harness.
"""
mock_logger = MagicMock()
monkeypatch.setattr(_impl_module, "logger", mock_logger)
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = None # no fresh decision
c = _make_choreographer(journal=journal_svc)
t = MagicMock(id=uuid4())
env = await c._check_pm_decision_required(
"unblock",
uuid4(),
t.id,
t,
pm_decision_outcome="transient_failure",
)
assert env is None
mock_logger.warning.assert_called_once()
call = mock_logger.warning.call_args
assert _TRANSIENT_WARNING_SUBSTRING in call.args[0]
assert call.kwargs.get("verb") == "unblock"
@pytest.mark.asyncio
async def test_check_pm_decision_required_absent_still_rejects() -> None:
""" "absent" (no rationale, no fresh decision) rejects exactly as before
defense-in-depth unchanged."""
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = None
c = _make_choreographer(journal=journal_svc)
t = MagicMock(id=uuid4())
env = await c._check_pm_decision_required(
"unblock", uuid4(), t.id, t, pm_decision_outcome="absent"
)
assert env is not None
assert env.as_dict()["error"] == "tracing_gap"
@pytest.mark.asyncio
async def test_check_pm_decision_required_none_default_unchanged() -> None:
"""Every call site that hasn't threaded the outcome through (there are
none left in production, but the param defaults to None for legacy
parity) behaves byte-for-byte as before: no fresh decision rejects."""
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = None
c = _make_choreographer(journal=journal_svc)
t = MagicMock(id=uuid4())
env = await c._check_pm_decision_required("unblock", uuid4(), t.id, t)
assert env is not None
assert env.as_dict()["error"] == "tracing_gap"
@pytest.mark.asyncio
async def test_check_pm_decision_required_fresh_path_unchanged() -> None:
"""A genuinely fresh decision passes regardless of pm_decision_outcome
"fresh" is not a special-case bypass, it's the ordinary passing path."""
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
c = _make_choreographer(journal=journal_svc)
t = MagicMock(id=uuid4())
env = await c._check_pm_decision_required(
"unblock", uuid4(), t.id, t, pm_decision_outcome="fresh"
)
assert env is None
# ---------------------------------------------------------------------------
# _check_complete_gates (cell_pm / main_pm complete)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_check_complete_gates_transient_failure_satisfies_gate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""See ``test_check_pm_decision_required_transient_failure_satisfies_gate``
for why the module logger is patched directly instead of using caplog."""
mock_logger = MagicMock()
monkeypatch.setattr(_impl_module, "logger", mock_logger)
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False
journal_svc.has_reflect_for_task.return_value = False
c = _make_choreographer(journal=journal_svc)
env = await c._check_complete_gates(
uuid4(),
uuid4(),
"closing this task: the cell's contribution merged cleanly",
pm_decision_outcome="transient_failure",
)
assert env is None
mock_logger.warning.assert_called_once()
call = mock_logger.warning.call_args
assert _TRANSIENT_WARNING_SUBSTRING in call.args[0]
assert call.kwargs.get("verb") == "complete"
@pytest.mark.asyncio
async def test_check_complete_gates_absent_still_rejects() -> None:
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False
journal_svc.has_reflect_for_task.return_value = False
c = _make_choreographer(journal=journal_svc)
env = await c._check_complete_gates(
uuid4(),
uuid4(),
"closing this task: the cell's contribution merged cleanly",
pm_decision_outcome="absent",
)
assert env is not None
assert env.as_dict()["error"] == "tracing_gap"
@pytest.mark.asyncio
async def test_check_complete_gates_fresh_path_unchanged() -> None:
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.has_reflect_for_task.return_value = True
c = _make_choreographer(journal=journal_svc)
env = await c._check_complete_gates(
uuid4(),
uuid4(),
"closing this task: the cell's contribution merged cleanly",
pm_decision_outcome="fresh",
)
assert env is None
# ---------------------------------------------------------------------------
# _cell_pm_complete_guard — the real complete() call site, one hop above the
# gate helper, proving the outcome actually reaches it end to end (without
# dragging in the full cell_pm_complete verb's PR-merge/finalize machinery).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_guard_survives_journal_write_lock_timeout() -> None:
"""journal.write_decision raising (a lock-timeout under DB contention)
with a substantive ``notes`` rationale in hand must not reject the PM's
complete the guard clears (returns None) instead of tracing_gap."""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
assigned_to=pm_id,
status="awaiting_pm_review",
pr_number=42,
)
task_svc = AsyncMock()
task_svc.all_subtasks_terminal.return_value = True
task_svc.uncovered_parent_acceptance_criteria.return_value = []
# _ensure_pm_decision opens a session.begin_nested() savepoint before
# the write raises — an unshaped AsyncMock's auto-attribute return
# doesn't support `async with`, orphaning the mock's internal coroutine
# (AsyncMockMixin._execute_mock_call never awaited).
task_svc.session = MagicMock()
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False
journal_svc.has_reflect_for_task.return_value = False
journal_svc.latest_decision_at.return_value = None
journal_svc.write_decision.side_effect = OperationalError(
"INSERT INTO journal_entries (id, ...) VALUES (...)",
{},
Exception("canceling statement due to lock timeout"),
)
c = _make_choreographer(task=task_svc, journal=journal_svc)
env = await c._cell_pm_complete_guard(
pm_id, task_id, t, "closing this task: the cell's contribution merged cleanly"
)
task_svc.session.begin_nested.assert_called()
assert env is None, env.as_dict() if env is not None else None
@pytest.mark.asyncio
async def test_cell_pm_complete_guard_empty_notes_still_rejects() -> None:
"""No rationale at all (empty notes) AND no fresh decision on record
still rejects "absent" is not a bypass."""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
assigned_to=pm_id,
status="awaiting_pm_review",
pr_number=42,
)
task_svc = AsyncMock()
task_svc.all_subtasks_terminal.return_value = True
task_svc.uncovered_parent_acceptance_criteria.return_value = []
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False
journal_svc.has_reflect_for_task.return_value = False
journal_svc.latest_decision_at.return_value = None
c = _make_choreographer(task=task_svc, journal=journal_svc)
env = await c._cell_pm_complete_guard(pm_id, task_id, t, "")
assert env is not None
assert env.as_dict()["error"] == "tracing_gap"
-1
View File
@@ -88,7 +88,6 @@ def test_agent_instance_default_factory_assigns_id() -> None:
def test_orchestrator_agent_config_defaults() -> None: def test_orchestrator_agent_config_defaults() -> None:
cfg = OrchestratorAgentConfig( cfg = OrchestratorAgentConfig(
agent_id="be-dev-1", agent_id="be-dev-1",
blueprint_path=Path("/tmp/blueprint"), blueprint_path=Path("/tmp/blueprint"),
@@ -49,7 +49,6 @@ async def test_get_bool_parses_and_defaults() -> None:
async def test_apply_overrides_stored_flags_only( async def test_apply_overrides_stored_flags_only(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
# Baseline env defaults. # Baseline env defaults.
monkeypatch.setattr(cfg, "external_pr_enabled", False) monkeypatch.setattr(cfg, "external_pr_enabled", False)
monkeypatch.setattr(cfg, "research_enabled", True) monkeypatch.setattr(cfg, "research_enabled", True)
@@ -73,7 +72,6 @@ async def test_apply_overrides_stored_flags_only(
async def test_effective_values_use_env_default_when_unset( async def test_effective_values_use_env_default_when_unset(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
monkeypatch.setattr(cfg, "strategy_engine_enabled", True) monkeypatch.setattr(cfg, "strategy_engine_enabled", True)
async def fake_get(_self: SettingsService, _key: str) -> str | None: async def fake_get(_self: SettingsService, _key: str) -> str | None:
@@ -149,3 +149,80 @@ async def test_validator_timeout_fails_closed_and_reaps(
assert "timed out" in (result.get("reason") or "") assert "timed out" in (result.get("reason") or "")
fake_proc.kill.assert_called_once() fake_proc.kill.assert_called_once()
fake_proc.wait.assert_awaited_once() fake_proc.wait.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_conventions_validator_timeout_override_used_over_hardcoded(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An explicit ``timeout`` kwarg wins over the module-level hardcoded
default the advisory claim_review path's shorter budget must actually
reach the subprocess wait, not the fail-closed 120s cap. Sets the module
constant to something LONG (would never fire in the test's real time
budget) so a failure here would prove the override was ignored, not a
coincidence of both values being short."""
fake_proc = MagicMock()
fake_proc.returncode = None
async def _communicate() -> tuple[bytes, bytes]:
await asyncio.sleep(30)
return (b"", b"")
fake_proc.communicate = _communicate
fake_proc.kill = MagicMock()
fake_proc.wait = AsyncMock(return_value=-9)
async def _fake_exec(*_args: object, **_kwargs: object) -> object:
return fake_proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
monkeypatch.setattr(git_module, "_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS", 300)
svc = _service()
result = await svc._run_conventions_validator(tmp_path, ["a.py"], timeout=0.01)
assert result["could_not_run"] is True
assert "timed out after 0.01s" in (result.get("reason") or "")
def _task_with_id(branch_name: str) -> MagicMock:
"""Like ``_task`` but with a real UUID id — ``conventions_check_for_task``
calls ``require_uuid(task.id)`` outside the resolution try/except, so a
bare MagicMock id would raise before reaching the validator call."""
return MagicMock(branch_name=branch_name, id=uuid4())
@pytest.mark.asyncio
async def test_conventions_check_for_task_forwards_timeout_override() -> None:
"""``conventions_check_for_task``'s ``timeout`` kwarg must reach
``_run_conventions_validator`` the seam ``claim_review`` uses to pin
the ADVISORY (shorter) budget instead of the fail-closed default."""
svc = _service()
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "list_changed_files", AsyncMock(return_value=["a.py"]))
_bind(svc, "_worktree_for_task", MagicMock(return_value=Path("/tmp/wt")))
_bind(svc, "_ensure_worktree_for_commit", AsyncMock(return_value=None))
validator = AsyncMock(return_value={"findings": [], "could_not_run": False})
_bind(svc, "_run_conventions_validator", validator)
await svc.conventions_check_for_task(
uuid4(), _task_with_id("feature/backend/abc"), timeout=30.0
)
validator.assert_awaited_once_with(Path("/tmp/wt"), ["a.py"], timeout=30.0)
@pytest.mark.asyncio
async def test_conventions_check_for_task_default_timeout_is_none() -> None:
"""The fail-closed callers (i_am_done's ``_conventions_gate``, pr_pass's
``_conventions_guard``) never pass ``timeout`` confirming the default
forwards ``None`` so ``_run_conventions_validator`` falls back to its
hardcoded fail-closed cap, unchanged."""
svc = _service()
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "list_changed_files", AsyncMock(return_value=["a.py"]))
_bind(svc, "_worktree_for_task", MagicMock(return_value=Path("/tmp/wt")))
_bind(svc, "_ensure_worktree_for_commit", AsyncMock(return_value=None))
validator = AsyncMock(return_value={"findings": [], "could_not_run": False})
_bind(svc, "_run_conventions_validator", validator)
await svc.conventions_check_for_task(uuid4(), _task_with_id("feature/backend/abc"))
validator.assert_awaited_once_with(Path("/tmp/wt"), ["a.py"], timeout=None)
@@ -158,7 +158,7 @@ async def test_conventions_check_runs_validator_in_worktree_not_clone() -> None:
captured: list[Path] = [] captured: list[Path] = []
async def _capture_validator( async def _capture_validator(
workspace: Path, _files: list[str] workspace: Path, _files: list[str], **_kwargs: object
) -> dict[str, object]: ) -> dict[str, object]:
captured.append(Path(workspace)) captured.append(Path(workspace))
return {"findings": [], "could_not_run": False} return {"findings": [], "could_not_run": False}
@@ -169,7 +169,6 @@ async def test_create_rejects_cell_pm_assignee_plus_code() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_allows_cell_pm_assignee_plus_planning() -> None: async def test_create_allows_cell_pm_assignee_plus_planning() -> None:
be_pm_uuid = AGENTS["be-pm"].uuid be_pm_uuid = AGENTS["be-pm"].uuid
svc = TaskService( svc = TaskService(
MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock()) MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock())
@@ -0,0 +1,132 @@
"""fetch_branch_for_inspection's subprocess_timeout override (adversarial-review
round-2 fix 3): the fetch subprocess must self-bound near the caller's own leg
budget instead of running up to workspace_clone_timeout (300s) on the shared
DEFAULT asyncio executor (asyncio.to_thread, not git.py's dedicated
_GIT_EXECUTOR) after the caller has already given up waiting on it. A
timeout there now raises GitTimeoutError (mirroring _run_git's own
TimeoutExpired -> GitTimeoutError conversion in git.py), not a raw
subprocess.TimeoutExpired, so a bounded caller (run_bounded_leg) catches it
the same way as every other git-touching leg.
The clone-CREATION step (ensure_workspace, stubbed out here) is untouched by
this fix and keeps its own workspace_clone_timeout (300s) unconditionally
these tests isolate the FETCH subprocess only.
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.exceptions import GitTimeoutError
from roboco.services.workspace import WorkspaceService
if TYPE_CHECKING:
from pathlib import Path
# Named constant to satisfy ruff PLR2004 (magic value in comparison).
_EXPECTED_TIMEOUT_SECONDS = 5
def _service() -> WorkspaceService:
session = MagicMock()
session.execute = AsyncMock()
return WorkspaceService(session)
def _bind(svc: WorkspaceService, name: str, value: object) -> None:
"""Stub `name` on `svc` without tripping mypy's method-assign check."""
object.__setattr__(svc, name, value)
def _wire_resolution(svc: WorkspaceService, workspace: Path) -> None:
"""Stub the resolution steps ahead of the fetch subprocess so only the
fetch itself is under test."""
_bind(svc, "_resolve_branch_to_project_slug", AsyncMock(return_value="roboco"))
_bind(svc, "ensure_workspace", AsyncMock(return_value=workspace))
def _no_project_service_patch() -> Any:
"""No git token (project=None skips the decrypt path entirely)."""
return patch(
"roboco.services.project.get_project_service",
return_value=MagicMock(get_by_slug=AsyncMock(return_value=None)),
)
@pytest.mark.asyncio
async def test_default_subprocess_timeout_is_workspace_clone_timeout(
tmp_path: Path,
) -> None:
"""Every EXISTING caller omits subprocess_timeout — behavior byte-for-byte
unchanged: the fetch subprocess keeps the 300s workspace_clone_timeout."""
svc = _service()
_wire_resolution(svc, tmp_path)
captured: list[object] = []
def _fake_run(*_args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
captured.append(kwargs.get("timeout"))
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
_no_project_service_patch(),
):
await svc.fetch_branch_for_inspection(agent_id=uuid4(), branch_name="feature/x")
assert captured == [settings.workspace_clone_timeout]
@pytest.mark.asyncio
async def test_subprocess_timeout_override_reaches_the_fetch(tmp_path: Path) -> None:
svc = _service()
_wire_resolution(svc, tmp_path)
captured: list[object] = []
def _fake_run(*_args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
captured.append(kwargs.get("timeout"))
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
_no_project_service_patch(),
):
await svc.fetch_branch_for_inspection(
agent_id=uuid4(), branch_name="feature/x", subprocess_timeout=12.5
)
assert captured == [12.5]
@pytest.mark.asyncio
async def test_timeout_expired_becomes_git_timeout_error(tmp_path: Path) -> None:
"""Mirrors _run_git's own TimeoutExpired -> GitTimeoutError conversion
(git.py) so run_bounded_leg catches this uniformly with every other
git-touching leg, instead of a raw subprocess.TimeoutExpired propagating
uncaught to the RobocoError handler."""
svc = _service()
_wire_resolution(svc, tmp_path)
def _fake_run(*_args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
timeout = kwargs.get("timeout")
raise subprocess.TimeoutExpired(
cmd="git fetch",
timeout=float(timeout) if isinstance(timeout, int | float) else 0,
)
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
_no_project_service_patch(),
pytest.raises(GitTimeoutError) as exc_info,
):
await svc.fetch_branch_for_inspection(
agent_id=uuid4(), branch_name="feature/x", subprocess_timeout=5.0
)
assert exc_info.value.timeout == _EXPECTED_TIMEOUT_SECONDS
+1 -1
View File
@@ -350,7 +350,7 @@ def test_issue_agent_token_with_ttl_uses_expiring_format(
assert payload["id"] == "be-dev-1" assert payload["id"] == "be-dev-1"
assert payload["role"] == "developer" assert payload["role"] == "developer"
assert payload["team"] == "backend" assert payload["team"] == "backend"
assert isinstance(payload["iat"], (int, float)) assert isinstance(payload["iat"], int | float)
assert payload["exp"] == payload["iat"] + 3600 assert payload["exp"] == payload["iat"] + 3600