oc_chat
Loading...
Searching...
No Matches
escalation.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/round/escalation.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_round_close_escalation.py"
6"""
7Versioned escalation contract for round-close failures.
8
9Purpose
10-------
11Render a stable, machine-readable escalation document (OI-218, OI-316)
12whenever a round-close step stops and needs operator attention.
13
14Responsibilities
15----------------
16- Enumerate the stable error-code set for round-close stops.
17- Carry a ``resume_command`` except where recovery is rollback-only.
18- Serialize deterministically for logs and operator prompts.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - escalation raised
25 L3 - code and resume-command details
26 L4 - deep tracing
27
28Contracts
29---------
30- ``STAGING_BRANCH_DIVERGED`` always renders an empty ``resume_command``.
31- The schema is versioned; consumers branch on ``schema_version``.
32
33Dependencies
34------------
35- oc_diagnostics (structured debug logging via _dbg)
36- dataclasses (the frozen Escalation record)
37- enum (the EscalationCode enum)
38"""
39from __future__ import annotations
40
41from dataclasses import dataclass, field
42from enum import Enum
43from typing import Mapping
44
45from oc_diagnostics import _dbg
46
47
48_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
49
50ESCALATION_SCHEMA_VERSION = "1.0"
51
52
53class EscalationCode(str, Enum):
54 """Stable round-close escalation codes."""
55
56 STAGING_BRANCH_DIVERGED = "STAGING_BRANCH_DIVERGED"
57 MERGE_CONFLICT = "MERGE_CONFLICT"
58 MANUAL_MERGE_REQUIRED = "MANUAL_MERGE_REQUIRED"
59 TEST_FAILED = "TEST_FAILED"
60 LINT_FAILED = "LINT_FAILED"
61 PREFLIGHT_FAILED = "PREFLIGHT_FAILED"
62 LOCK_HELD = "LOCK_HELD"
63 ROUND_STATE = "ROUND_STATE"
64 CLEANUP_FAILED = "CLEANUP_FAILED"
65 ROADMAP_BUMP_FAILED = "ROADMAP_BUMP_FAILED"
66 ROADMAP_UNTRACKED_EDIT = "E_ROADMAP_UNTRACKED_EDIT"
67
68
69# The one non-resumable class: the operator committed to the staging branch
70# outside the job, so re-running merges on top would silently incorporate it.
71NON_RESUMABLE_CODES = frozenset({EscalationCode.STAGING_BRANCH_DIVERGED})
72
73
74@dataclass(frozen=True)
76 """A versioned, serializable round-close escalation document."""
77
78 code: EscalationCode
79 round_no: int
80 phase: str
81 message: str
82 resume_command: str | None = None
83 details: Mapping[str, object] = field(default_factory=dict)
84 schema_version: str = ESCALATION_SCHEMA_VERSION
85
86 def to_dict(self) -> dict[str, object]:
87 """Return a deterministic, JSON-serializable mapping."""
88
89 func = "to_dict"
90 resume = "" if self.code in NON_RESUMABLE_CODES else (self.resume_command or "")
91 _dbg("OC_CHAT.MAIN", func, f"render {self.code.value}", 4)
92 return {
93 "schema_version": self.schema_version,
94 "kind": "round_close_escalation",
95 "code": self.code.value,
96 "round_no": self.round_no,
97 "phase": self.phase,
98 "message": self.message,
99 "resume_command": resume,
100 "details": dict(self.details),
101 }
102
103
104class RoundEscalation(RuntimeError):
105 """Carries an :class:`Escalation` payload up to the orchestrator surface."""
106
107 def __init__(self, escalation: Escalation) -> None:
108 self.escalation = escalation
109 super().__init__(f"{escalation.code.value}: {escalation.message}")
110
111
113 code: EscalationCode,
114 *,
115 round_no: int,
116 phase: str,
117 message: str,
118 resume_command: str | None = None,
119 details: Mapping[str, object] | None = None,
120) -> Escalation:
121 """Build an :class:`Escalation`, enforcing the non-resumable policy."""
122
123 func = "make_escalation"
124 effective_resume = None if code in NON_RESUMABLE_CODES else resume_command
125 _dbg("OC_CHAT.MAIN", func, f"build {code.value} round={round_no}", 2)
126 return Escalation(
127 code=code,
128 round_no=round_no,
129 phase=phase,
130 message=message,
131 resume_command=effective_resume,
132 details=dict(details or {}),
133 )
134
135
136def resume_command_for(round_no: int, *, slot: str | None = None) -> str:
137 """Return the canonical resume command string for a round."""
138
139 func = "resume_command_for"
140 _dbg("OC_CHAT.MAIN", func, f"resume cmd round={round_no}", 4)
141 base = f"oct agent round resume {round_no}"
142 return base if slot is None else f"{base} --as {slot}"
None __init__(self, Escalation escalation)
Escalation make_escalation(EscalationCode code, *, int round_no, str phase, str message, str|None resume_command=None, Mapping[str, object]|None details=None)
str resume_command_for(int round_no, *, str|None slot=None)