mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user