7The registry backend: the queue's operations over the registry's own.
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
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.
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
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.
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.
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
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`.
68 L2 - lifecycle events (an operation emitted)
69 L3 - semantic details (a mapping, a refusal)
70 L4 - deep tracing (module load)
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
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.
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)
99from __future__
import annotations
102from dataclasses
import replace
104from oc_diagnostics
import _dbg
106from oc_orchestrator.work_queue
import (
122 QUEUE_DEQUEUE_REASONS,
130_dbg(
"OC_CHAT.MAIN",
"module",
"queue_backend loaded", 4)
136ENQUEUE_REASON =
"initial enqueue"
145DEQUEUE_REASON_DEFAULT =
"operator_dequeue"
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,
157_COMMAND_PREFIX =
"work_queue"
158_INSTANCE_RE = re.compile(
r"instance=(?P<instance>\S+)")
162 """A work item's lane is not the registered slot this adapter writes to."""
166 """An operation needs an argument only the registry backend demands."""
170 """The registry holds no ``plans`` row for this item."""
174 """This adapter is plan-shaped, because ``related_plan_id`` is."""
178 """The contract's six operations over the registry's four."""
186 actor_type: str =
"human_operator",
188 func =
"RegistryQueueBackend.__init__"
193 _dbg(
"OC_CHAT.MAIN", func, f
"slot={slot} actor={actor_slot}", 2)
198 """The three buckets: order from the projection, phase from the log."""
199 func =
"RegistryQueueBackend.status"
206 kind=KIND_PLAN, item_id=plan_id, instance=instances.get(plan_id)
208 for plan_id
in positions
209 if phases.get(plan_id)
in {OP_ENQUEUE, OP_MOVE}
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
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
224 f
"{len(pending)}p/{len(in_progress)}i/{len(completed)}c",
228 pending=tuple(pending),
229 in_progress=tuple(in_progress),
230 completed=tuple(completed),
233 def history(self, item_id: str) -> tuple[QueueOp, ...]:
234 """Every queue operation the log holds for *item_id*, oldest first."""
237 for event
in queue_history(self.
registry, item_id)
238 if event.operation_type
in _OP_BY_TYPE
242 seq=event.state_version,
244 op=_OP_BY_TYPE[event.operation_type],
246 payload=dict(event.payload),
247 actor=event.actor_slot,
254 def enqueue(self, item: WorkItem, position: int |
None =
None) -> QueueState:
255 """Emit ``queue.enqueue`` for *item* at *position*, or at the end.
258 UnsupportedItemKindError: VALIDATION_ERROR (L3) -- the kind
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
265 DuplicateItemError / BadPositionError: as the contract says.
267 func =
"RegistryQueueBackend.enqueue"
273 if held
is not None and held !=
"completed":
274 raise DuplicateItemError(
275 f
"{item.item_id!r} is already {held} in this queue"
278 _dbg(
"OC_CHAT.MAIN", func, f
"{item.item_id} -> position {index}", 2)
281 plan_id=item.item_id,
284 reason=ENQUEUE_REASON,
287 command=self.
_command(OP_ENQUEUE, item.item_id, reason=ENQUEUE_REASON),
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"
297 _dbg(
"OC_CHAT.MAIN", func, f
"{item_id}: {current} -> {index}", 2)
303 from_position=current,
308 command=self.
_command(OP_MOVE, item_id, reason=reason),
312 def dequeue(self, item_id: str, *, instance: str, reason: str) -> QueueState:
313 """Emit ``queue.dequeue``, mapping the reason onto the closed set.
315 The contract's reason and the instance both survive in
316 ``command``; see the module docstring for why neither has a
319 func =
"RegistryQueueBackend.dequeue"
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)
333 OP_DEQUEUE, item_id, reason=reason, instance=instance
343 round_no: int |
None =
None,
344 merge_commit_sha: str |
None =
None,
346 """Emit ``queue.complete``. Idempotent, like the file backend.
349 MissingRegistryArgumentError: VALIDATION_ERROR (L3) --
350 ``queue_complete`` requires both ``round_no`` and
351 ``merge_commit_sha`` and this adapter will not invent
353 UnknownItemError: VALIDATION_ERROR (L3) -- the queue holds
356 func =
"RegistryQueueBackend.complete"
359 if held ==
"completed":
360 _dbg(
"OC_CHAT.MAIN", func, f
"{item_id} already completed; no-op", 2)
363 raise UnknownItemError(
364 f
"{item_id!r} is not pending or in progress in this queue"
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."
376 merge_commit_sha=merge_commit_sha,
379 command=self.
_command(OP_COMPLETE, item_id, reason=reason),
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."
395 """Refuse a lane that is not this adapter's registered slot."""
396 if lane
is not None and lane != self.
slot:
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 "
407 """Refuse an item the ``plans`` table does not hold.
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.
415 "SELECT 1 FROM plans WHERE plan_id = ?", (plan_id,)
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 "
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)
434 """A read connection through the storage backend."""
435 connect = getattr(self.
registry.backend,
"connect",
None)
437 raise QueueBackendError(
438 "the registry backend does not expose read connections"
443 """``(plan_id, operation_type, command)`` for every queue op, in order."""
444 placeholders =
", ".join(
"?" for _
in _OP_BY_TYPE)
448 "SELECT related_plan_id, operation_type, command FROM operation_log "
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)),
455 (str(row[
"related_plan_id"]), str(row[
"operation_type"]), str(row[
"command"]
or ""))
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]
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":
472 match = _INSTANCE_RE.search(command)
474 instances[plan_id] = match.group(
"instance")
475 elif plan_id
in instances:
476 del instances[plan_id]
481 for plan_id, instance
in instances.items()
482 if phases.get(plan_id)
in {OP_DEQUEUE, OP_COMPLETE}
486 """Plan ids with a queue position in this slot, in position order."""
490 "SELECT plan_id FROM plans WHERE assigned_slot = ? "
491 "AND queue_position IS NOT NULL ORDER BY queue_position",
494 return [str(row[
"plan_id"])
for row
in rows]
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"),
504 if any(item.item_id == item_id
for item
in getattr(state, attribute)):
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:
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}")
520 """*position* as a 1-based index in ``1..upper``, or the end."""
523 if not 1 <= position <= max(upper, 1):
524 raise BadPositionError(f
"position {position} is outside 1..{max(upper, 1)}")
529 "DEQUEUE_REASON_DEFAULT",
531 "LaneIsNotASlotError",
532 "MissingRegistryArgumentError",
533 "RegistryQueueBackend",
534 "UnknownPlanRowError",
535 "UnsupportedItemKindError",
None _check_lane(self, str|None lane)
None _require_plan_row(self, str plan_id)
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] _phases(self)
dict[str, str] _instances(self, dict[str, str] phases)
list[tuple[str, str, str]] _queue_rows(self)
QueueState dequeue(self, str item_id, *, str instance, str reason)
None _check_kind(self, str kind)
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)
list[str] _pending_positions(self)
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)