Files
roboco/tests/foundation/test_tracing.py
T
aa15dc40cc Feature/video artifact verification (#537)
* fix(release): CI wait polls the prod rung; escape the header tooltip apostrophe

get_latest_ci_conclusion defaults to the ladder's head rung, so
wait_for_ci searched slave for a release commit that lives on master
and timed out after 40 minutes with the run already green. The wait
now passes the prod branch explicitly. Also fixes the
react/no-unescaped-entities error that turned master's Panel CI red.

* fix(panel,video): dead dialog triggers behind tooltips; dotted composition ids render

HelpTip nested inside a Dialog/AlertDialog trigger puts the trigger's
click handler on the Tooltip root, which renders no DOM — the agents
Spawn item and the KB Reindex-All / Delete-index confirms were dead.
Tooltips now wrap the triggers. The video renderer accepts interior
single dots in composition ids (release-0.25.0) with '..' still
unrepresentable, and propose_video refuses an unrenderable id at
authoring time.

* fix(dispatch): restart-safe PM review turns

A leaf task in awaiting_pm_review had no periodic pickup: the closure
dispatcher bailed on childless tasks and skipped PR-bearing review
tasks as already-promoted, assuming the submit-time PM session was
still alive — an assumption every restart breaks. Proven live on the
docs-sync leaf after the 0.25.0 redeploy, which also dependency-blocked
its sibling dev task. Childless awaiting_pm_review tasks now flow to
the PM's review turn, and the merge turn respawns its PM when none is
active.

* feat(video): verify the rendered artifact, not the source

The 14s release-0.25.0 cut shipped with only one of four scenes visibly
registering: the dev authored DOM, the smoke asserted DOM, QA read code —
nobody consumed the rendered MP4 before the CEO did. Close that loop, and
the reject loop behind it:

- sidecar frames mode: POST /render with frames=1..32 renders the cut,
  ffprobes the REAL duration, extracts midpoint-sampled keyframe PNGs
  (timestamps in filenames), streams a tar.gz back with X-Video-Duration
- request_render do-verb (developer/QA, request_sandbox's shape): renders
  the caller's ACTUAL composition — dev's own worktree (head_sha/dirty
  provenance), QA a read-only git-archive export of the assembled branch —
  extracts frames to the container-shared .previews/ path, stamps the
  render_preview marker, returns the paths as envelope evidence
- gate: i_am_done on a source=video task refuses without a stamped
  render_preview (Requirement.RENDER_VERIFIED; canonical source string
  moved to foundation as markers.VIDEO_TASK_SOURCE; mirrored in the
  possibilities-matrix fast path so it cannot bypass the check)
- QA claim_review evidence carries video_context (composition id, the
  dev's preview, a re-render instruction) so review checks output
- dev spawn prompt block + a 4th authoring AC order Read-every-frame
  verification before submitting
- reject -> re-author: a CEO reject with a reason opens a fresh authoring
  task carrying the verbatim feedback + a revise-in-place pointer at the
  existing composition (best-effort, never fails the reject) — rejection
  feedback no longer dies on the cancelled draft

E2E: rendered the committed release-0.25.0 composition through the new
frames mode locally — the returned keyframes show exactly the reported
failure (blank frame at 5.8s, only 'Env ladder' by 12.8s), the check the
fleet was missing.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-16 19:49:26 +02:00

238 lines
7.9 KiB
Python

"""Tier 1 — tracing Requirement enum + check_requirements scaffolding."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from roboco.foundation.policy import tracing
def test_requirement_enum_has_canonical_values() -> None:
"""Required-set vocabulary mirrors the pre-Phase-2 tracing_gate.py
PLUS the new pre-gateway parity additions."""
expected = {
"plan",
"commits>=1",
"pr_open",
"progress>=1",
"journal:reflect",
"journal:decision",
"journal:learning",
"journal:struggle",
"journal:note_at_claim",
"journal:decision_at_claim",
"journal:during_work>=1",
"acceptance_criteria_addressed",
"qa_notes>=min",
"qa_evidence_inspected",
"docs_notes>=min",
"docs_files_non_empty",
"self_verified",
"notes>=min",
"subtasks_terminal",
"dev_notes>=min",
"pr_reviewer_notes>=min",
"quick_context>=min",
"findings_addressed",
"render_verified",
}
actual = {r.value for r in tracing.Requirement}
assert actual == expected, f"Requirement drift: {actual ^ expected}"
def test_gate_context_has_journal_presence_flags() -> None:
ctx = tracing.GateContext()
assert ctx.journal_reflect_present is False
assert ctx.journal_decision_present is False
assert ctx.journal_learning_present is False
assert ctx.journal_struggle_present is False
assert ctx.journal_note_at_claim_present is False
assert ctx.journal_during_work_count == 0
def test_gate_result_has_passed_and_missing() -> None:
result = tracing.GateResult(passed=True)
assert result.passed is True
assert result.missing == []
def test_docs_notes_checker_reads_doc_notes_not_dev_notes() -> None:
"""Regression: _check_docs_notes_min_chars must read ``doc_notes`` (the
documenter's section), not ``dev_notes`` (the developer's)."""
ctx = tracing.GateContext(docs_notes_min_chars=20)
# A long dev_notes must NOT satisfy the docs requirement.
only_dev = SimpleNamespace(dev_notes="x" * 50, doc_notes="")
assert tracing._check_docs_notes_min_chars(only_dev, ctx) == ["docs_notes>=min"]
# A long doc_notes satisfies it.
has_doc = SimpleNamespace(dev_notes="", doc_notes="y" * 25)
assert tracing._check_docs_notes_min_chars(has_doc, ctx) == []
def test_note_section_checkers_read_their_own_fields() -> None:
ctx = tracing.GateContext() # defaults: dev 40, pr_reviewer 40, quick_context 30
assert (
tracing._check_dev_notes_min_chars(SimpleNamespace(dev_notes="z" * 40), ctx)
== []
)
assert tracing._check_dev_notes_min_chars(
SimpleNamespace(dev_notes="z" * 39), ctx
) == ["dev_notes>=min"]
assert (
tracing._check_pr_reviewer_notes_min_chars(
SimpleNamespace(pr_reviewer_notes="z" * 40), ctx
)
== []
)
assert tracing._check_quick_context_min_chars(
SimpleNamespace(quick_context="z" * 29), ctx
) == ["quick_context>=min"]
def test_note_section_obligations_wired_to_verbs() -> None:
assert tracing.Requirement.DEV_NOTES_MIN_CHARS in tracing.requirements_for(
"i_am_done"
)
assert tracing.Requirement.QUICK_CONTEXT_MIN_CHARS in tracing.requirements_for(
"delegate"
)
for verb in ("pr_pass", "pr_fail", "post_pr_review"):
assert tracing.Requirement.PR_REVIEWER_NOTES_MIN_CHARS in (
tracing.requirements_for(verb)
)
def test_check_requirements_passes_when_all_satisfied() -> None:
task = SimpleNamespace(
plan={"x": 1},
commits=[{"sha": "abc"}],
pr_number=42,
progress_updates=[{"message": "x"}],
acceptance_criteria=[],
acceptance_criteria_status=[],
)
ctx = tracing.GateContext(journal_reflect_present=True)
result = tracing.check_requirements(
task=task,
requirements=[
tracing.Requirement.PLAN,
tracing.Requirement.COMMITS_AT_LEAST_ONE,
tracing.Requirement.PR_OPEN,
tracing.Requirement.JOURNAL_REFLECT,
],
ctx=ctx,
)
assert result.passed is True
def test_check_requirements_returns_missing_keys_on_failure() -> None:
task = SimpleNamespace(
plan=None,
commits=[],
pr_number=None,
progress_updates=[],
acceptance_criteria=[],
acceptance_criteria_status=[],
)
result = tracing.check_requirements(
task=task,
requirements=[
tracing.Requirement.PLAN,
tracing.Requirement.COMMITS_AT_LEAST_ONE,
tracing.Requirement.PR_OPEN,
tracing.Requirement.JOURNAL_REFLECT,
],
)
assert result.passed is False
assert "plan" in result.missing
assert "commits>=1" in result.missing
assert "pr_open" in result.missing
assert "journal:reflect" in result.missing
def test_acceptance_criteria_check_treats_reflect_note_as_addressing_artifact() -> None:
"""Spec §9 item 1: reflect-note clears the criteria gate."""
task = SimpleNamespace(
acceptance_criteria=["AC1", "AC2"],
acceptance_criteria_status=[],
)
ctx = tracing.GateContext(journal_reflect_present=True)
result = tracing.check_requirements(
task=task,
requirements=[tracing.Requirement.ACCEPTANCE_CRITERIA_ADDRESSED],
ctx=ctx,
)
assert result.passed is True
def test_during_work_count_satisfies_requirement() -> None:
task = SimpleNamespace()
ctx = tracing.GateContext(journal_during_work_count=1)
result = tracing.check_requirements(
task=task,
requirements=[tracing.Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE],
ctx=ctx,
)
assert result.passed is True
def test_verb_requirements_covers_pm_decision_chain() -> None:
"""The 6 inline journal:decision callsites' verbs all require it."""
for verb in (
"submit_up",
"complete",
"unblock",
"escalate_up",
"escalate_to_ceo",
"delegate",
):
reqs = tracing.requirements_for(verb)
assert tracing.Requirement.JOURNAL_DECISION in reqs, (
f"{verb} should require journal:decision per spec §11"
)
def test_verb_requirements_covers_dev_completion_chain() -> None:
reqs = tracing.requirements_for("i_am_done")
assert tracing.Requirement.COMMITS_AT_LEAST_ONE in reqs
assert tracing.Requirement.PR_OPEN in reqs
assert tracing.Requirement.PROGRESS_AT_LEAST_ONE in reqs
assert tracing.Requirement.JOURNAL_REFLECT in reqs
assert tracing.Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE in reqs
assert tracing.Requirement.ACCEPTANCE_CRITERIA_ADDRESSED in reqs
def test_verb_requirements_includes_pre_gateway_parity_at_claim() -> None:
assert tracing.Requirement.JOURNAL_NOTE_AT_CLAIM in tracing.requirements_for(
"i_will_work_on"
)
assert tracing.Requirement.JOURNAL_DECISION_AT_CLAIM in tracing.requirements_for(
"i_will_plan"
)
def test_pm_complete_requires_both_decision_and_reflect() -> None:
"""Pre-gateway parity P4: PMs wrote both before complete."""
reqs = tracing.requirements_for("complete")
assert tracing.Requirement.JOURNAL_DECISION in reqs
assert tracing.Requirement.JOURNAL_REFLECT in reqs
def test_qa_pass_review_requires_learning() -> None:
reqs = tracing.requirements_for("pass_review")
assert tracing.Requirement.QA_NOTES_MIN_CHARS in reqs
assert tracing.Requirement.QA_EVIDENCE_INSPECTED in reqs
assert tracing.Requirement.JOURNAL_LEARNING in reqs
def test_i_am_blocked_requires_struggle_journal() -> None:
"""Lifts JOURNAL_STRUGGLE out of dangling-enum status."""
assert tracing.Requirement.JOURNAL_STRUGGLE in tracing.requirements_for(
"i_am_blocked"
)
def test_requirements_for_unknown_verb_raises_key_error() -> None:
with pytest.raises(KeyError):
tracing.requirements_for("not_a_real_verb")