oc_orchestrator
Loading...
Searching...
No Matches
work_queue_file.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/work_queue_file.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_orchestrator/tests/test_258_work_queue_file.py"
6"""
7The file backend: an append-only operation log plus a Markdown projection.
8
9Purpose
10-------
11Plan 258 P2, from the factory-decomposition design note §5 and §7. This
12is the DEFAULT backend and the one the package's own tests run against,
13which is the condition that stops it rotting into dead code kept warm
14for strangers. It is two files under one injected directory:
15
16``work_queue.ops.jsonl``
17 The append-only log of typed operations, one JSON object per line
18 in the :class:`~oc_orchestrator.work_queue.QueueOp` shape. Opened for
19 APPEND only, flushed and fsynced per write, never rewritten. This
20 file is the truth.
21
22``work_queue.md``
23 The projection, rendered by folding the log. A view a human may
24 also edit; :func:`FileQueueBackend.reconcile` compares the fold
25 with the parsed view and, with ``apply``, records the hand edit as
26 operations so the log stays complete.
27
28The projection's grammar is lifted from the queue plan 223 retired at
29commit ``9ac4b6a1``, with the letters removed: a first-line marker,
30three tracked H2 sections, ``- <item_id>`` bullets with an optional
31bracketed annotation, and every line outside a tracked bullet list
32preserved verbatim on round trip. :func:`_windows_safe_replace` is
33lifted VERBATIM from ``e1b5c716:oc_chat/oc_chat/core/agent_queue.py``
34because no dispatch-layer library module carries a temp-then-replace
35that recovers from a read-only target: ``scripts/plan_manage.py``'s
36``_atomic_write`` is script-private and has no recovery, and
37``core/provider_secrets.py`` is security code that is not imported for
38a queue.
39
40Responsibilities
41----------------
42- :class:`QueuePaths` -- the injected filesystem inputs, in the
43 ``PlanPaths`` shape.
44- :func:`read_ops` / :func:`fold` -- the log, and the state it folds to.
45- :func:`render_projection` / :func:`parse_projection` -- the view.
46- :class:`FileQueueBackend` -- the six contract operations, plus
47 :meth:`~FileQueueBackend.write_projection` and
48 :meth:`~FileQueueBackend.reconcile`.
49- :class:`QueueDrift` / :class:`CorruptLogError` -- what reconcile
50 reports, and what a bad log line raises.
51
52Diagnostics
53-----------
54Domain: OC_CHAT.MAIN
55Levels:
56 L2 - lifecycle events (a write, a fold, a reconcile)
57 L3 - semantic details (a refusal, a drift class)
58 L4 - deep tracing (module load, per-line parse)
59
60Contracts
61---------
62- NO LOCKING. Two writers can lose an update: each reads the log,
63 decides, and appends, so a concurrent pair can both append an op that
64 was computed against the same prefix. That is accepted while the
65 operator session is the sole writer (design note §5, and the plan-255
66 sidecar carries the same statement). A lock file or a daemon is
67 forbidden by operator ruling, not merely out of scope.
68- The log is APPEND-ONLY. No code path in this module opens the ops
69 file for writing, truncation or replacement. The projection is the
70 only file that is rewritten.
71- A corrupt log line is :class:`CorruptLogError` naming the file and
72 the 1-BASED line number. The backend never silently skips a line.
73- A re-render of unchanged state writes NOTHING:
74 :meth:`FileQueueBackend.write_projection` returns ``False`` and the
75 file's bytes and mtime are untouched.
76- ``note`` lives in the log, not in the projection: the annotation
77 grammar is ``key=value`` pairs separated by commas, and free prose
78 would not survive it. ``lane`` and ``instance`` are annotated.
79- Nothing here reads the process working directory. :class:`QueuePaths`
80 defaults to the relative literal that is today's location, exactly as
81 :class:`~oc_orchestrator.plan_inventory.PlanPaths` does, and the caller
82 resolves it.
83
84Dependencies
85------------
86- oc_diagnostics (structured debug logging via _dbg)
87- oc_orchestrator.work_queue (the contract)
88"""
89
90from __future__ import annotations
91
92import json
93import os
94import re
95import stat
96import tempfile
97import time
98from dataclasses import dataclass, field, replace
99from datetime import datetime
100from pathlib import Path
101from typing import Any
102
103from oc_diagnostics import _dbg
104
105from oc_orchestrator.work_queue import (
106 KIND_PLAN,
107 OP_COMPLETE,
108 OP_DEQUEUE,
109 OP_ENQUEUE,
110 OP_MOVE,
111 OP_NAMES,
112 BadPositionError,
113 DuplicateItemError,
114 QueueBackendError,
115 QueueOp,
116 QueueState,
117 UnknownItemError,
118 WorkItem,
119)
120
121_dbg("OC_CHAT.MAIN", "module", "work_queue_file loaded", 4)
122
123
124# ── Constants ────────────────────────────────────────────────────────────
125
126
127#: Where the two files live, relative to the caller's root. Today's
128#: value; injected through :class:`QueuePaths`, never discovered. It is
129#: the monorepo's layout and a DEFAULT, not a requirement: a consumer
130#: whose queue lives elsewhere passes its own ``QueuePaths(queue_dir=...)``
131#: and this literal is never read.
132DEFAULT_QUEUE_DIR = Path(".option_c/orchestration")
133
134#: The append-only typed operation log.
135OPS_FILENAME = "work_queue.ops.jsonl"
136
137#: The Markdown projection folded out of the log.
138PROJECTION_FILENAME = "work_queue.md"
139
140#: The first-line marker the writer emits and the parser tolerates.
141MARKER = "<!-- work-queue: v1 -->"
142
143#: The three tracked H2 sections, in render order, paired with the
144#: :class:`~oc_orchestrator.work_queue.QueueState` field each one holds.
145SECTIONS: tuple[tuple[str, str], ...] = (
146 ("Pending", "pending"),
147 ("In progress", "in_progress"),
148 ("Completed", "completed"),
149)
150
151#: Rendered in a tracked section that holds nothing. Lifted from the
152#: retired renderer so an operator reading the file sees what they saw.
153EMPTY_MARK = "_(none)_"
154
155# OCT-LINT: disable=no_hardcoded_secrets reason="Markdown grammar: the tracked-H2 header pattern for the projection parser; no credential content"
156_SECTION_RE = re.compile(r"^##\s+(?P<name>Pending|In progress|Completed)\s*$", re.I)
157_BULLET_RE = re.compile(
158 # OCT-LINT: disable=no_hardcoded_secrets reason="Markdown grammar: the work-item bullet pattern (id plus optional [k=v] annotation); no credential content"
159 r"^-\s+(?P<item_id>[A-Za-z0-9][A-Za-z0-9_\-./]*)"
160 r"(?:\s+\‍[(?P<annotations>[^\‍]]*)\‍])?\s*$"
161)
162
163_ACTOR_RECONCILE = "reconcile"
164
165
167 """A line in the operation log does not parse; the message names it."""
168
169
170# ── Paths ────────────────────────────────────────────────────────────────
171
172
173@dataclass(frozen=True)
175 """All caller-controlled filesystem inputs to one queue.
176
177 The :class:`~oc_orchestrator.plan_inventory.PlanPaths` shape: a frozen
178 dataclass whose defaults are today's values, constructed at the
179 boundary and passed whole. ``queue_dir`` is late-bound through a
180 default factory so a caller that monkeypatches
181 :data:`DEFAULT_QUEUE_DIR` is still honoured.
182 """
183
184 queue_dir: Path = field(default_factory=lambda: DEFAULT_QUEUE_DIR)
185 #: Overrides the derived ``<queue_dir>/work_queue.ops.jsonl``.
186 ops_path_override: Path | None = None
187 #: Overrides the derived ``<queue_dir>/work_queue.md``.
188 projection_path_override: Path | None = None
189
190 @property
191 def ops_path(self) -> Path:
192 """The append-only log: the override, else derived."""
193 if self.ops_path_override is not None:
194 return self.ops_path_override
195 return self.queue_dir / OPS_FILENAME
196
197 @property
198 def projection_path(self) -> Path:
199 """The Markdown view: the override, else derived."""
200 if self.projection_path_override is not None:
201 return self.projection_path_override
202 return self.queue_dir / PROJECTION_FILENAME
203
204
205# ── The log ──────────────────────────────────────────────────────────────
206
207
208def _now_iso() -> str:
209 """A local-with-offset ISO 8601 timestamp, seconds resolution.
210
211 The project-wide convention, matching
212 ``core/plan_inventory.py::_now_iso``.
213 """
214 return datetime.now().astimezone().replace(microsecond=0).isoformat()
215
216
217def read_ops(ops_path: Path) -> tuple[QueueOp, ...]:
218 """Read every operation in *ops_path*, oldest first.
219
220 A missing file is an empty log, never a ``FileNotFoundError`` --
221 the same tolerance the retired ``load_queue`` had.
222
223 Raises:
224 CorruptLogError: SYSTEM_ERROR (L1 for the caller) -- a
225 non-blank line is not a JSON object with the operation
226 fields, or names an operation that is not in
227 :data:`~oc_orchestrator.work_queue.OP_NAMES`. The message
228 names the file and the 1-based line number; the reader
229 never silently skips it.
230 """
231 func = "read_ops"
232 path = Path(ops_path)
233 try:
234 text = path.read_text(encoding="utf-8")
235 except FileNotFoundError:
236 _dbg("OC_CHAT.MAIN", func, f"{path}: absent; empty log", 2)
237 return ()
238 ops: list[QueueOp] = []
239 for number, line in enumerate(text.splitlines(), start=1):
240 if not line.strip():
241 continue
242 ops.append(_decode_op(line, path, number))
243 _dbg("OC_CHAT.MAIN", func, f"{path}: {len(ops)} operation(s)", 2)
244 return tuple(ops)
245
246
247def _decode_op(line: str, path: Path, number: int) -> QueueOp:
248 """One log line to a :class:`QueueOp`, or a refusal that names it."""
249 func = "_decode_op"
250 try:
251 raw = json.loads(line)
252 except json.JSONDecodeError as exc:
253 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: line {number}: {exc}", 1, override=True)
254 raise CorruptLogError(
255 f"{path.name}: line {number} is not JSON: {exc}"
256 ) from exc
257 if not isinstance(raw, dict):
258 raise CorruptLogError(
259 f"{path.name}: line {number} is not a JSON object: {type(raw).__name__}"
260 )
261 missing = {"seq", "at", "op", "item_id"} - set(raw)
262 if missing:
263 raise CorruptLogError(
264 f"{path.name}: line {number} is missing {', '.join(sorted(missing))}"
265 )
266 op_name = str(raw["op"])
267 if op_name not in OP_NAMES:
268 raise CorruptLogError(
269 f"{path.name}: line {number} names an unknown operation {op_name!r}; "
270 f"expected one of {', '.join(OP_NAMES)}"
271 )
272 payload = raw.get("payload") or {}
273 if not isinstance(payload, dict):
274 raise CorruptLogError(
275 f"{path.name}: line {number} has a non-object payload"
276 )
277 return QueueOp(
278 seq=int(raw["seq"]),
279 at=str(raw["at"]),
280 op=op_name,
281 item_id=str(raw["item_id"]),
282 payload=payload,
283 actor=str(raw.get("actor", "")),
284 )
285
286
287def _encode_op(op: QueueOp) -> str:
288 """One :class:`QueueOp` to its log line, without the newline."""
289 return json.dumps(
290 {
291 "seq": op.seq,
292 "at": op.at,
293 "op": op.op,
294 "item_id": op.item_id,
295 "payload": dict(op.payload),
296 "actor": op.actor,
297 },
298 ensure_ascii=False,
299 sort_keys=True,
300 )
301
302
303def append_op(ops_path: Path, op: QueueOp) -> None:
304 """Append *op* to the log, flushed and fsynced.
305
306 The ONLY write path for the log, and it opens the file in append
307 mode. Nothing in this module truncates or replaces it.
308 """
309 func = "append_op"
310 path = Path(ops_path)
311 path.parent.mkdir(parents=True, exist_ok=True)
312 with path.open("a", encoding="utf-8", newline="\n") as handle:
313 handle.write(_encode_op(op) + "\n")
314 handle.flush()
315 os.fsync(handle.fileno())
316 _dbg("OC_CHAT.MAIN", func, f"{path.name} += {op.op} {op.item_id} (seq {op.seq})", 2)
317
318
319def _item_from_payload(item_id: str, payload: Any) -> WorkItem:
320 """Rebuild the work item an ``enqueue`` payload carried."""
321 return WorkItem(
322 kind=str(payload.get("kind", KIND_PLAN)),
323 item_id=item_id,
324 lane=_optional_str(payload.get("lane")),
325 instance=_optional_str(payload.get("instance")),
326 note=_optional_str(payload.get("note")),
327 )
328
329
330def _optional_str(value: object) -> str | None:
331 return None if value is None else str(value)
332
333
334def fold(ops: tuple[QueueOp, ...]) -> QueueState:
335 """Replay *ops* into the three buckets. The fold IS the state.
336
337 Raises:
338 CorruptLogError: SYSTEM_ERROR -- an operation refers to an item
339 the earlier operations never put anywhere. A log that
340 cannot be folded is corrupt, and saying so beats guessing.
341 """
342 func = "fold"
343 pending: list[WorkItem] = []
344 in_progress: list[WorkItem] = []
345 completed: list[WorkItem] = []
346
347 def _take(bucket: list[WorkItem], item_id: str) -> WorkItem | None:
348 for index, item in enumerate(bucket):
349 if item.item_id == item_id:
350 return bucket.pop(index)
351 return None
352
353 for op in ops:
354 if op.op == OP_ENQUEUE:
355 item = _item_from_payload(op.item_id, op.payload)
356 # A re-enqueue takes the item back out of completed: it is
357 # being worked again, which is the motion the registry's own
358 # vocabulary calls promoted_from_partial and whose projection
359 # puts a completed plan back into pending.
360 _take(completed, op.item_id)
361 position = op.payload.get("position")
362 index = len(pending) if position is None else int(position) - 1
363 pending.insert(index, item)
364 elif op.op == OP_MOVE:
365 item = _take(pending, op.item_id)
366 if item is None:
367 raise CorruptLogError(
368 f"operation {op.seq} moves {op.item_id!r}, which is not pending"
369 )
370 pending.insert(int(op.payload["position"]) - 1, item)
371 elif op.op == OP_DEQUEUE:
372 item = _take(pending, op.item_id)
373 if item is None:
374 raise CorruptLogError(
375 f"operation {op.seq} dequeues {op.item_id!r}, which is not pending"
376 )
377 in_progress.append(
378 replace(item, instance=_optional_str(op.payload.get("instance")))
379 )
380 elif op.op == OP_COMPLETE:
381 item = _take(in_progress, op.item_id) or _take(pending, op.item_id)
382 if item is None:
383 raise CorruptLogError(
384 f"operation {op.seq} completes {op.item_id!r}, which the queue "
385 "does not hold"
386 )
387 completed.append(item)
388
389 _dbg(
390 "OC_CHAT.MAIN",
391 func,
392 f"{len(ops)} op(s) -> {len(pending)}p/{len(in_progress)}i/{len(completed)}c",
393 3,
394 )
395 return QueueState(
396 pending=tuple(pending),
397 in_progress=tuple(in_progress),
398 completed=tuple(completed),
399 )
400
401
402# ── The projection ───────────────────────────────────────────────────────
403
404
405def _annotation(item: WorkItem) -> str:
406 """The bracketed ``[k=v, k=v]`` suffix for *item*, or ``""``."""
407 parts = [
408 f"{key}={value}"
409 for key, value in (("lane", item.lane), ("instance", item.instance))
410 if value
411 ]
412 return f" [{', '.join(parts)}]" if parts else ""
413
414
415def _bullets(items: tuple[WorkItem, ...]) -> list[str]:
416 if not items:
417 return [EMPTY_MARK]
418 return [f"- {item.item_id}{_annotation(item)}" for item in items]
419
420
421def _canonical_text(state: QueueState) -> str:
422 """A fresh projection, written when there is no file to splice into."""
423 lines: list[str] = [
424 MARKER,
425 "",
426 "# Work queue",
427 "",
428 (
429 "The view. The truth is the append-only operation log beside this "
430 f"file, `{OPS_FILENAME}`. Edit this file by hand if you like: "
431 "`queue_manage reconcile` reports the difference and "
432 "`reconcile --apply` records it as operations. Every line outside "
433 "the three tracked bullet lists is preserved verbatim."
434 ),
435 "",
436 ]
437 for title, attribute in SECTIONS:
438 lines.append(f"## {title}")
439 lines.append("")
440 lines.extend(_bullets(getattr(state, attribute)))
441 lines.append("")
442 return "\n".join(lines).rstrip() + "\n"
443
444
445def _splice_into_existing(text: str, state: QueueState) -> str:
446 """Replace every tracked bullet list inside *text* with fresh content.
447
448 Lifted in shape from the retired ``agent_queue._splice_into_existing``
449 with the letters removed. Lines outside any tracked section --
450 preamble, freeform paragraphs, unrelated H2s -- are preserved
451 verbatim, and the marker is inserted at the top when absent.
452 """
453 func = "_splice_into_existing"
454 out_lines: list[str] = []
455 skip_existing = False
456
457 for line in text.splitlines():
458 match = _SECTION_RE.match(line)
459 if match:
460 attribute = _attribute_for(match.group("name"))
461 out_lines.append(line)
462 out_lines.append("")
463 out_lines.extend(_bullets(getattr(state, attribute)))
464 out_lines.append("")
465 skip_existing = True
466 continue
467 if skip_existing:
468 # Inside a tracked section: drop the OLD bullets until any
469 # header-shaped line ends the tracked region, then fall
470 # through so that terminating header is emitted.
471 if line.startswith("## ") or line.startswith("# "):
472 skip_existing = False
473 else:
474 continue
475 out_lines.append(line)
476
477 if MARKER not in "\n".join(out_lines):
478 out_lines.insert(0, "")
479 out_lines.insert(0, MARKER)
480 _dbg("OC_CHAT.MAIN", func, "marker injected on save", 3)
481
482 rendered = "\n".join(out_lines).rstrip()
483 tail: list[str] = []
484 for title, attribute in SECTIONS:
485 if not re.search(rf"^##\s+{re.escape(title)}\s*$", rendered, re.I | re.M):
486 tail.append(f"## {title}")
487 tail.append("")
488 tail.extend(_bullets(getattr(state, attribute)))
489 tail.append("")
490 if tail:
491 rendered = rendered + "\n\n" + "\n".join(tail).rstrip()
492 _dbg("OC_CHAT.MAIN", func, f"appended {len(tail)} line(s) for missing sections", 3)
493 return rendered.rstrip() + "\n"
494
495
496def _attribute_for(title: str) -> str:
497 """The :class:`QueueState` field a tracked H2 title holds."""
498 lowered = title.strip().lower()
499 for section_title, attribute in SECTIONS:
500 if section_title.lower() == lowered:
501 return attribute
502 raise CorruptLogError(f"unknown tracked section {title!r}")
503
504
505def render_projection(state: QueueState, existing: str | None) -> str:
506 """The projection text for *state*, spliced into *existing* when given."""
507 if existing is None:
508 return _canonical_text(state)
509 return _splice_into_existing(existing, state)
510
511
512def parse_projection(text: str) -> QueueState:
513 """Parse a projection back into a state.
514
515 Regex-anchored on the three tracked H2 sections, exactly as the
516 retired parser was anchored on its eight. A bullet under any other
517 heading is not a work item and is ignored.
518 """
519 func = "parse_projection"
520 buckets: dict[str, list[WorkItem]] = {
521 attribute: [] for _title, attribute in SECTIONS
522 }
523 current: str | None = None
524 for line in text.splitlines():
525 match = _SECTION_RE.match(line)
526 if match:
527 current = _attribute_for(match.group("name"))
528 continue
529 if line.startswith("## ") or line.startswith("# "):
530 current = None
531 continue
532 bullet = _BULLET_RE.match(line)
533 if bullet is None or current is None:
534 continue
535 buckets[current].append(
536 _item_from_annotations(bullet.group("item_id"), bullet.group("annotations"))
537 )
538 _dbg(
539 "OC_CHAT.MAIN",
540 func,
541 ", ".join(f"{name}: {len(items)}" for name, items in buckets.items()),
542 4,
543 )
544 return QueueState(
545 pending=tuple(buckets["pending"]),
546 in_progress=tuple(buckets["in_progress"]),
547 completed=tuple(buckets["completed"]),
548 )
549
550
551def _item_from_annotations(item_id: str, annotations: str | None) -> WorkItem:
552 """A work item from a bullet's id and its ``[k=v, k=v]`` suffix."""
553 values: dict[str, str] = {}
554 for part in (annotations or "").split(","):
555 key, separator, value = part.partition("=")
556 if separator:
557 values[key.strip()] = value.strip()
558 return WorkItem(
559 kind=KIND_PLAN,
560 item_id=item_id,
561 lane=values.get("lane") or None,
562 instance=values.get("instance") or None,
563 )
564
565
566def _windows_safe_replace(src: Path, dst: Path) -> None:
567 """Atomically replace *dst* with *src*; tolerate Windows handle/read-only quirks.
568
569 Windows `os.replace` fails with `PermissionError` when the target is
570 read-only or briefly held by another reader (antivirus, indexer,
571 pytest cleanup). Clear the read-only attribute and retry up to twice
572 with short backoff. Atomicity is preserved on every attempt.
573 """
574 # LIFTED VERBATIM from e1b5c716:oc_chat/oc_chat/core/agent_queue.py
575 # (plan 258 P2). No dispatch-layer library module carries a
576 # temp-then-replace with read-only recovery; plan_manage's
577 # _atomic_write is script-private and has none.
578 for attempt in range(3):
579 try:
580 src.replace(dst)
581 return
582 except PermissionError:
583 if dst.exists():
584 try:
585 dst.chmod(stat.S_IWRITE | stat.S_IREAD)
586 except OSError:
587 pass
588 if attempt == 2:
589 raise
590 time.sleep(0.05 * (attempt + 1))
591
592
593# ── Drift ────────────────────────────────────────────────────────────────
594
595
596@dataclass(frozen=True)
598 """One difference between the fold of the log and the parsed view."""
599
600 #: ``added`` / ``advanced`` / ``moved`` / ``removed``.
601 kind: str
602 item_id: str
603 #: What ``--apply`` would record, in words.
604 detail: str
605 #: ``False`` for ``removed``: the contract has no remove operation,
606 #: so a hand deletion is drift the log cannot absorb.
607 fixable: bool = True
608
609
610# ── The backend ──────────────────────────────────────────────────────────
611
612
614 """The default backend: an append-only log and a Markdown view."""
615
616 def __init__(self, paths: QueuePaths | None = None, *, actor: str = "operator") -> None:
617 func = "FileQueueBackend.__init__"
618 self.paths = paths if paths is not None else QueuePaths()
619 self.actor = actor
620 _dbg("OC_CHAT.MAIN", func, f"over {self.paths.queue_dir}", 2)
621
622 # -- reads ------------------------------------------------------------
623
624 def status(self) -> QueueState:
625 """The three buckets, folded out of the log."""
626 return fold(read_ops(self.paths.ops_path))
627
628 def history(self, item_id: str) -> tuple[QueueOp, ...]:
629 """Every recorded operation about *item_id*, oldest first."""
630 return tuple(
631 op for op in read_ops(self.paths.ops_path) if op.item_id == item_id
632 )
633
634 # -- writes -----------------------------------------------------------
635
636 def enqueue(self, item: WorkItem, position: int | None = None) -> QueueState:
637 """Put *item* into pending at *position*, or at the end.
638
639 A COMPLETED item may be enqueued again: the item comes back out
640 of completed and the log shows both runs. That is the motion
641 the registry's own enqueue vocabulary calls
642 ``promoted_from_partial`` / ``restored_from_aborted_round``, and
643 whose projection puts a completed plan back into ``pending``.
644 The retired ``add_task`` refused it because a slot's completed
645 LIST was that model's only record; here the log is the record,
646 so nothing is lost by allowing it.
647
648 Raises:
649 DuplicateItemError: VALIDATION_ERROR (L3) -- the item is
650 already pending or already in progress.
651 BadPositionError: VALIDATION_ERROR (L3) -- *position* is
652 not a 1-based index into the pending list.
653 """
654 func = "FileQueueBackend.enqueue"
655 state = self.status()
656 held = self._bucket_of(state, item.item_id)
657 if held is not None and held != "completed":
658 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: duplicate {item.item_id}", 3)
659 raise DuplicateItemError(
660 f"{item.item_id!r} is already {held} in this queue"
661 )
662 index = self._checked_index(position, len(state.pending) + 1)
663 payload: dict[str, object] = {
664 "kind": item.kind,
665 "lane": item.lane,
666 "instance": item.instance,
667 "note": item.note,
668 "position": index,
669 }
670 return self._record(OP_ENQUEUE, item.item_id, payload)
671
672 def move(self, item_id: str, position: int, *, reason: str) -> QueueState:
673 """Move a pending item to *position*, for a stated *reason*."""
674 func = "FileQueueBackend.move"
675 state = self.status()
676 current = self._pending_index(state, item_id)
677 index = self._checked_index(position, len(state.pending))
678 _dbg("OC_CHAT.MAIN", func, f"{item_id}: {current} -> {index}", 3)
679 return self._record(
680 OP_MOVE,
681 item_id,
682 {"position": index, "from_position": current, "reason": reason},
683 )
684
685 def dequeue(self, item_id: str, *, instance: str, reason: str) -> QueueState:
686 """Take an item out of pending into progress, naming *instance*."""
687 state = self.status()
688 self._pending_index(state, item_id)
689 return self._record(
690 OP_DEQUEUE, item_id, {"instance": instance, "reason": reason}
691 )
692
694 self,
695 item_id: str,
696 *,
697 reason: str,
698 round_no: int | None = None,
699 merge_commit_sha: str | None = None,
700 ) -> QueueState:
701 """Finish an item, for a stated *reason*.
702
703 Idempotent: completing an already-completed item records
704 nothing and returns the state unchanged, which is what the
705 retired ``complete_task`` did when it refused to append a plan
706 to a completed list twice.
707
708 ``round_no`` and ``merge_commit_sha`` are the REGISTRY backend's
709 demands (``queue_complete`` requires both). They are accepted
710 here so one call site drives both backends; this backend
711 records them in the op payload, because the log is the truth,
712 and derives no state from them.
713
714 Raises:
715 UnknownItemError: VALIDATION_ERROR (L3) -- the queue holds
716 no such item.
717 """
718 func = "FileQueueBackend.complete"
719 state = self.status()
720 held = self._bucket_of(state, item_id)
721 if held == "completed":
722 _dbg("OC_CHAT.MAIN", func, f"{item_id} already completed; no-op", 2)
723 return state
724 if held is None:
725 raise UnknownItemError(
726 f"{item_id!r} is not pending or in progress in this queue"
727 )
728 payload: dict[str, object] = {"reason": reason}
729 if round_no is not None:
730 payload["round_no"] = round_no
731 if merge_commit_sha is not None:
732 payload["merge_commit_sha"] = merge_commit_sha
733 return self._record(OP_COMPLETE, item_id, payload)
734
735 # -- the projection ---------------------------------------------------
736
737 def write_projection(self) -> bool:
738 """Render the fold into the projection. ``True`` when it wrote.
739
740 A re-render of unchanged state writes NOTHING: no temp file, no
741 replace, so the bytes and the mtime both stand still.
742 """
743 func = "FileQueueBackend.write_projection"
744 path = self.paths.projection_path
745 try:
746 existing: str | None = path.read_text(encoding="utf-8")
747 except FileNotFoundError:
748 existing = None
749 text = render_projection(self.status(), existing)
750 if existing is not None and text == existing:
751 _dbg("OC_CHAT.MAIN", func, f"{path.name} unchanged; wrote nothing", 2)
752 return False
753
754 path.parent.mkdir(parents=True, exist_ok=True)
755 with tempfile.NamedTemporaryFile(
756 mode="w",
757 encoding="utf-8",
758 newline="\n",
759 dir=path.parent,
760 prefix=".work_queue_",
761 suffix=".tmp",
762 delete=False,
763 ) as tmp:
764 tmp.write(text)
765 tmp_path = Path(tmp.name)
766 _windows_safe_replace(tmp_path, path)
767 _dbg("OC_CHAT.MAIN", func, f"wrote {path} ({len(text)} bytes)", 2)
768 return True
769
770 def reconcile(self, *, apply: bool = False) -> tuple[QueueDrift, ...]:
771 """Compare the fold with the parsed projection; report the difference.
772
773 Dry-run by default: it writes nothing at all, neither file. With
774 *apply* it records each fixable difference as an operation with
775 ``actor="reconcile"`` -- so a hand edit ends up in the log like
776 every other change -- and then re-renders the projection from
777 the new fold.
778
779 A hand DELETION is reported and never applied: the contract has
780 no remove operation, and inventing one to absorb a deletion
781 would make the log lossy in exactly the way it exists to avoid.
782 ``--apply`` still re-renders afterwards, so the view comes back
783 into agreement with the truth and the operator has been told
784 which edit did not survive.
785
786 Returns what the DRY RUN would have reported, whether or not
787 *apply* was set, so a caller can print one list either way.
788 """
789 func = "FileQueueBackend.reconcile"
790 try:
791 text = self.paths.projection_path.read_text(encoding="utf-8")
792 except FileNotFoundError:
793 _dbg("OC_CHAT.MAIN", func, "no projection; nothing to compare", 2)
794 return ()
795 parsed = parse_projection(text)
796 drifts = self._drift(self.status(), parsed)
797 _dbg("OC_CHAT.MAIN", func, f"{len(drifts)} drift(s); apply={apply}", 2)
798 if not apply or not drifts:
799 return drifts
800
801 # One fixable drift per pass, because applying one changes the
802 # positions the next one is measured against. The bound is the
803 # number of differences there were to begin with.
804 for _pass in range(len(drifts) + 1):
805 remaining = [d for d in self._drift(self.status(), parsed) if d.fixable]
806 if not remaining:
807 break
808 self._apply_drift(remaining[0], parsed)
809 self.write_projection()
810 return drifts
811
812 # -- internals --------------------------------------------------------
813
815 self, op_name: str, item_id: str, payload: dict[str, object]
816 ) -> QueueState:
817 """Append one operation, re-render the view, return the new state."""
818 ops = read_ops(self.paths.ops_path)
819 append_op(
820 self.paths.ops_path,
821 QueueOp(
822 seq=len(ops) + 1,
823 at=_now_iso(),
824 op=op_name,
825 item_id=item_id,
826 payload=payload,
827 actor=self.actor,
828 ),
829 )
830 self.write_projection()
831 return self.status()
832
833 @staticmethod
834 def _bucket_of(state: QueueState, item_id: str) -> str | None:
835 """Which bucket holds *item_id*, in words, or ``None``."""
836 for label, attribute in (
837 ("pending", "pending"),
838 ("in progress", "in_progress"),
839 ("completed", "completed"),
840 ):
841 if any(item.item_id == item_id for item in getattr(state, attribute)):
842 return label
843 return None
844
845 def _pending_index(self, state: QueueState, item_id: str) -> int:
846 """The 1-based position of *item_id* in pending, or a refusal."""
847 for index, item in enumerate(state.pending, start=1):
848 if item.item_id == item_id:
849 return index
850 held = self._bucket_of(state, item_id)
851 where = f"it is {held}" if held else "the queue does not hold it"
852 raise UnknownItemError(f"{item_id!r} is not pending: {where}")
853
854 @staticmethod
855 def _checked_index(position: int | None, upper: int) -> int:
856 """*position* as a 1-based index in ``1..upper``, or the end."""
857 if position is None:
858 return max(upper, 1)
859 if not 1 <= position <= max(upper, 1):
860 raise BadPositionError(
861 f"position {position} is outside 1..{max(upper, 1)}"
862 )
863 return position
864
865 @staticmethod
866 def _drift(folded: QueueState, parsed: QueueState) -> tuple[QueueDrift, ...]:
867 """Every difference between the fold and the parsed view."""
868 drifts: list[QueueDrift] = []
869 folded_where = {
870 item.item_id: attribute
871 for _title, attribute in SECTIONS
872 for item in getattr(folded, attribute)
873 }
874 parsed_where = {
875 item.item_id: attribute
876 for _title, attribute in SECTIONS
877 for item in getattr(parsed, attribute)
878 }
879 for item_id, attribute in parsed_where.items():
880 if item_id not in folded_where:
881 drifts.append(
883 kind="added",
884 item_id=item_id,
885 detail=f"hand-added to {attribute}",
886 )
887 )
888 elif folded_where[item_id] != attribute:
889 drifts.append(
891 kind="advanced",
892 item_id=item_id,
893 detail=f"{folded_where[item_id]} -> {attribute} by hand",
894 )
895 )
896 for item_id in folded_where:
897 if item_id not in parsed_where:
898 drifts.append(
900 kind="removed",
901 item_id=item_id,
902 detail="deleted from the projection by hand; the log has "
903 "no remove operation, so this is reported, not applied",
904 fixable=False,
905 )
906 )
907 # Reordering, judged over the items BOTH sides agree are pending:
908 # an item that was added or advanced is already reported above and
909 # would otherwise be counted twice.
910 shared = [
911 item.item_id
912 for item in parsed.pending
913 if folded_where.get(item.item_id) == "pending"
914 ]
915 folded_order = [
916 item.item_id for item in folded.pending if item.item_id in set(shared)
917 ]
918 for position, item_id in enumerate(shared, start=1):
919 if folded_order.index(item_id) != position - 1:
920 drifts.append(
922 kind="moved",
923 item_id=item_id,
924 detail=f"reordered to position {position} by hand",
925 )
926 )
927 break
928 return tuple(drifts)
929
930 def _apply_drift(self, drift: QueueDrift, parsed: QueueState) -> None:
931 """Record one fixable drift as an operation attributed to reconcile."""
932 func = "FileQueueBackend._apply_drift"
933 if not drift.fixable:
934 _dbg("OC_CHAT.MAIN", func, f"{drift.item_id}: {drift.kind} not applied", 3)
935 return
936 backend = FileQueueBackend(self.paths, actor=_ACTOR_RECONCILE)
937 item, bucket, position = _locate(parsed, drift.item_id)
938 if drift.kind == "added":
939 backend.enqueue(
940 item, position=position if bucket == "pending" else None
941 )
942 if bucket == "in_progress":
943 backend.dequeue(
944 drift.item_id,
945 instance=item.instance or "unknown",
946 reason="reconciled from the projection",
947 )
948 elif bucket == "completed":
949 backend.complete(
950 drift.item_id, reason="reconciled from the projection"
951 )
952 elif drift.kind == "advanced":
953 if bucket == "in_progress":
954 backend.dequeue(
955 drift.item_id,
956 instance=item.instance or "unknown",
957 reason="reconciled from the projection",
958 )
959 elif bucket == "completed":
960 backend.complete(
961 drift.item_id, reason="reconciled from the projection"
962 )
963 elif drift.kind == "moved":
964 backend.move(
965 drift.item_id,
966 position,
967 reason="reconciled from the projection",
968 )
969
970
971def _locate(state: QueueState, item_id: str) -> tuple[WorkItem, str, int]:
972 """*item_id*'s item, bucket name and 1-based position inside it."""
973 for _title, attribute in SECTIONS:
974 for position, item in enumerate(getattr(state, attribute), start=1):
975 if item.item_id == item_id:
976 return item, attribute, position
977 raise UnknownItemError(f"{item_id!r} is not in the parsed projection")
978
979
980__all__ = [
981 "DEFAULT_QUEUE_DIR",
982 "EMPTY_MARK",
983 "MARKER",
984 "OPS_FILENAME",
985 "PROJECTION_FILENAME",
986 "SECTIONS",
987 "CorruptLogError",
988 "FileQueueBackend",
989 "QueueDrift",
990 "QueuePaths",
991 "append_op",
992 "fold",
993 "parse_projection",
994 "read_ops",
995 "render_projection",
996]
QueueState _record(self, str op_name, str item_id, dict[str, object] payload)
tuple[QueueDrift,...] _drift(QueueState folded, QueueState parsed)
QueueState enqueue(self, WorkItem item, int|None position=None)
tuple[QueueOp,...] history(self, str item_id)
None _apply_drift(self, QueueDrift drift, QueueState parsed)
int _checked_index(int|None position, int upper)
tuple[QueueDrift,...] reconcile(self, *, bool apply=False)
QueueState complete(self, str item_id, *, str reason, int|None round_no=None, str|None merge_commit_sha=None)
int _pending_index(self, QueueState state, str item_id)
QueueState dequeue(self, str item_id, *, str instance, str reason)
str|None _bucket_of(QueueState state, str item_id)
None __init__(self, QueuePaths|None paths=None, *, str actor="operator")
QueueState move(self, str item_id, int position, *, str reason)
None append_op(Path ops_path, QueueOp op)
str _canonical_text(QueueState state)
tuple[QueueOp,...] read_ops(Path ops_path)
QueueState parse_projection(str text)
QueueState fold(tuple[QueueOp,...] ops)
WorkItem _item_from_annotations(str item_id, str|None annotations)
None _windows_safe_replace(Path src, Path dst)
WorkItem _item_from_payload(str item_id, Any payload)
str render_projection(QueueState state, str|None existing)
str _splice_into_existing(str text, QueueState state)
QueueOp _decode_op(str line, Path path, int number)
list[str] _bullets(tuple[WorkItem,...] items)
str|None _optional_str(object value)
tuple[WorkItem, str, int] _locate(QueueState state, str item_id)