Adds the canonical cross-role handoff schema and its fail-closed validator, live-state recovery, role-limited continuation, mandatory durable posting, the merged-awaiting-controller boundary, controller accept/reject continuation, and workflow-failure escalation with duplicate handling. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
908 lines
31 KiB
Python
908 lines
31 KiB
Python
"""Self-propagating canonical handoffs through final controller closure (#626).
|
|
|
|
#494-#507 defined the canonical ledger, next-action comments, comment
|
|
validation, the controller acceptance gate, and the Canonical Thread Handoff
|
|
(CTH) shape. What none of them enforce is the *chain*: that every actor
|
|
consumes exactly one canonical handoff, performs exactly one authorized role,
|
|
records the result durably in Gitea, and emits the next complete handoff until
|
|
the controller records final closure.
|
|
|
|
This module owns that systemic gap:
|
|
|
|
* one canonical cross-role handoff schema (:data:`HANDOFF_FIELDS`);
|
|
* a fail-closed validator that rejects incomplete handoffs;
|
|
* live-state recovery so a receiving actor never trusts an inherited handoff;
|
|
* role-limited continuation;
|
|
* mandatory durable posting into Gitea;
|
|
* the ``merged-awaiting-controller`` boundary and controller accept/reject
|
|
continuation;
|
|
* workflow-failure escalation into separate durable issues, with duplicate
|
|
handling;
|
|
* terminal closure that must *not* emit an unnecessary next prompt.
|
|
|
|
Everything here is pure assessment: no network calls, no mutation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any, Iterable, Mapping, Sequence
|
|
|
|
MARKER = "<!-- sph:v1 -->"
|
|
HANDOFF_HEADING = "Canonical Handoff"
|
|
|
|
#: Canonical workflow states a handoff may declare.
|
|
WORKFLOW_STATES: tuple[str, ...] = (
|
|
"needs-author",
|
|
"needs-review",
|
|
"approved-awaiting-merge",
|
|
"merged-awaiting-controller",
|
|
"blocked",
|
|
"complete",
|
|
)
|
|
|
|
TERMINAL_STATES = frozenset({"complete"})
|
|
|
|
#: The single role authorized to act on each workflow state.
|
|
NEXT_ACTOR_BY_STATE: dict[str, str] = {
|
|
"needs-author": "author",
|
|
"needs-review": "reviewer",
|
|
"approved-awaiting-merge": "merger",
|
|
"merged-awaiting-controller": "controller",
|
|
"blocked": "operator",
|
|
"complete": "none",
|
|
}
|
|
|
|
WORKFLOW_ROLES = frozenset(
|
|
{"author", "reviewer", "merger", "controller", "operator", "reconciler"}
|
|
)
|
|
|
|
#: What each receiving role is authorized to do when it consumes a handoff.
|
|
ROLE_ALLOWED_ACTIONS: dict[str, tuple[str, ...]] = {
|
|
"author": ("implement", "commit", "push", "create_pr", "comment"),
|
|
"reviewer": ("review", "approve", "request_changes", "comment"),
|
|
"merger": ("verify_approval_parity", "merge", "comment"),
|
|
"controller": ("accept", "reject", "reopen", "close_issue", "comment"),
|
|
"operator": ("repair_infrastructure", "comment"),
|
|
"reconciler": ("close_superseded_pr", "cleanup_branch", "comment"),
|
|
}
|
|
|
|
ROLE_FORBIDDEN_ACTIONS: dict[str, tuple[str, ...]] = {
|
|
"author": ("approve", "request_changes", "merge", "close_issue"),
|
|
"reviewer": ("merge", "commit", "push", "create_pr"),
|
|
"merger": ("approve", "commit", "push", "create_pr"),
|
|
"controller": ("approve", "merge", "commit", "push"),
|
|
"operator": ("approve", "merge", "close_issue"),
|
|
"reconciler": ("approve", "merge", "commit", "push", "create_pr"),
|
|
}
|
|
|
|
#: Ordered canonical handoff fields. Every one of them is required; the
|
|
#: fields in :data:`NONE_ALLOWED_FIELDS` may legitimately carry ``none``.
|
|
HANDOFF_FIELDS: tuple[str, ...] = (
|
|
"REPOSITORY",
|
|
"ISSUE",
|
|
"PR",
|
|
"WORKFLOW_STATE",
|
|
"HEAD_SHA",
|
|
"BASE_BRANCH",
|
|
"BASE_OR_MERGE_SHA",
|
|
"ACTING_ROLE",
|
|
"ACTING_IDENTITY",
|
|
"COMPLETED_ACTIONS",
|
|
"VALIDATION_EVIDENCE",
|
|
"MUTATION_LEDGER",
|
|
"BLOCKERS",
|
|
"NEXT_ACTOR",
|
|
"NEXT_ACTION",
|
|
"PROHIBITED_ACTIONS",
|
|
"NEXT_PROMPT",
|
|
"WORKFLOW_FAILURE_ISSUES",
|
|
"LAST_UPDATED",
|
|
)
|
|
|
|
NONE_ALLOWED_FIELDS = frozenset(
|
|
{
|
|
"PR",
|
|
"HEAD_SHA",
|
|
"BASE_OR_MERGE_SHA",
|
|
"BLOCKERS",
|
|
"WORKFLOW_FAILURE_ISSUES",
|
|
"NEXT_PROMPT",
|
|
"NEXT_ACTION",
|
|
}
|
|
)
|
|
|
|
#: States where no PR or head SHA exists yet, so ``none`` is legitimate.
|
|
_PRE_PR_STATES = frozenset({"needs-author", "blocked"})
|
|
|
|
_PLACEHOLDERS = frozenset({"", "none", "n/a", "na", "tbd", "todo", "unknown", "?"})
|
|
|
|
#: A next prompt short enough to be a stub cannot be "ready to run".
|
|
MIN_NEXT_PROMPT_CHARS = 40
|
|
|
|
_FIELD_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", re.MULTILINE)
|
|
_HEADING_RE = re.compile(r"^##\s*Canonical Handoff\s*$", re.IGNORECASE | re.MULTILINE)
|
|
_EXTERNAL_CHAT_RE = re.compile(
|
|
r"\b(?:previous chat|prior conversation|earlier conversation|see (?:the )?chat|"
|
|
r"chat history|paste (?:this )?(?:from|into) chatgpt|ask the operator to paste)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
LIVE_DETECTION_KINDS: tuple[str, ...] = (
|
|
"changed_pr_head",
|
|
"stale_approval",
|
|
"issue_closed",
|
|
"issue_reopened",
|
|
"pr_merged",
|
|
"pr_closed_unmerged",
|
|
"stale_lease",
|
|
"foreign_lease",
|
|
"missing_worktree",
|
|
"dirty_worktree",
|
|
"namespace_mismatch",
|
|
"stale_runtime",
|
|
"changed_base",
|
|
"conflicting_canonical_comments",
|
|
)
|
|
|
|
CONTROLLER_DECISIONS = frozenset(
|
|
{
|
|
"accept",
|
|
"request_tests",
|
|
"request_proof",
|
|
"request_corrections",
|
|
"reopen",
|
|
"return_to_actor",
|
|
}
|
|
)
|
|
|
|
CONTROLLER_CLOSURE_PROOF_FIELDS = (
|
|
"acceptance_criteria_satisfied",
|
|
"cleanup_complete",
|
|
"canonical_final_state_posted",
|
|
"issue_closed_through_workflow",
|
|
)
|
|
|
|
WORKFLOW_FAILURE_FIELDS = (
|
|
"classification",
|
|
"linked_issue",
|
|
"temporary_impact",
|
|
"next_valid_actor",
|
|
"recovery_prompt",
|
|
)
|
|
|
|
|
|
def _is_placeholder(value: Any) -> bool:
|
|
return str(value or "").strip().lower() in _PLACEHOLDERS
|
|
|
|
|
|
def _clean(value: Any) -> str:
|
|
text = str(value).strip() if value is not None else ""
|
|
return text or "none"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rendering / parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def render_self_propagating_handoff(**values: Any) -> str:
|
|
"""Render a canonical cross-role handoff block.
|
|
|
|
Raises ``ValueError`` for an unknown workflow state so a malformed handoff
|
|
can never be produced by the sanctioned renderer.
|
|
"""
|
|
state = str(values.get("WORKFLOW_STATE", values.get("workflow_state", ""))).strip()
|
|
if state not in WORKFLOW_STATES:
|
|
raise ValueError(
|
|
f"unknown workflow state '{state}'; expected one of {list(WORKFLOW_STATES)}"
|
|
)
|
|
lines = [MARKER, f"## {HANDOFF_HEADING}", "", "```text"]
|
|
for name in HANDOFF_FIELDS:
|
|
raw = values.get(name, values.get(name.lower()))
|
|
lines.append(f"{name}: {_clean(raw)}")
|
|
lines.append("```")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def parse_self_propagating_handoff(text: str) -> dict[str, str] | None:
|
|
"""Parse a canonical handoff block, or ``None`` when absent."""
|
|
body = text or ""
|
|
if MARKER not in body and not _HEADING_RE.search(body):
|
|
return None
|
|
fields = {
|
|
match.group(1): match.group(2).strip()
|
|
for match in _FIELD_LINE_RE.finditer(body)
|
|
}
|
|
if not fields:
|
|
return None
|
|
return fields
|
|
|
|
|
|
def handoff_present(text: str) -> bool:
|
|
"""Whether *text* carries a canonical handoff block at all."""
|
|
return parse_self_propagating_handoff(text) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# handoff validation (AC: a validator rejects incomplete handoffs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def assess_self_propagating_handoff(text: str) -> dict[str, Any]:
|
|
"""Fail closed unless *text* carries one complete canonical handoff."""
|
|
fields = parse_self_propagating_handoff(text)
|
|
if fields is None:
|
|
return {
|
|
"valid": False,
|
|
"block": True,
|
|
"present": False,
|
|
"fields": {},
|
|
"missing_fields": list(HANDOFF_FIELDS),
|
|
"workflow_state": None,
|
|
"next_actor": None,
|
|
"terminal": False,
|
|
"reasons": ["report or comment carries no canonical handoff block"],
|
|
"safe_next_action": (
|
|
"add a canonical handoff block with all "
|
|
f"{len(HANDOFF_FIELDS)} fields before posting"
|
|
),
|
|
}
|
|
|
|
reasons: list[str] = []
|
|
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
|
terminal = state in TERMINAL_STATES
|
|
|
|
if state not in WORKFLOW_STATES:
|
|
reasons.append(
|
|
f"unknown WORKFLOW_STATE '{state or 'missing'}'; "
|
|
f"expected one of {list(WORKFLOW_STATES)}"
|
|
)
|
|
|
|
missing = [name for name in HANDOFF_FIELDS if name not in fields]
|
|
reasons.extend(f"handoff missing field: {name}" for name in missing)
|
|
|
|
for name in HANDOFF_FIELDS:
|
|
if name in missing:
|
|
continue
|
|
value = fields.get(name, "")
|
|
if not _is_placeholder(value):
|
|
continue
|
|
if name in NONE_ALLOWED_FIELDS:
|
|
continue
|
|
# A terminated chain names no next actor by design.
|
|
if name == "NEXT_ACTOR" and terminal:
|
|
continue
|
|
reasons.append(f"handoff field {name} must be concrete, got '{value or ''}'")
|
|
|
|
if state and state not in _PRE_PR_STATES and state in WORKFLOW_STATES:
|
|
for name in ("PR", "HEAD_SHA"):
|
|
if name not in missing and _is_placeholder(fields.get(name)):
|
|
reasons.append(
|
|
f"handoff field {name} must be concrete in state '{state}'"
|
|
)
|
|
|
|
if state == "blocked" and _is_placeholder(fields.get("BLOCKERS")):
|
|
reasons.append("state 'blocked' requires a concrete BLOCKERS entry")
|
|
|
|
declared_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
|
|
expected_actor = NEXT_ACTOR_BY_STATE.get(state)
|
|
if expected_actor and declared_actor != expected_actor:
|
|
reasons.append(
|
|
f"NEXT_ACTOR '{declared_actor or 'missing'}' does not match state "
|
|
f"'{state}', which authorizes '{expected_actor}'"
|
|
)
|
|
|
|
next_prompt = (fields.get("NEXT_PROMPT") or "").strip()
|
|
next_action = (fields.get("NEXT_ACTION") or "").strip()
|
|
if terminal:
|
|
# A completed workflow terminates; it must not manufacture more work.
|
|
if not _is_placeholder(next_prompt):
|
|
reasons.append(
|
|
"terminal state 'complete' must not carry a NEXT_PROMPT; "
|
|
"the chain ends at controller closure"
|
|
)
|
|
if not _is_placeholder(next_action):
|
|
reasons.append(
|
|
"terminal state 'complete' must not carry a NEXT_ACTION"
|
|
)
|
|
else:
|
|
if _is_placeholder(next_prompt):
|
|
reasons.append(
|
|
"non-terminal handoff requires a complete ready-to-run NEXT_PROMPT"
|
|
)
|
|
elif len(next_prompt) < MIN_NEXT_PROMPT_CHARS:
|
|
reasons.append(
|
|
"NEXT_PROMPT is too short to be ready-to-run "
|
|
f"({len(next_prompt)} < {MIN_NEXT_PROMPT_CHARS} characters)"
|
|
)
|
|
if _is_placeholder(next_action):
|
|
reasons.append("non-terminal handoff requires a concrete NEXT_ACTION")
|
|
|
|
acting_role = (fields.get("ACTING_ROLE") or "").strip().lower()
|
|
if acting_role and acting_role not in WORKFLOW_ROLES:
|
|
reasons.append(
|
|
f"unknown ACTING_ROLE '{acting_role}'; expected one of "
|
|
f"{sorted(WORKFLOW_ROLES)}"
|
|
)
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"valid": not block,
|
|
"block": block,
|
|
"present": True,
|
|
"fields": fields,
|
|
"missing_fields": missing,
|
|
"workflow_state": state or None,
|
|
"next_actor": declared_actor or None,
|
|
"terminal": terminal,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"complete every canonical handoff field before posting"
|
|
if block
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_thread_recoverability(text: str) -> dict[str, Any]:
|
|
"""The next actor must recover from the thread alone — never outside chat."""
|
|
assessment = assess_self_propagating_handoff(text)
|
|
if assessment["block"]:
|
|
return {
|
|
"recoverable": False,
|
|
"block": True,
|
|
"reasons": assessment["reasons"],
|
|
"safe_next_action": assessment["safe_next_action"],
|
|
}
|
|
|
|
fields = assessment["fields"]
|
|
reasons: list[str] = []
|
|
if assessment["terminal"]:
|
|
return {
|
|
"recoverable": True,
|
|
"block": False,
|
|
"reasons": [],
|
|
"safe_next_action": "proceed",
|
|
}
|
|
|
|
prompt = fields.get("NEXT_PROMPT", "")
|
|
repository = fields.get("REPOSITORY", "").strip()
|
|
issue = fields.get("ISSUE", "").strip().lstrip("#")
|
|
|
|
if repository and repository.lower() not in prompt.lower():
|
|
reasons.append("NEXT_PROMPT must name the repository it applies to")
|
|
if issue and issue not in prompt:
|
|
reasons.append(f"NEXT_PROMPT must name issue {issue}")
|
|
if _EXTERNAL_CHAT_RE.search(prompt):
|
|
reasons.append(
|
|
"NEXT_PROMPT must not depend on outside chat history; the issue or "
|
|
"PR thread, workflow docs, and live repository state must suffice"
|
|
)
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"recoverable": not block,
|
|
"block": block,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"rewrite NEXT_PROMPT so it is self-contained" if block else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# live-state recovery (AC: head changes invalidate stale review/merge handoffs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _detection(kind: str, detail: str) -> dict[str, str]:
|
|
return {"kind": kind, "detail": detail}
|
|
|
|
|
|
def assess_handoff_live_state(
|
|
*,
|
|
handoff: str | Mapping[str, str],
|
|
live: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Re-derive workflow truth from live state instead of trusting *handoff*.
|
|
|
|
*live* carries observed facts; absent keys are simply not checked, but any
|
|
fact that contradicts the inherited handoff fails closed.
|
|
"""
|
|
if isinstance(handoff, Mapping):
|
|
fields = dict(handoff)
|
|
else:
|
|
parsed = parse_self_propagating_handoff(handoff or "")
|
|
if parsed is None:
|
|
return {
|
|
"block": True,
|
|
"detections": [_detection("missing_handoff", "no canonical handoff")],
|
|
"kinds": ["missing_handoff"],
|
|
"reasons": ["no canonical handoff to reconcile against live state"],
|
|
"recovered_state": None,
|
|
"safe_next_action": "post a canonical handoff before continuing",
|
|
}
|
|
fields = parsed
|
|
|
|
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
|
next_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
|
|
detections: list[dict[str, str]] = []
|
|
recovered_state: str | None = None
|
|
|
|
handoff_head = (fields.get("HEAD_SHA") or "").strip()
|
|
live_head = str(live.get("pr_head_sha") or "").strip()
|
|
head_changed = bool(
|
|
live_head and handoff_head and not _is_placeholder(handoff_head)
|
|
and live_head != handoff_head
|
|
)
|
|
if head_changed:
|
|
detections.append(
|
|
_detection(
|
|
"changed_pr_head",
|
|
f"handoff pinned {handoff_head}, live head is {live_head}",
|
|
)
|
|
)
|
|
if next_actor in {"reviewer", "merger"}:
|
|
recovered_state = "needs-review"
|
|
|
|
approved_head = str(live.get("approved_head_sha") or "").strip()
|
|
if approved_head and live_head and approved_head != live_head:
|
|
detections.append(
|
|
_detection(
|
|
"stale_approval",
|
|
f"approval recorded at {approved_head}, live head is {live_head}",
|
|
)
|
|
)
|
|
if next_actor == "merger":
|
|
recovered_state = "needs-review"
|
|
|
|
issue_state = str(live.get("issue_state") or "").strip().lower()
|
|
if issue_state == "closed" and state not in TERMINAL_STATES:
|
|
detections.append(
|
|
_detection("issue_closed", "linked issue is closed but handoff is not complete")
|
|
)
|
|
if issue_state == "open" and state in TERMINAL_STATES:
|
|
detections.append(
|
|
_detection("issue_reopened", "handoff claims complete but the issue is open")
|
|
)
|
|
recovered_state = "needs-author"
|
|
|
|
pr_state = str(live.get("pr_state") or "").strip().lower()
|
|
if pr_state == "merged" and state in {
|
|
"needs-author",
|
|
"needs-review",
|
|
"approved-awaiting-merge",
|
|
}:
|
|
detections.append(
|
|
_detection("pr_merged", "PR is already merged; controller boundary applies")
|
|
)
|
|
recovered_state = "merged-awaiting-controller"
|
|
if pr_state == "closed" and state not in TERMINAL_STATES:
|
|
detections.append(
|
|
_detection("pr_closed_unmerged", "PR is closed without merge")
|
|
)
|
|
|
|
lease = live.get("lease") or {}
|
|
if isinstance(lease, Mapping) and lease:
|
|
lease_status = str(lease.get("status") or "").strip().lower()
|
|
if lease_status and lease_status != "active":
|
|
detections.append(
|
|
_detection("stale_lease", f"lease status is '{lease_status}'")
|
|
)
|
|
lease_session = str(lease.get("session_id") or "").strip()
|
|
actor_session = str(live.get("actor_session_id") or "").strip()
|
|
if lease_session and actor_session and lease_session != actor_session:
|
|
detections.append(
|
|
_detection(
|
|
"foreign_lease",
|
|
"lease is owned by another session; never adopt it implicitly",
|
|
)
|
|
)
|
|
|
|
worktree = live.get("worktree") or {}
|
|
if isinstance(worktree, Mapping) and worktree:
|
|
if worktree.get("present") is False:
|
|
detections.append(_detection("missing_worktree", "bound worktree is absent"))
|
|
if worktree.get("dirty") is True:
|
|
detections.append(
|
|
_detection("dirty_worktree", "bound worktree carries uncommitted changes")
|
|
)
|
|
|
|
namespace_role = str(live.get("namespace_role") or "").strip().lower()
|
|
if namespace_role and next_actor and next_actor != "none":
|
|
if namespace_role != next_actor:
|
|
detections.append(
|
|
_detection(
|
|
"namespace_mismatch",
|
|
f"live namespace role '{namespace_role}' cannot act as '{next_actor}'",
|
|
)
|
|
)
|
|
|
|
if live.get("runtime_stale") is True:
|
|
detections.append(
|
|
_detection("stale_runtime", "serving runtime is stale; reconnect required")
|
|
)
|
|
|
|
handoff_base = (fields.get("BASE_BRANCH") or "").strip()
|
|
live_base = str(live.get("base_branch") or "").strip()
|
|
if handoff_base and live_base and not _is_placeholder(handoff_base):
|
|
if handoff_base != live_base:
|
|
detections.append(
|
|
_detection(
|
|
"changed_base",
|
|
f"handoff base '{handoff_base}' but live base '{live_base}'",
|
|
)
|
|
)
|
|
|
|
if live.get("conflicting_canonical_comments") is True:
|
|
detections.append(
|
|
_detection(
|
|
"conflicting_canonical_comments",
|
|
"thread carries contradictory canonical comments",
|
|
)
|
|
)
|
|
|
|
kinds = [item["kind"] for item in detections]
|
|
reasons = [f"{item['kind']}: {item['detail']}" for item in detections]
|
|
block = bool(detections)
|
|
return {
|
|
"block": block,
|
|
"detections": detections,
|
|
"kinds": kinds,
|
|
"reasons": reasons,
|
|
"recovered_state": recovered_state,
|
|
"safe_next_action": (
|
|
"post a corrected canonical handoff for the recovered live state "
|
|
"before acting"
|
|
if block
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# role-limited continuation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def assess_role_continuation(
|
|
*,
|
|
handoff: str | Mapping[str, str],
|
|
actor_role: str,
|
|
) -> dict[str, Any]:
|
|
"""Only the role the current state authorizes may continue the chain."""
|
|
if isinstance(handoff, Mapping):
|
|
fields = dict(handoff)
|
|
else:
|
|
fields = parse_self_propagating_handoff(handoff or "") or {}
|
|
|
|
role = (actor_role or "").strip().lower()
|
|
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
|
expected = NEXT_ACTOR_BY_STATE.get(state)
|
|
reasons: list[str] = []
|
|
|
|
if not fields:
|
|
reasons.append("no canonical handoff to continue from")
|
|
if role not in WORKFLOW_ROLES:
|
|
reasons.append(f"unknown actor role '{actor_role}'")
|
|
if expected is None and fields:
|
|
reasons.append(f"unknown workflow state '{state}'")
|
|
elif expected == "none":
|
|
reasons.append(
|
|
"workflow state 'complete' is terminal; no further role may continue"
|
|
)
|
|
elif expected and role != expected:
|
|
reasons.append(
|
|
f"state '{state}' authorizes '{expected}', not '{role}'"
|
|
)
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"allowed": not block,
|
|
"block": block,
|
|
"expected_actor": expected,
|
|
"actor_role": role,
|
|
"allowed_actions": () if block else ROLE_ALLOWED_ACTIONS.get(role, ()),
|
|
"forbidden_actions": ROLE_FORBIDDEN_ACTIONS.get(role, ()),
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
f"hand off to '{expected}'" if block and expected else
|
|
"stop; the workflow is complete" if expected == "none" else
|
|
"proceed"
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# durable posting (AC: a chat-only report is never sufficient)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def assess_durable_state_update(
|
|
*,
|
|
handoff_text: str,
|
|
posted_comment_id: Any = None,
|
|
canonical_state_posted: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""A successful actor session must leave the handoff in Gitea, not chat."""
|
|
reasons: list[str] = []
|
|
assessment = assess_self_propagating_handoff(handoff_text)
|
|
if assessment["block"]:
|
|
reasons.extend(assessment["reasons"])
|
|
if not posted_comment_id:
|
|
reasons.append(
|
|
"canonical handoff was not posted to Gitea; a chat-only report is "
|
|
"not durable workflow state"
|
|
)
|
|
if not canonical_state_posted:
|
|
reasons.append(
|
|
"canonical issue/PR state and thread ledger were not updated"
|
|
)
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"durable": not block,
|
|
"block": block,
|
|
"posted_comment_id": posted_comment_id,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"post the canonical handoff and state update to Gitea before "
|
|
"ending the session"
|
|
if block
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# merge -> controller boundary and controller continuation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def assess_merge_completion_transition(
|
|
*,
|
|
merge_succeeded: bool,
|
|
controller_auto_accept: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""A merged PR is not accepted work until the controller says so."""
|
|
if not merge_succeeded:
|
|
return {
|
|
"next_state": "approved-awaiting-merge",
|
|
"next_actor": "merger",
|
|
"next_prompt_required": True,
|
|
"reasons": ["merge did not succeed; the merger retains the work item"],
|
|
}
|
|
if controller_auto_accept:
|
|
return {
|
|
"next_state": "complete",
|
|
"next_actor": "none",
|
|
"next_prompt_required": False,
|
|
"reasons": ["configured workflow authorizes automatic acceptance on merge"],
|
|
}
|
|
return {
|
|
"next_state": "merged-awaiting-controller",
|
|
"next_actor": "controller",
|
|
"next_prompt_required": True,
|
|
"reasons": [
|
|
"merge succeeded; acceptance requires the authorized controller"
|
|
],
|
|
}
|
|
|
|
|
|
def assess_controller_decision(
|
|
*,
|
|
decision: str,
|
|
closure_proof: Mapping[str, Any] | None = None,
|
|
return_to: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Controller acceptance or rejection produces the next or final state."""
|
|
normalized = (decision or "").strip().lower()
|
|
if normalized not in CONTROLLER_DECISIONS:
|
|
return {
|
|
"block": True,
|
|
"next_state": None,
|
|
"next_actor": None,
|
|
"next_prompt_required": True,
|
|
"reasons": [
|
|
f"unknown controller decision '{decision}'; expected one of "
|
|
f"{sorted(CONTROLLER_DECISIONS)}"
|
|
],
|
|
"safe_next_action": "record a supported controller decision",
|
|
}
|
|
|
|
if normalized == "accept":
|
|
proof = dict(closure_proof or {})
|
|
missing = [
|
|
name
|
|
for name in CONTROLLER_CLOSURE_PROOF_FIELDS
|
|
if proof.get(name) is not True
|
|
]
|
|
if missing:
|
|
return {
|
|
"block": True,
|
|
"next_state": "merged-awaiting-controller",
|
|
"next_actor": "controller",
|
|
"next_prompt_required": True,
|
|
"reasons": [
|
|
"controller acceptance missing closure proof: " + ", ".join(missing)
|
|
],
|
|
"safe_next_action": (
|
|
"satisfy and record every closure proof field before closing"
|
|
),
|
|
}
|
|
return {
|
|
"block": False,
|
|
"next_state": "complete",
|
|
"next_actor": "none",
|
|
"next_prompt_required": False,
|
|
"reasons": ["controller accepted; workflow chain terminates"],
|
|
"safe_next_action": "post the final canonical state and stop",
|
|
}
|
|
|
|
if normalized == "return_to_actor":
|
|
target = (return_to or "").strip().lower()
|
|
state_by_actor = {
|
|
"author": "needs-author",
|
|
"reviewer": "needs-review",
|
|
"merger": "approved-awaiting-merge",
|
|
}
|
|
if target not in state_by_actor:
|
|
return {
|
|
"block": True,
|
|
"next_state": None,
|
|
"next_actor": None,
|
|
"next_prompt_required": True,
|
|
"reasons": [
|
|
f"return_to_actor requires a target in {sorted(state_by_actor)}"
|
|
],
|
|
"safe_next_action": "name the actor the work returns to",
|
|
}
|
|
return {
|
|
"block": False,
|
|
"next_state": state_by_actor[target],
|
|
"next_actor": target,
|
|
"next_prompt_required": True,
|
|
"reasons": [f"controller returned the work item to '{target}'"],
|
|
"safe_next_action": f"post a complete handoff for '{target}'",
|
|
}
|
|
|
|
# request_tests / request_proof / request_corrections / reopen
|
|
return {
|
|
"block": False,
|
|
"next_state": "needs-author",
|
|
"next_actor": "author",
|
|
"next_prompt_required": True,
|
|
"reasons": [f"controller decision '{normalized}' returns the work to the author"],
|
|
"safe_next_action": "post a complete author handoff describing what is required",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# workflow-failure escalation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def assess_workflow_failure_escalation(
|
|
*,
|
|
failures: Sequence[Mapping[str, Any]] | None,
|
|
active_issue_number: int | str | None,
|
|
existing_failure_issues: Iterable[Mapping[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Tooling defects hit while working an issue become separate durable work."""
|
|
entries = list(failures or [])
|
|
known = {
|
|
str((item.get("signature") or "")).strip().lower(): item.get("number")
|
|
for item in (existing_failure_issues or [])
|
|
if str((item.get("signature") or "")).strip()
|
|
}
|
|
active = str(active_issue_number or "").strip().lstrip("#")
|
|
|
|
reasons: list[str] = []
|
|
reused: list[dict[str, Any]] = []
|
|
seen_signatures: dict[str, str] = {}
|
|
|
|
for index, failure in enumerate(entries):
|
|
label = str(failure.get("signature") or f"failure[{index}]")
|
|
missing = [
|
|
name
|
|
for name in WORKFLOW_FAILURE_FIELDS
|
|
if _is_placeholder(failure.get(name))
|
|
]
|
|
if missing:
|
|
reasons.append(
|
|
f"{label}: workflow failure missing " + ", ".join(missing)
|
|
)
|
|
|
|
linked = str(failure.get("linked_issue") or "").strip().lstrip("#")
|
|
if linked and active and linked == active:
|
|
reasons.append(
|
|
f"{label}: workflow defects must not be folded into the active "
|
|
f"work item #{active}; file a separate durable issue"
|
|
)
|
|
|
|
signature = str(failure.get("signature") or "").strip().lower()
|
|
if not signature:
|
|
continue
|
|
if signature in known:
|
|
expected = str(known[signature] or "").strip().lstrip("#")
|
|
if linked and expected and linked != expected:
|
|
reasons.append(
|
|
f"{label}: duplicate workflow-failure issue #{linked}; "
|
|
f"reuse the existing issue #{expected}"
|
|
)
|
|
else:
|
|
reused.append({"signature": signature, "issue": expected})
|
|
if signature in seen_signatures:
|
|
reasons.append(
|
|
f"{label}: duplicate workflow-failure signature reported twice "
|
|
"in one session"
|
|
)
|
|
else:
|
|
seen_signatures[signature] = linked
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"escalated": not block,
|
|
"block": block,
|
|
"failure_count": len(entries),
|
|
"reused_issues": reused,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"file or reference one durable issue per distinct workflow failure"
|
|
if block
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# final-report integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def assess_final_report_self_propagating_handoff(report_text: str) -> dict[str, Any]:
|
|
"""#626 gate for final reports.
|
|
|
|
Applicability mirrors the #495 canonical-state gate: once a report adopts
|
|
the protocol — by carrying the marker, the ``Canonical Handoff`` heading,
|
|
or a ``WORKFLOW_STATE`` line — the full schema is enforced. Reports that
|
|
predate the protocol are untouched here; the workflow schemas require the
|
|
block going forward.
|
|
"""
|
|
text = report_text or ""
|
|
applicable = (
|
|
MARKER in text
|
|
or bool(_HEADING_RE.search(text))
|
|
or bool(re.search(r"^WORKFLOW_STATE\s*:", text, re.MULTILINE))
|
|
)
|
|
if not applicable:
|
|
return {
|
|
"applicable": False,
|
|
"valid": True,
|
|
"block": False,
|
|
"reasons": [],
|
|
"safe_next_action": "proceed",
|
|
}
|
|
|
|
assessment = assess_self_propagating_handoff(text)
|
|
recoverability = assess_thread_recoverability(text)
|
|
reasons = list(assessment["reasons"])
|
|
if not assessment["block"]:
|
|
reasons.extend(recoverability.get("reasons") or [])
|
|
block = bool(assessment["block"] or recoverability.get("block"))
|
|
return {
|
|
"applicable": True,
|
|
"valid": not block,
|
|
"block": block,
|
|
"workflow_state": assessment.get("workflow_state"),
|
|
"next_actor": assessment.get("next_actor"),
|
|
"terminal": assessment.get("terminal"),
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
assessment["safe_next_action"]
|
|
if assessment["block"]
|
|
else recoverability.get("safe_next_action", "proceed")
|
|
),
|
|
}
|