oc_chat
Loading...
Searching...
No Matches
mirror_observer.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/mirror_observer.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/unit/test_257_dispatcher_seams.py"
6"""
7Registry-side listener for the dispatch layer's plan-state events.
8
9Purpose
10-------
11Plan 257 P1 (A7, cut 1). This module owns the legacy-compat mirroring
12that used to live inside ``oc_chat/scripts/plan_status.py``: opening the
13orchestration registry, taking the legacy-compat write lock, and
14replaying a worker's ``.PLAN_STATUS.md`` lifecycle into the ``plans``
15table. The code was moved here verbatim, not rewritten -- the point of
16the move is that the dispatch layer stops naming the registry, not that
17the mirroring behaves differently.
18
19Responsibilities
20----------------
21- :class:`RegistryMirrorObserver` -- implements
22 :class:`oc_chat.core.state_observer.StateObserver` against the live
23 orchestration registry.
24- :func:`compat_context` -- the fail-soft registry handle the mirror
25 runs on: ``None`` when there is no registry to write to.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.MAIN
30Levels:
31 L2 - lifecycle events (mirror attempts, drift reports)
32 L3 - semantic details (skipped mirrors, absent registry)
33 L4 - deep tracing (module load)
34
35Contracts
36---------
37- Behaviour is byte-identical to the pre-257 ``plan_status`` mirror: the
38 same ``REGISTRY DRIFT - <operation> failed: <Type>: <msg>. Run `oct
39 agent migrate verify`.`` line on stderr, the same return code ``2``,
40 and the same silent ``0`` when no registry file exists.
41- A missing registry is not an error. A registry that exists but has no
42 active operator slot IS -- that refusal is deliberately re-raised out
43 of :func:`compat_context` and reported as drift.
44- No exception escapes a callback. Every failure becomes the drift line
45 plus return code ``2``.
46- The registry handle is injectable so a witness can drive every branch
47 without a SQLite file.
48
49Dependencies
50------------
51- oc_diagnostics (structured debug logging via _dbg)
52- oc_chat.registry (lazy; Registry facade opened by compat_context)
53- oc_chat.registry.backends.sqlite (lazy; SQLiteBackend and the active operator slot in compat_context)
54- oc_chat.registry.discovery (lazy; discover_registry_location in compat_context)
55- oc_chat.registry.domain (lazy; plan_create, plan_assign and plan_set_status writers for the mirrors)
56- oc_chat.scripts.plan_status (lazy; _plan_number for the plan_created mirror)
57"""
58
59from __future__ import annotations
60
61import sys
62from typing import Callable
63
64from oc_diagnostics import _dbg
65
66_dbg("OC_CHAT.MAIN", "module", "mirror_observer loaded", 4)
67
68
69#: What a mirror needs to write: an open registry, its backend, and the
70#: actor slot the write is attributed to. ``None`` means "no registry
71#: here", which is a silent skip rather than a failure.
72CompatContext = Callable[[], "tuple[object, object, str] | None"]
73
74
75def compat_context() -> tuple[object, object, str] | None:
76 """Open the discovered orchestration registry, or return ``None``.
77
78 Moved verbatim from ``plan_status._compat_context`` (plan 257 P1).
79 The broad ``except`` is deliberate and pre-existing: a worker must be
80 able to record its own status in a checkout that has no registry at
81 all. The one refusal that is NOT swallowed is a registry that exists
82 but has no active operator slot, because writing unattributed rows
83 would be worse than refusing.
84 """
85 func = "compat_context"
86 try:
87 from oc_chat.registry import Registry
88 from oc_chat.registry.backends.sqlite import SQLiteBackend
89 from oc_chat.registry.discovery import discover_registry_location
90
91 location = discover_registry_location()
92 if not location.registry_path.is_file():
93 return None
94 backend = SQLiteBackend(location.registry_path, check_capabilities=False)
95 actor_slot = backend.active_operator_slot()
96 if actor_slot is None:
97 raise RuntimeError("no active operator slot found")
98 return Registry(backend), backend, actor_slot
99 except Exception as exc:
100 if "no active operator slot" in str(exc):
101 raise
102 _dbg("OC_CHAT.MAIN", func, f"no registry to mirror into: {exc}", 3)
103 return None
104
105
106def registry_plan_exists(registry: object, plan_id: str) -> bool:
107 """True when *plan_id* already has a row in the registry."""
108 return registry_plan_status(registry, plan_id) is not None
109
110
111def registry_plan_status(registry: object, plan_id: str) -> str | None:
112 """The registry's current status for *plan_id*, or ``None``."""
113 row = registry_plan_row(registry, plan_id)
114 return None if row is None else str(row["status"])
115
116
117def registry_plan_row(registry: object, plan_id: str) -> object | None:
118 """The registry's ``plans`` row for *plan_id*, or ``None``."""
119 connect = getattr(getattr(registry, "backend", None), "connect", None)
120 if connect is None:
121 return None
122 with connect() as conn:
123 row = conn.execute(
124 # OCT-LINT: disable=no_hardcoded_secrets reason="Status enum values, model IDs, and CLI choice strings are not secrets"
125 "SELECT status, assigned_slot, queue_position, branch, worktree "
126 "FROM plans WHERE plan_id = ?",
127 (plan_id,),
128 ).fetchone()
129 return row
130
131
133 """Mirrors dispatch-layer plan events into the orchestration registry.
134
135 The status tool registers one of these by default, so a worker that
136 changes nothing sees exactly the pre-257 behaviour. Passing
137 ``context_factory`` swaps the registry handle, which is how the
138 witnesses drive the drift and skip branches without a SQLite file.
139 """
140
141 def __init__(self, *, context_factory: CompatContext | None = None) -> None:
142 self._context_factory: CompatContext = context_factory or compat_context
143
145 self,
146 *,
147 plan_id: str,
148 title: str,
149 plan_type: str | None,
150 body_path: str,
151 body_sha256: str,
152 body_observed_at: str | None,
153 model: str,
154 thinking: str,
155 session: str,
156 branch: str,
157 worktree: str,
158 target_status: str,
159 ) -> int:
160 """Create the plan row, re-anchor its assignment, set its status."""
161 func = "plan_created"
162 _dbg("OC_CHAT.MAIN", func, f"mirroring init for {plan_id}", 2)
163
164 def _mirror(registry: object, actor_slot: str) -> None:
165 from oc_chat.registry.domain import plan_assign, plan_create, plan_set_status
166
167 # Derived here rather than by the caller so a plan id with no
168 # numeric prefix still reports as drift and returns 2, exactly as
169 # it did when this whole block lived inside the status tool. The
170 # registry -> dispatch direction is the permitted one and is
171 # already precedented by registry/migrate.py.
172 from oc_chat.scripts.plan_status import _plan_number
173
174 plan_create(
175 registry,
176 plan_id=plan_id,
177 plan_number=_plan_number(plan_id),
178 title=title,
179 plan_type=plan_type,
180 body_path=body_path,
181 body_sha256=body_sha256,
182 body_observed_at=body_observed_at,
183 model_override=model,
184 thinking_override=thinking,
185 session_override=session,
186 actor_slot=actor_slot,
187 actor_type="human_operator",
188 command="plan_status init",
189 )
190 plan_row = registry_plan_row(registry, plan_id)
191 if (
192 plan_row is not None
193 and plan_row["assigned_slot"] is not None
194 and plan_row["queue_position"] is not None
195 and (
196 str(plan_row["branch"] or "") != branch
197 or str(plan_row["worktree"] or "") != worktree
198 )
199 ):
200 plan_assign(
201 registry,
202 plan_id=plan_id,
203 from_slot=str(plan_row["assigned_slot"]),
204 to_slot=str(plan_row["assigned_slot"]),
205 branch=branch,
206 worktree=worktree,
207 queue_position=int(plan_row["queue_position"]),
208 actor_slot=actor_slot,
209 actor_type="human_operator",
210 command="plan_status init",
211 )
212 from_status = registry_plan_status(registry, plan_id) or "draft"
213 if target_status != from_status:
214 plan_set_status(
215 registry,
216 plan_id=plan_id,
217 from_status=from_status,
218 to_status=target_status,
219 source="worker_self",
220 note=(
221 "legacy plan_status init"
222 if target_status in {"partial", "failed"}
223 else None
224 ),
225 actor_slot=actor_slot,
226 actor_type="human_operator",
227 command="plan_status init",
228 )
229
230 return self._run("plan_status.init", _mirror)
231
233 self,
234 *,
235 plan_id: str,
236 from_status: str,
237 to_status: str,
238 note: str | None,
239 ) -> int:
240 """Record one status transition against the registry."""
241 func = "status_changed"
242 _dbg("OC_CHAT.MAIN", func, f"mirroring {from_status} -> {to_status}", 2)
243
244 def _mirror(registry: object, actor_slot: str) -> None:
245 from oc_chat.registry.domain import plan_set_status
246
247 plan_set_status(
248 registry,
249 plan_id=plan_id,
250 from_status=from_status,
251 to_status=to_status,
252 source="worker_self",
253 note=note,
254 actor_slot=actor_slot,
255 actor_type="human_operator",
256 command="plan_status set",
257 )
258
259 return self._run("plan.set_status", _mirror)
260
261 def _run(
262 self,
263 operation_type: str,
264 callback: Callable[[object, str], None],
265 ) -> int:
266 """Take the legacy-compat lock and run *callback*; report drift.
267
268 Moved verbatim from ``plan_status._mirror_plan_status``.
269 """
270 func = "_run"
271 try:
272 context = self._context_factory()
273 if context is None:
274 return 0
275 registry, _backend, actor_slot = context
276 lock = registry.acquire_lock(
277 "legacy-compat-write",
278 actor_slot,
279 60,
280 {"source": "plan_status", "operation_type": operation_type},
281 )
282 try:
283 callback(registry, actor_slot)
284 finally:
285 lock.release()
286 return 0
287 except Exception as exc:
288 # EXTERNAL_ERROR: the registry is a separate store that may be
289 # locked, migrating or absent; the worker's own status file is
290 # already written, so this is reported, never raised.
291 _dbg(
292 "OC_CHAT.MAIN",
293 func,
294 f"EXTERNAL_ERROR: {operation_type}: {type(exc).__name__}: {exc}",
295 2,
296 )
297 print(
298 f"REGISTRY DRIFT - {operation_type} failed: {type(exc).__name__}: {exc}. "
299 "Run `oct agent migrate verify`.",
300 file=sys.stderr,
301 )
302 return 2
303
304
305__all__ = [
306 "CompatContext",
307 "RegistryMirrorObserver",
308 "compat_context",
309 "registry_plan_exists",
310 "registry_plan_row",
311 "registry_plan_status",
312]
int status_changed(self, *, str plan_id, str from_status, str to_status, str|None note)
int _run(self, str operation_type, Callable[[object, str], None] callback)
int plan_created(self, *, str plan_id, str title, str|None plan_type, str body_path, str body_sha256, str|None body_observed_at, str model, str thinking, str session, str branch, str worktree, str target_status)
None __init__(self, *, CompatContext|None context_factory=None)
str|None registry_plan_status(object registry, str plan_id)
tuple[object, object, str]|None compat_context()
bool registry_plan_exists(object registry, str plan_id)
object|None registry_plan_row(object registry, str plan_id)