Files
roboco/roboco/foundation/policy/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

473 lines
19 KiB
Python

"""Tracing-gate policy — verb→required-set table + check_requirements.
Replaces:
- services/gateway/tracing_gate.py (the entire module)
- 6 inline `journal:decision` checks scattered in choreographer/_impl.py
- inline gates in choreographer/qa.py (QA pass/fail)
- inline gates in choreographer/doc.py (i_documented)
Adds (per spec §11 P1-P4 pre-gateway parity restorations):
- JOURNAL_NOTE_AT_CLAIM — required by i_will_work_on
- JOURNAL_DECISION_AT_CLAIM — required by i_will_plan
- JOURNAL_DURING_WORK_AT_LEAST_ONE — required by i_am_done
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from roboco.foundation.policy.content import markers
class Requirement(StrEnum):
PLAN = "plan"
COMMITS_AT_LEAST_ONE = "commits>=1"
PR_OPEN = "pr_open"
PROGRESS_AT_LEAST_ONE = "progress>=1"
JOURNAL_REFLECT = "journal:reflect"
JOURNAL_DECISION = "journal:decision"
JOURNAL_LEARNING = "journal:learning"
JOURNAL_STRUGGLE = "journal:struggle"
JOURNAL_NOTE_AT_CLAIM = "journal:note_at_claim"
JOURNAL_DECISION_AT_CLAIM = "journal:decision_at_claim"
JOURNAL_DURING_WORK_AT_LEAST_ONE = "journal:during_work>=1"
ACCEPTANCE_CRITERIA_ADDRESSED = "acceptance_criteria_addressed"
QA_NOTES_MIN_CHARS = "qa_notes>=min"
QA_EVIDENCE_INSPECTED = "qa_evidence_inspected"
DOCS_NOTES_MIN_CHARS = "docs_notes>=min"
DOCS_FILES_NON_EMPTY = "docs_files_non_empty"
SELF_VERIFIED = "self_verified"
NOTES_MIN_CHARS = "notes>=min"
SUBTASKS_TERMINAL = "subtasks_terminal"
# Role note-section obligations (parity with the journal requirements): a
# role with a dedicated note section must populate it via
# note(scope='handoff') the same way journals are obligated. The developer's
# dev_notes, the PR reviewer's pr_reviewer_notes, and the PM's quick_context
# had no agent write-path before — these obligate the section now that one
# exists.
DEV_NOTES_MIN_CHARS = "dev_notes>=min"
PR_REVIEWER_NOTES_MIN_CHARS = "pr_reviewer_notes>=min"
QUICK_CONTEXT_MIN_CHARS = "quick_context>=min"
# Revision-findings ledger resolution gate (i_am_done): every OPEN
# finding on the task (from a prior qa_fail/pr_fail/request_changes/
# ceo_reject) must be addressed — via i_am_done's `resolved_findings` —
# before the developer can resubmit. Trivially satisfied by an empty
# ledger (no findings ever filed).
FINDINGS_ADDRESSED = "findings_addressed"
# A video-authoring task must carry a request_render preview before
# i_am_done — proof the author looked at the rendered artifact, not just
# the source. No-op for a non-video task.
RENDER_VERIFIED = "render_verified"
@dataclass(frozen=True)
class GateContext:
"""Ambient inputs the checker needs that don't live on the Task model."""
journal_reflect_present: bool = False
journal_decision_present: bool = False
journal_learning_present: bool = False
journal_struggle_present: bool = False
journal_note_at_claim_present: bool = False
journal_during_work_count: int = 0
qa_notes_min_chars: int = 80
docs_notes_min_chars: int = 20
notes_min_chars: int = 20
dev_notes_min_chars: int = 40
pr_reviewer_notes_min_chars: int = 40
quick_context_min_chars: int = 30
# 8-char ledger ids (str(finding.id)[:8]) still OPEN on the task, computed
# by the caller (the choreographer, which has DB access this pure module
# does not) AFTER applying any `resolved_findings` from this same call.
open_finding_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class GateResult:
passed: bool
missing: list[str] = field(default_factory=list)
Checker = Callable[[Any, GateContext], list[str]]
def _check_plan(task: Any, _ctx: GateContext) -> list[str]:
return [] if getattr(task, "plan", None) else ["plan"]
def _check_commits(task: Any, _ctx: GateContext) -> list[str]:
commits = getattr(task, "commits", None) or []
return [] if len(commits) >= 1 else ["commits>=1"]
def _check_pr_open(task: Any, _ctx: GateContext) -> list[str]:
return [] if getattr(task, "pr_number", None) else ["pr_open"]
def _check_progress(task: Any, _ctx: GateContext) -> list[str]:
progress = getattr(task, "progress_updates", None) or []
return [] if len(progress) >= 1 else ["progress>=1"]
def _check_journal_reflect(_task: Any, ctx: GateContext) -> list[str]:
return [] if ctx.journal_reflect_present else ["journal:reflect"]
def _check_journal_decision(_task: Any, ctx: GateContext) -> list[str]:
return [] if ctx.journal_decision_present else ["journal:decision"]
def _check_journal_learning(_task: Any, ctx: GateContext) -> list[str]:
return [] if ctx.journal_learning_present else ["journal:learning"]
def _check_journal_struggle(_task: Any, ctx: GateContext) -> list[str]:
return [] if ctx.journal_struggle_present else ["journal:struggle"]
def _check_journal_note_at_claim(_task: Any, ctx: GateContext) -> list[str]:
return [] if ctx.journal_note_at_claim_present else ["journal:note_at_claim"]
def _check_journal_decision_at_claim(_task: Any, ctx: GateContext) -> list[str]:
# Reuse JOURNAL_DECISION presence flag; "_at_claim" timing is the
# caller's responsibility (i_will_plan only requires a decision entry
# exists for this task by this agent — its position in the timeline
# is enforced by the verb's call order, not the gate).
return [] if ctx.journal_decision_present else ["journal:decision_at_claim"]
def _check_journal_during_work(_task: Any, ctx: GateContext) -> list[str]:
return [] if ctx.journal_during_work_count >= 1 else ["journal:during_work>=1"]
def _unaddressed_criteria(task: Any) -> list[str]:
criteria = list(getattr(task, "acceptance_criteria", []) or [])
status_rows = list(getattr(task, "acceptance_criteria_status", []) or [])
addressed = {
s["criterion"]
for s in status_rows
if isinstance(s, dict) and s.get("referencing_artifact_id")
}
return [c for c in criteria if c not in addressed]
def _check_acceptance_criteria(task: Any, ctx: GateContext) -> list[str]:
"""Reflect-note serves as the addressing artifact when explicit
per-criterion citation is absent. See spec §9 item 1."""
if ctx.journal_reflect_present:
return []
return [f"acceptance_criterion:{c}" for c in _unaddressed_criteria(task)]
def _check_qa_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
notes = getattr(task, "qa_notes", "") or ""
return [] if len(notes) >= ctx.qa_notes_min_chars else ["qa_notes>=min"]
def _check_qa_evidence_inspected(task: Any, _ctx: GateContext) -> list[str]:
return (
[]
if getattr(task, "qa_evidence_inspected", False)
else ["qa_evidence_inspected"]
)
def _check_docs_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
notes = getattr(task, "doc_notes", "") or ""
return [] if len(notes) >= ctx.docs_notes_min_chars else ["docs_notes>=min"]
def _check_dev_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
notes = getattr(task, "dev_notes", "") or ""
return [] if len(notes) >= ctx.dev_notes_min_chars else ["dev_notes>=min"]
def _check_pr_reviewer_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
notes = getattr(task, "pr_reviewer_notes", "") or ""
return (
[]
if len(notes) >= ctx.pr_reviewer_notes_min_chars
else ["pr_reviewer_notes>=min"]
)
def _check_quick_context_min_chars(task: Any, ctx: GateContext) -> list[str]:
notes = getattr(task, "quick_context", "") or ""
return [] if len(notes) >= ctx.quick_context_min_chars else ["quick_context>=min"]
def _check_docs_files_non_empty(task: Any, _ctx: GateContext) -> list[str]:
docs = getattr(task, "documents", None) or []
return [] if len(docs) >= 1 else ["docs_files_non_empty"]
def _check_self_verified(task: Any, _ctx: GateContext) -> list[str]:
return [] if getattr(task, "self_verified", False) else ["self_verified"]
def _check_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
notes = getattr(task, "notes", "") or ""
return [] if len(notes) >= ctx.notes_min_chars else ["notes>=min"]
def _check_findings_addressed(_task: Any, ctx: GateContext) -> list[str]:
"""One ``finding:<id8>`` miss per still-open ledger row.
Mirrors ``_check_acceptance_criteria``'s ``acceptance_criterion:<name>``
shape — the caller (``_build_tracing_gap``) batches these into one
multi-finding hint instead of a bare per-finding token.
"""
return [f"finding:{fid}" for fid in ctx.open_finding_ids]
def _check_render_verified(task: Any, _ctx: GateContext) -> list[str]:
"""The gate that makes a video dev look at the rendered artifact."""
if getattr(task, "source", None) != markers.VIDEO_TASK_SOURCE:
return []
return [] if markers.get_render_preview(task) else ["render_preview"]
def _check_subtasks_terminal(task: Any, _ctx: GateContext) -> list[str]:
"""Caller passes a task whose `_subtasks_all_terminal` boolean is set
by the choreographer based on a DB query. Validator just reads it."""
return (
[] if getattr(task, "_subtasks_all_terminal", False) else ["subtasks_terminal"]
)
_CHECKERS: dict[Requirement, Checker] = {
Requirement.PLAN: _check_plan,
Requirement.COMMITS_AT_LEAST_ONE: _check_commits,
Requirement.PR_OPEN: _check_pr_open,
Requirement.PROGRESS_AT_LEAST_ONE: _check_progress,
Requirement.JOURNAL_REFLECT: _check_journal_reflect,
Requirement.JOURNAL_DECISION: _check_journal_decision,
Requirement.JOURNAL_LEARNING: _check_journal_learning,
Requirement.JOURNAL_STRUGGLE: _check_journal_struggle,
Requirement.JOURNAL_NOTE_AT_CLAIM: _check_journal_note_at_claim,
Requirement.JOURNAL_DECISION_AT_CLAIM: _check_journal_decision_at_claim,
Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE: _check_journal_during_work,
Requirement.ACCEPTANCE_CRITERIA_ADDRESSED: _check_acceptance_criteria,
Requirement.QA_NOTES_MIN_CHARS: _check_qa_notes_min_chars,
Requirement.QA_EVIDENCE_INSPECTED: _check_qa_evidence_inspected,
Requirement.DOCS_NOTES_MIN_CHARS: _check_docs_notes_min_chars,
Requirement.DOCS_FILES_NON_EMPTY: _check_docs_files_non_empty,
Requirement.SELF_VERIFIED: _check_self_verified,
Requirement.NOTES_MIN_CHARS: _check_notes_min_chars,
Requirement.SUBTASKS_TERMINAL: _check_subtasks_terminal,
Requirement.DEV_NOTES_MIN_CHARS: _check_dev_notes_min_chars,
Requirement.PR_REVIEWER_NOTES_MIN_CHARS: _check_pr_reviewer_notes_min_chars,
Requirement.QUICK_CONTEXT_MIN_CHARS: _check_quick_context_min_chars,
Requirement.FINDINGS_ADDRESSED: _check_findings_addressed,
Requirement.RENDER_VERIFIED: _check_render_verified,
}
def check_requirements(
*,
task: Any,
requirements: list[Requirement],
ctx: GateContext | None = None,
) -> GateResult:
"""Run every requirement in `requirements` against `task` + `ctx`.
Returns GateResult(passed, missing) — `missing` is empty on pass.
"""
context = ctx or GateContext()
missing: list[str] = []
for req in requirements:
missing.extend(_CHECKERS[req](task, context))
return GateResult(passed=len(missing) == 0, missing=missing)
# Verb name → required Requirements (single source of truth).
VERB_REQUIREMENTS: dict[str, frozenset[Requirement]] = {
# Developer claim — pre-gateway DEVELOPER.md required a work_log entry on claim.
# PLAN mirrors spec.PRECONDITION_PLAN at the tracing layer (single source of truth).
"i_will_work_on": frozenset(
{
Requirement.PLAN,
Requirement.JOURNAL_NOTE_AT_CLAIM,
}
),
# PM claim — pre-gateway PM.md required a journal:decision on plan.
"i_will_plan": frozenset(
{
Requirement.PLAN,
Requirement.JOURNAL_DECISION_AT_CLAIM,
}
),
# PM delegate — pre-gateway PM.md required journal:decision before each
# delegate. QUICK_CONTEXT_MIN_CHARS obligates the PM's resumption section
# (quick_context) on the parent: satisfiable because the PM pre-writes it via
# note(scope='handoff', section={done, next, ...}) before delegate.
"delegate": frozenset(
{Requirement.JOURNAL_DECISION, Requirement.QUICK_CONTEXT_MIN_CHARS}
),
# Developer submit — adds JOURNAL_DURING_WORK_AT_LEAST_ONE for mid-flight cadence.
# SELF_VERIFIED is set by the auto-run in_progress→verifying transition; it
# stays in the required-set as a defense-in-depth backstop.
"i_am_done": frozenset(
{
Requirement.COMMITS_AT_LEAST_ONE,
Requirement.PR_OPEN,
Requirement.PROGRESS_AT_LEAST_ONE,
Requirement.SELF_VERIFIED,
Requirement.JOURNAL_REFLECT,
Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE,
Requirement.ACCEPTANCE_CRITERIA_ADDRESSED,
# The developer's dedicated section: obligated like the journal.
# Satisfiable because the dev pre-writes dev_notes via
# note(scope='handoff') before i_am_done (write-then-gate).
Requirement.DEV_NOTES_MIN_CHARS,
# Every OPEN revision-ledger finding must be addressed (via
# `resolved_findings`) before resubmitting. Trivially satisfied
# when the ledger has no rows for this task.
Requirement.FINDINGS_ADDRESSED,
# A video-authoring task must have a request_render preview on
# file. No-op for every other task source.
Requirement.RENDER_VERIFIED,
}
),
# QA pass/fail.
"pass_review": frozenset(
{
Requirement.QA_NOTES_MIN_CHARS,
Requirement.QA_EVIDENCE_INSPECTED,
Requirement.JOURNAL_LEARNING,
}
),
"fail_review": frozenset(
{
Requirement.QA_NOTES_MIN_CHARS,
Requirement.QA_EVIDENCE_INSPECTED,
Requirement.JOURNAL_LEARNING,
}
),
# PR reviewer posts its change-request — must record a learning entry first,
# and fill its dedicated pr_reviewer_notes section. The section note is the
# verb's own argument (review body), so it is checked via a SimpleNamespace
# shim at the call site, not the persisted task (write-then-gate: the arg
# isn't on the task yet — same pattern as qa_notes).
"post_pr_review": frozenset(
{Requirement.JOURNAL_LEARNING, Requirement.PR_REVIEWER_NOTES_MIN_CHARS}
),
# In-path PR-review gate: the reviewer records a learning entry before
# passing or failing the assembled PR (parity with post_pr_review), and
# fills pr_reviewer_notes (the verb's notes/issues argument, shimmed).
"pr_pass": frozenset(
{Requirement.JOURNAL_LEARNING, Requirement.PR_REVIEWER_NOTES_MIN_CHARS}
),
"pr_fail": frozenset(
{Requirement.JOURNAL_LEARNING, Requirement.PR_REVIEWER_NOTES_MIN_CHARS}
),
# Doc submit.
"i_documented": frozenset(
{
Requirement.DOCS_FILES_NON_EMPTY,
Requirement.DOCS_NOTES_MIN_CHARS,
Requirement.JOURNAL_REFLECT,
}
),
# PM submit-up — adds JOURNAL_REFLECT (pre-gateway required decision AND reflect).
# FINDINGS_ADDRESSED closes the pr_gate/pm/ceo-origin resolution gap: a
# bounced coordination root had no equivalent of i_am_done's resolution
# gate, so a re-submit could sail past open findings unaddressed.
"submit_up": frozenset(
{
Requirement.SUBTASKS_TERMINAL,
Requirement.JOURNAL_DECISION,
Requirement.JOURNAL_REFLECT,
Requirement.NOTES_MIN_CHARS,
Requirement.FINDINGS_ADDRESSED,
}
),
# Main PM submit-root — root analogue of submit_up (opens the root→master
# PR + enters the gate). Same accountability set.
"submit_root": frozenset(
{
Requirement.SUBTASKS_TERMINAL,
Requirement.JOURNAL_DECISION,
Requirement.JOURNAL_REFLECT,
Requirement.NOTES_MIN_CHARS,
Requirement.FINDINGS_ADDRESSED,
}
),
# PM complete — adds JOURNAL_REFLECT (parity with submit_up).
"complete": frozenset(
{
Requirement.JOURNAL_DECISION,
Requirement.JOURNAL_REFLECT,
Requirement.NOTES_MIN_CHARS,
}
),
# PM merge-review reject — a decision like complete; issues are enforced
# verb-side (non-empty + soup check), so no notes requirement here.
"request_changes": frozenset({Requirement.JOURNAL_DECISION}),
# PM unblock — was inline at _impl.py:2192-2200; now declared.
"unblock": frozenset({Requirement.JOURNAL_DECISION}),
# PM escalate up — was inline.
"escalate_up": frozenset({Requirement.JOURNAL_DECISION}),
# Board/MainPM escalate to CEO.
"escalate_to_ceo": frozenset({Requirement.JOURNAL_DECISION}),
# Developer block — pre-gateway required journal:struggle.
"i_am_blocked": frozenset({Requirement.JOURNAL_STRUGGLE}),
}
# Verbs that intentionally have no tracing requirement (read-only / discovery /
# pure state moves). Each entry is a deliberate decision, not an oversight.
VERBS_WITHOUT_TRACING: frozenset[str] = frozenset(
{
"give_me_work", # discovery — no state change
"triage", # read-only listing
"triage_all", # read-only listing
"evidence", # read-only evidence dump
"i_am_idle", # signal only
"unclaim", # voluntary release; no rationale required
"reassign", # mechanical intra-cell hand-off; branch/WIP preserved
# declare_coverage is a mechanical AC-ref stamp + its own audit row
# (task.coverage_declared) — no journal rationale required.
"declare_coverage",
"resume", # pure state move paused→in_progress
# claim_review's tracing applies on pass_review / fail_review.
"claim_review",
# claim_pr_review's tracing applies on post_pr_review.
"claim_pr_review",
# claim_gate_review's tracing applies on pr_pass / pr_fail.
"claim_gate_review",
# claim_doc_task's tracing applies on i_documented.
"claim_doc_task",
# open_pr is a mechanical push+open; preconditions are inline.
"open_pr",
# sync_branch is a git-only rebase+force-push through the gate; no DB
# state change, no journal/plan rationale (mirrors open_pr).
"sync_branch",
# waive_finding is an auditor curation move: the required note is the
# rationale, captured on the ledger row + a task.finding_waived audit
# event — no task status change, no journal:decision needed.
"waive_finding",
}
)
def requirements_for(verb: str) -> frozenset[Requirement]:
"""Lookup the required-set for a verb.
Raises KeyError when the verb is neither in VERB_REQUIREMENTS nor in
VERBS_WITHOUT_TRACING — caller should never reach a verb name unknown to
foundation.
"""
if verb in VERB_REQUIREMENTS:
return VERB_REQUIREMENTS[verb]
if verb in VERBS_WITHOUT_TRACING:
return frozenset()
raise KeyError(
f"unknown verb in tracing table: {verb!r} "
f"(known: {sorted(set(VERB_REQUIREMENTS) | VERBS_WITHOUT_TRACING)})"
)