oc_chat
Loading...
Searching...
No Matches
queue_backend.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/queue_backend.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_258_queue_backend.py"
6"""
7The registry backend: the queue's operations over the registry's own.
8
9Purpose
10-------
11Plan 258 P3. The queue has one operation set and two backends. This is
12the one oc_chat plugs in, and it lives on the REGISTRY side on purpose:
13signing, audit and checkpoints already live here, so the queue inherits
14them instead of growing a second copy. It implements the same
15:class:`~oc_chat.core.work_queue.QueueBackend` protocol over the four
16typed operations that ``registry/domain.py`` already exposes --
17``queue_enqueue``, ``queue_move``, ``queue_dequeue``, ``queue_complete``
18-- and writes nothing else: every mutation is one operation-log row with
19``related_plan_id`` set, and the registry's own projection updates the
20``plans`` table.
21
22The import direction is registry -> dispatch, which is the permitted
23one and the one ``registry/mirror_observer.py`` already takes. Nothing
24in the dispatch layer imports this module.
25
26**The vocabulary mismatch, named rather than papered over.** The
27registry's operations are slot-shaped and reason-closed; the contract's
28are lane-shaped and reason-free. Three things do not fit, and each is
29handled in the open:
30
31``lane`` does not map onto ``slot``
32 MEASURED against a temporary registry, not assumed: ``plans``
33 declares ``assigned_slot TEXT REFERENCES agents(slot)`` and
34 ``PRAGMA foreign_keys`` is on, so a free-text slot is refused with
35 ``sqlite3.IntegrityError: FOREIGN KEY constraint failed``. The
36 adapter therefore takes ONE registered slot at construction and
37 refuses a work item whose ``lane`` names anything else, by name.
38 No roster was invented to make the mapping work.
39
40``instance`` and the contract's own reason have no column
41 The queue payloads are fixed and adding a field is a registry
42 change this plan does not make. They ride in ``command``, the
43 operation log's free-text provenance column, in a deterministic
44 form this module also parses back.
45
46``complete`` needs a round and a merge commit
47 ``queue_complete`` requires ``round_no`` and ``merge_commit_sha``.
48 They are taken as keyword arguments -- which the file backend
49 accepts and ignores -- rather than smuggled through ``reason``,
50 because a round number and a sha packed into free text would be a
51 parser in disguise.
52
53Responsibilities
54----------------
55- :class:`RegistryQueueBackend` -- the six contract operations.
56- :data:`ENQUEUE_REASON` / :data:`DEQUEUE_REASON_DEFAULT` -- the two
57 closed-set members this adapter maps onto, pinned as constants so the
58 witness can assert them.
59- :class:`LaneIsNotASlotError`, :class:`MissingRegistryArgumentError`,
60 :class:`UnknownPlanRowError`, :class:`UnsupportedItemKindError` --
61 the adapter's own named refusals, all under the contract's
62 :class:`~oc_chat.core.work_queue.QueueBackendError`.
63
64Diagnostics
65-----------
66Domain: OC_CHAT.MAIN
67Levels:
68 L2 - lifecycle events (an operation emitted)
69 L3 - semantic details (a mapping, a refusal)
70 L4 - deep tracing (module load)
71
72Contracts
73---------
74- This module MUTATES only through ``registry.domain``: no SQL write,
75 no direct table update. Its reads are SELECTs through
76 ``registry.readers`` and one connection for the ``plans`` projection.
77- ``status()`` takes the pending ORDER from the ``plans`` table's
78 ``assigned_slot`` / ``queue_position`` projection -- that projection
79 is what the registry maintains -- and the PHASE of each item from its
80 latest queue operation in the log, because the projection models
81 pending and not-pending and cannot by itself tell "in progress" from
82 "completed" from "never queued".
83- ``kind`` other than ``"plan"`` is refused BY NAME: ``related_plan_id``
84 is plan-shaped, which the design note ยง5 names as the seam a second
85 kind will find.
86- Nothing here is edited in ``registry/domain.py``,
87 ``registry/backends/**`` or ``registry/cli/**``; this adapter is the
88 only registry-side file plan 258 adds.
89
90Dependencies
91------------
92- oc_diagnostics (structured debug logging via _dbg)
93- oc_orchestrator.work_queue (the contract; moved there by plan 271)
94- oc_chat.registry.api (the Registry facade)
95- oc_chat.registry.domain (the four typed queue operations)
96- oc_chat.registry.readers (the queue history reader)
97"""
98
99from __future__ import annotations
100
101import re
102from dataclasses import replace
103
104from oc_diagnostics import _dbg
105
106from oc_orchestrator.work_queue import (
107 KIND_PLAN,
108 OP_COMPLETE,
109 OP_DEQUEUE,
110 OP_ENQUEUE,
111 OP_MOVE,
112 BadPositionError,
113 DuplicateItemError,
114 QueueBackendError,
115 QueueOp,
116 QueueState,
117 UnknownItemError,
118 WorkItem,
119)
120from oc_chat.registry.api import Registry
121from oc_chat.registry.domain import (
122 QUEUE_DEQUEUE_REASONS,
123 queue_complete,
124 queue_dequeue,
125 queue_enqueue,
126 queue_move,
127)
128from oc_chat.registry.readers import queue_history
129
130_dbg("OC_CHAT.MAIN", "module", "queue_backend loaded", 4)
131
132
133#: The ``QUEUE_ENQUEUE_REASONS`` member an enqueue maps onto. The
134#: contract's enqueue carries no reason, and this is the member that
135#: means what it does.
136ENQUEUE_REASON = "initial enqueue"
137
138#: The ``QUEUE_DEQUEUE_REASONS`` member a dequeue maps onto when the
139#: contract's own reason is not already a member. The closed set has NO
140#: member meaning "dispatched to an instance"; of the three it has,
141#: this is the only one whose projection leaves the plan's status
142#: alone, which is what a dispatch needs -- ``transition_to_failed``
143#: and ``transition_to_integrating`` both rewrite it, and a dispatch is
144#: neither. That the set needs a member is reported, not invented.
145DEQUEUE_REASON_DEFAULT = "operator_dequeue"
146
147#: The registry operation types this adapter emits and folds, mapped to
148#: the contract's op names so a history read through either backend
149#: reads the same.
150_OP_BY_TYPE: dict[str, str] = {
151 "queue.enqueue": OP_ENQUEUE,
152 "queue.move": OP_MOVE,
153 "queue.dequeue": OP_DEQUEUE,
154 "queue.complete": OP_COMPLETE,
155}
156
157_COMMAND_PREFIX = "work_queue"
158_INSTANCE_RE = re.compile(r"instance=(?P<instance>\S+)")
159
160
161class LaneIsNotASlotError(QueueBackendError):
162 """A work item's lane is not the registered slot this adapter writes to."""
163
164
165class MissingRegistryArgumentError(QueueBackendError):
166 """An operation needs an argument only the registry backend demands."""
167
168
169class UnknownPlanRowError(QueueBackendError):
170 """The registry holds no ``plans`` row for this item."""
171
172
173class UnsupportedItemKindError(QueueBackendError):
174 """This adapter is plan-shaped, because ``related_plan_id`` is."""
175
176
178 """The contract's six operations over the registry's four."""
179
181 self,
182 registry: Registry,
183 *,
184 slot: str,
185 actor_slot: str,
186 actor_type: str = "human_operator",
187 ) -> None:
188 func = "RegistryQueueBackend.__init__"
189 self.registry = registry
190 self.slot = slot
191 self.actor_slot = actor_slot
192 self.actor_type = actor_type
193 _dbg("OC_CHAT.MAIN", func, f"slot={slot} actor={actor_slot}", 2)
194
195 # -- reads ------------------------------------------------------------
196
197 def status(self) -> QueueState:
198 """The three buckets: order from the projection, phase from the log."""
199 func = "RegistryQueueBackend.status"
200 phases = self._phases()
201 instances = self._instances(phases)
202 positions = self._pending_positions()
203
204 pending = [
205 WorkItem(
206 kind=KIND_PLAN, item_id=plan_id, instance=instances.get(plan_id)
207 )
208 for plan_id in positions
209 if phases.get(plan_id) in {OP_ENQUEUE, OP_MOVE}
210 ]
211 in_progress = [
212 WorkItem(kind=KIND_PLAN, item_id=plan_id, instance=instances.get(plan_id))
213 for plan_id, op_name in phases.items()
214 if op_name == OP_DEQUEUE
215 ]
216 completed = [
217 WorkItem(kind=KIND_PLAN, item_id=plan_id, instance=instances.get(plan_id))
218 for plan_id, op_name in phases.items()
219 if op_name == OP_COMPLETE
220 ]
221 _dbg(
222 "OC_CHAT.MAIN",
223 func,
224 f"{len(pending)}p/{len(in_progress)}i/{len(completed)}c",
225 3,
226 )
227 return QueueState(
228 pending=tuple(pending),
229 in_progress=tuple(in_progress),
230 completed=tuple(completed),
231 )
232
233 def history(self, item_id: str) -> tuple[QueueOp, ...]:
234 """Every queue operation the log holds for *item_id*, oldest first."""
235 events = [
236 event
237 for event in queue_history(self.registry, item_id)
238 if event.operation_type in _OP_BY_TYPE
239 ]
240 return tuple(
241 QueueOp(
242 seq=event.state_version,
243 at=event.ts,
244 op=_OP_BY_TYPE[event.operation_type],
245 item_id=item_id,
246 payload=dict(event.payload),
247 actor=event.actor_slot,
248 )
249 for event in events
250 )
251
252 # -- writes -----------------------------------------------------------
253
254 def enqueue(self, item: WorkItem, position: int | None = None) -> QueueState:
255 """Emit ``queue.enqueue`` for *item* at *position*, or at the end.
256
257 Raises:
258 UnsupportedItemKindError: VALIDATION_ERROR (L3) -- the kind
259 is not ``"plan"``.
260 LaneIsNotASlotError: VALIDATION_ERROR (L3) -- the lane is
261 not this adapter's registered slot.
262 UnknownPlanRowError: EXTERNAL_ERROR (L2) -- the registry
263 holds no ``plans`` row, so the projection would be
264 silently skipped.
265 DuplicateItemError / BadPositionError: as the contract says.
266 """
267 func = "RegistryQueueBackend.enqueue"
268 self._check_kind(item.kind)
269 self._check_lane(item.lane)
270 self._require_plan_row(item.item_id)
271 state = self.status()
272 held = _bucket_of(state, item.item_id)
273 if held is not None and held != "completed":
274 raise DuplicateItemError(
275 f"{item.item_id!r} is already {held} in this queue"
276 )
277 index = _checked_index(position, len(state.pending) + 1)
278 _dbg("OC_CHAT.MAIN", func, f"{item.item_id} -> position {index}", 2)
279 queue_enqueue(
280 self.registry,
281 plan_id=item.item_id,
282 slot=self.slot,
283 position=index,
284 reason=ENQUEUE_REASON,
285 actor_slot=self.actor_slot,
286 actor_type=self.actor_type,
287 command=self._command(OP_ENQUEUE, item.item_id, reason=ENQUEUE_REASON),
288 )
289 return self.status()
290
291 def move(self, item_id: str, position: int, *, reason: str) -> QueueState:
292 """Emit ``queue.move``; the registry's reason is free text, so verbatim."""
293 func = "RegistryQueueBackend.move"
294 state = self.status()
295 current = _pending_index(state, item_id)
296 index = _checked_index(position, len(state.pending))
297 _dbg("OC_CHAT.MAIN", func, f"{item_id}: {current} -> {index}", 2)
298 queue_move(
299 self.registry,
300 plan_id=item_id,
301 from_slot=self.slot,
302 to_slot=self.slot,
303 from_position=current,
304 to_position=index,
305 reason=reason,
306 actor_slot=self.actor_slot,
307 actor_type=self.actor_type,
308 command=self._command(OP_MOVE, item_id, reason=reason),
309 )
310 return self.status()
311
312 def dequeue(self, item_id: str, *, instance: str, reason: str) -> QueueState:
313 """Emit ``queue.dequeue``, mapping the reason onto the closed set.
314
315 The contract's reason and the instance both survive in
316 ``command``; see the module docstring for why neither has a
317 column of its own.
318 """
319 func = "RegistryQueueBackend.dequeue"
320 state = self.status()
321 position = _pending_index(state, item_id)
322 mapped = reason if reason in QUEUE_DEQUEUE_REASONS else DEQUEUE_REASON_DEFAULT
323 _dbg("OC_CHAT.MAIN", func, f"reason {reason!r} -> {mapped!r}", 3)
324 queue_dequeue(
325 self.registry,
326 plan_id=item_id,
327 slot=self.slot,
328 position=position,
329 reason=mapped,
330 actor_slot=self.actor_slot,
331 actor_type=self.actor_type,
332 command=self._command(
333 OP_DEQUEUE, item_id, reason=reason, instance=instance
334 ),
335 )
336 return self.status()
337
339 self,
340 item_id: str,
341 *,
342 reason: str,
343 round_no: int | None = None,
344 merge_commit_sha: str | None = None,
345 ) -> QueueState:
346 """Emit ``queue.complete``. Idempotent, like the file backend.
347
348 Raises:
349 MissingRegistryArgumentError: VALIDATION_ERROR (L3) --
350 ``queue_complete`` requires both ``round_no`` and
351 ``merge_commit_sha`` and this adapter will not invent
352 either.
353 UnknownItemError: VALIDATION_ERROR (L3) -- the queue holds
354 no such item.
355 """
356 func = "RegistryQueueBackend.complete"
357 state = self.status()
358 held = _bucket_of(state, item_id)
359 if held == "completed":
360 _dbg("OC_CHAT.MAIN", func, f"{item_id} already completed; no-op", 2)
361 return state
362 if held is None:
363 raise UnknownItemError(
364 f"{item_id!r} is not pending or in progress in this queue"
365 )
366 if round_no is None or merge_commit_sha is None:
368 "the registry backend's complete requires round_no and "
369 "merge_commit_sha; queue_complete demands both and they are not "
370 "packed into reason. The file backend accepts and ignores them."
371 )
372 queue_complete(
373 self.registry,
374 plan_id=item_id,
375 round_no=round_no,
376 merge_commit_sha=merge_commit_sha,
377 actor_slot=self.actor_slot,
378 actor_type=self.actor_type,
379 command=self._command(OP_COMPLETE, item_id, reason=reason),
380 )
381 return self.status()
382
383 # -- internals --------------------------------------------------------
384
385 def _check_kind(self, kind: str) -> None:
386 """Refuse a non-plan kind by name; ``related_plan_id`` is plan-shaped."""
387 if kind != KIND_PLAN:
389 f"the registry backend accepts kind {KIND_PLAN!r} only; got "
390 f"{kind!r}. The operation log's plan column is related_plan_id, "
391 "so a second kind needs a registry change, not an adapter one."
392 )
393
394 def _check_lane(self, lane: str | None) -> None:
395 """Refuse a lane that is not this adapter's registered slot."""
396 if lane is not None and lane != self.slot:
398 # OCT-LINT: disable=no_hardcoded_secrets reason="Operator-facing refusal prose naming the schema constraint; no credential content"
399 f"lane {lane!r} cannot be a registry slot: plans.assigned_slot "
400 "REFERENCES agents(slot) with foreign keys on, so a free-text "
401 "slot is refused with 'FOREIGN KEY constraint failed'. This "
402 f"adapter writes to the registered slot {self.slot!r}; pass that "
403 "lane or none."
404 )
405
406 def _require_plan_row(self, plan_id: str) -> None:
407 """Refuse an item the ``plans`` table does not hold.
408
409 Without this the operation would be written and the projection
410 silently skipped -- ``_project_queue_enqueue`` returns early on
411 a missing plan row -- leaving a log entry that changed nothing.
412 """
413 with self._connect() as conn:
414 row = conn.execute(
415 "SELECT 1 FROM plans WHERE plan_id = ?", (plan_id,)
416 ).fetchone()
417 if row is None:
419 f"the registry holds no plans row for {plan_id!r}; enqueue it "
420 "through plan.create first, or the projection is skipped in "
421 "silence."
422 )
423
424 def _command(self, op_name: str, item_id: str, **fields: object) -> str:
425 """The deterministic ``command`` text for one operation."""
426 parts = [f"{_COMMAND_PREFIX} {op_name}", f"item={item_id}"]
427 if "instance" in fields and fields["instance"]:
428 parts.append(f"instance={fields['instance']}")
429 if fields.get("reason"):
430 parts.append(f"reason={fields['reason']}")
431 return " ".join(parts)
432
433 def _connect(self) -> object:
434 """A read connection through the storage backend."""
435 connect = getattr(self.registry.backend, "connect", None)
436 if connect is None:
437 raise QueueBackendError(
438 "the registry backend does not expose read connections"
439 )
440 return connect()
441
442 def _queue_rows(self) -> list[tuple[str, str, str]]:
443 """``(plan_id, operation_type, command)`` for every queue op, in order."""
444 placeholders = ", ".join("?" for _ in _OP_BY_TYPE)
445 with self._connect() as conn:
446 rows = conn.execute(
447 # OCT-LINT: disable=no_hardcoded_secrets reason="Static SQL projection queries are not secrets"
448 "SELECT related_plan_id, operation_type, command FROM operation_log "
449 # OCT-LINT: disable=no_hardcoded_secrets reason="Static SQL projection queries are not secrets"
450 f"WHERE operation_type IN ({placeholders}) "
451 "AND related_plan_id IS NOT NULL ORDER BY state_version",
452 tuple(sorted(_OP_BY_TYPE)),
453 ).fetchall()
454 return [
455 (str(row["related_plan_id"]), str(row["operation_type"]), str(row["command"] or ""))
456 for row in rows
457 ]
458
459 def _phases(self) -> dict[str, str]:
460 """Each item's phase: the contract op name of its LATEST queue row."""
461 phases: dict[str, str] = {}
462 for plan_id, operation_type, _command in self._queue_rows():
463 phases[plan_id] = _OP_BY_TYPE[operation_type]
464 return phases
465
466 def _instances(self, phases: dict[str, str]) -> dict[str, str]:
467 """Each item's instance, read back out of its latest dequeue command."""
468 instances: dict[str, str] = {}
469 for plan_id, operation_type, command in self._queue_rows():
470 if operation_type != "queue.dequeue":
471 continue
472 match = _INSTANCE_RE.search(command)
473 if match:
474 instances[plan_id] = match.group("instance")
475 elif plan_id in instances:
476 del instances[plan_id]
477 # An item that came back into pending is no longer held by an
478 # instance, so a stale name must not survive a re-enqueue.
479 return {
480 plan_id: instance
481 for plan_id, instance in instances.items()
482 if phases.get(plan_id) in {OP_DEQUEUE, OP_COMPLETE}
483 }
484
485 def _pending_positions(self) -> list[str]:
486 """Plan ids with a queue position in this slot, in position order."""
487 with self._connect() as conn:
488 rows = conn.execute(
489 # OCT-LINT: disable=no_hardcoded_secrets reason="Static SQL projection query over the plans table; no credential content"
490 "SELECT plan_id FROM plans WHERE assigned_slot = ? "
491 "AND queue_position IS NOT NULL ORDER BY queue_position",
492 (self.slot,),
493 ).fetchall()
494 return [str(row["plan_id"]) for row in rows]
495
496
497def _bucket_of(state: QueueState, item_id: str) -> str | None:
498 """Which bucket holds *item_id*, in words, or ``None``."""
499 for label, attribute in (
500 ("pending", "pending"),
501 ("in progress", "in_progress"),
502 ("completed", "completed"),
503 ):
504 if any(item.item_id == item_id for item in getattr(state, attribute)):
505 return label
506 return None
507
508
509def _pending_index(state: QueueState, item_id: str) -> int:
510 """The 1-based position of *item_id* in pending, or a named refusal."""
511 for index, item in enumerate(state.pending, start=1):
512 if item.item_id == item_id:
513 return index
514 held = _bucket_of(state, item_id)
515 where = f"it is {held}" if held else "the queue does not hold it"
516 raise UnknownItemError(f"{item_id!r} is not pending: {where}")
517
518
519def _checked_index(position: int | None, upper: int) -> int:
520 """*position* as a 1-based index in ``1..upper``, or the end."""
521 if position is None:
522 return max(upper, 1)
523 if not 1 <= position <= max(upper, 1):
524 raise BadPositionError(f"position {position} is outside 1..{max(upper, 1)}")
525 return position
526
527
528__all__ = [
529 "DEQUEUE_REASON_DEFAULT",
530 "ENQUEUE_REASON",
531 "LaneIsNotASlotError",
532 "MissingRegistryArgumentError",
533 "RegistryQueueBackend",
534 "UnknownPlanRowError",
535 "UnsupportedItemKindError",
536]
str _command(self, str op_name, str item_id, **object fields)
QueueState move(self, str item_id, int position, *, str reason)
None __init__(self, Registry registry, *, str slot, str actor_slot, str actor_type="human_operator")
QueueState enqueue(self, WorkItem item, int|None position=None)
dict[str, str] _instances(self, dict[str, str] phases)
QueueState dequeue(self, str item_id, *, str instance, str reason)
tuple[QueueOp,...] history(self, str item_id)
QueueState complete(self, str item_id, *, str reason, int|None round_no=None, str|None merge_commit_sha=None)
int _checked_index(int|None position, int upper)
str|None _bucket_of(QueueState state, str item_id)
int _pending_index(QueueState state, str item_id)