7The queue's operator surface: list, add, move, dequeue, complete, next.
11Plan 258 P4. The queue CLI plan 223 retired is revived in the SHAPE
12that 140 dispatches taught it, and only in that shape. What comes back
13is ``list``, ``add``, ``move``, ``complete``, ``health``, ``reconcile``
14and ``history``, plus ``dequeue`` and ``next``. What does NOT come back
15is everything the letter model or the dispatcher now owns:
18 ``core/dispatch.py`` renders launch files.
20 ``next`` prints what the operator would dispatch; ``dispatch.ps1``
21 and ``prepare_worktree`` create the branch, the worktree and its
22 venv. This command never creates either, and never runs anything.
24 ``plan_manage agents active`` and the per-worktree
25 ``.PLAN_STATUS.md`` files cover it.
26``auto-assign`` / ``add-agent`` / ``list-agents``
27 The roster, the letters, the pools and the Plan-Type suggesters are
28 retired by operator ruling and are not revived.
30**This command dispatches nothing.** The autonomy boundary in the
31factory-decomposition design note §6 reserves the close and every
32ruling for the human, and automated dispatch is a later plan's work.
33``next`` prints the line an operator would run; that is the whole of
34it, and this module does not import ``subprocess`` so it could not do
39- :func:`build_parser` -- the command shape, exposed so a witness can
40 read the defaults off it.
41- :func:`main` -- argument dispatch and the exit-code policy. It carries
42 the two consumer seams (plan 271 P2): ``prog``, the name the CLI gives
43 itself in ``--help``, and ``backend_factory``, which builds the
44 ``--backend registry`` backend. Both default to this package standing
45 alone: it names itself, and it has no registry to offer.
46- :func:`format_health_report` / :func:`format_reconcile_report` --
47 the operator-facing text, kept out of the command bodies.
53 L2 - lifecycle events (which subcommand ran)
54 L3 - semantic details (a refusal and its exit code)
55 L4 - deep tracing (module load)
59- EXIT CODES: ``0`` success, ``1`` a semantic refusal (the operator
60 asked for something that does not make sense -- an unknown item, a
61 duplicate, a bad position, a missing plan file), ``2`` a BACKEND
62 refusal (:class:`~oc_orchestrator.work_queue.QueueBackendError` -- a
63 corrupt log, a storage constraint, a missing registry argument).
64 Argparse's own usage errors keep argparse's ``2`` via ``SystemExit``.
65- ``--backend file`` is the DEFAULT, and the file backend is the mode
66 the package's own tests run in (design note §7).
67- ``--backend registry`` is a PLUGIN POINT and not this package's
68 business: the registry that implements it is a consumer's, so this
69 module names none of it, lazily or otherwise, and refuses the flag
70 with :data:`NO_BACKEND_FACTORY` when no consumer supplied a factory.
71- Every path is injected: ``--queue-dir`` for the queue,
72 ``--plans-dir`` for plan files, ``--agent-registry`` for the instance
73 registry, ``--registry`` for the orchestration registry. The
74 defaults are today's values and nothing is discovered.
75- ``add`` reads a plan's header through
76 ``plan_metadata.py``'s provider -- the dispatch layer's one
77 plan-header reader -- and never parses a plan file itself.
81- oc_diagnostics (structured debug logging via _dbg)
82- oc_orchestrator.work_queue (the contract)
83- oc_orchestrator.work_queue_file (the default backend)
84- oc_orchestrator.plan_inventory (the plans-dir literal and the terminal states)
85- oc_orchestrator.plan_metadata (the one plan-header reader)
86- oc_orchestrator.agent_instances (the instance registry ``health`` reads)
89from __future__
import annotations
93from collections.abc
import Callable
94from pathlib
import Path
96from oc_diagnostics
import _dbg
99 AgentInstanceRegistry,
101 default_agent_registry,
124_dbg(
"OC_CHAT.MAIN",
"module",
"work_queue_manage loaded", 4)
136NO_BACKEND_FACTORY = (
137 "--backend registry needs a consumer-supplied backend; "
138 "oc_chat supplies one through its shim"
144DISPATCH_LINE =
"scripts/dispatch.ps1 {number} <model>"
150def _plan_file(paths: PlanPaths, plan_id: str) -> Path |
None:
151 """The ACTIVE plan file for *plan_id*, or ``None``."""
152 candidate = paths.plans_dir / f
"{plan_id}.md"
153 return candidate
if candidate.is_file()
else None
157 """A state-suffixed plan file in a TERMINAL state, or ``None``.
159 ``plan_inventory.reconcile`` renames a plan's file to
160 ``<plan_id>_<status>.md`` when its status changes, so the
161 filesystem alone answers this -- no inventory singleton, no cwd.
163 for status
in TERMINAL_STATUSES:
164 candidate = paths.plans_dir / f
"{plan_id}_{status}.md"
165 if candidate.is_file():
171 """The worktree the plan's header declares, through the one reader."""
173 if plan_path
is None:
175 meta = resolve_provider(
None).metadata(plan_path, plan_id=plan_id)
176 return Path(meta.worktree)
if meta.worktree
else None
180 """The plan's ``# `` heading, through the one reader."""
182 if plan_path
is None:
184 return resolve_provider(
None).metadata(plan_path, plan_id=plan_id).title
188 """The ``NNN`` of a ``NNN-stem`` plan id, or the id when it has none."""
189 head = plan_id.split(
"-", 1)[0]
190 return head
if head.isdigit()
else plan_id
197 args: argparse.Namespace,
198 backend_factory: Callable[[argparse.Namespace], QueueBackend] |
None =
None,
200 """The backend the flags ask for. ``file`` unless told otherwise.
202 The ``registry`` arm is the PLUGIN POINT plan 258's receipt predicted
203 and plan 271 P2 built. Before the move this function reached four
204 ``oc_chat.registry`` names on four consecutive lines, and the receipt
205 modelled them as a named cut precisely because a later extraction
206 would replace them with a seam. This is that seam: the package holds
207 no registry, so a consumer that wants one passes *backend_factory*
208 and the package names nothing of it at any depth.
210 func =
"_build_backend"
211 if args.backend ==
"file":
212 _dbg(
"OC_CHAT.MAIN", func, f
"file backend at {args.queue_dir}", 2)
215 if backend_factory
is None:
221 _dbg(
"OC_CHAT.MAIN", func,
"registry backend from the consumer's factory", 2)
222 return backend_factory(args)
225def _paths(args: argparse.Namespace) -> PlanPaths:
226 return PlanPaths(plans_dir=Path(args.plans_dir))
233 """One work item as one line of operator-facing text."""
234 parts = [item.item_id]
239 (
"instance", item.instance),
244 parts.append(f
"[{', '.join(extras)}]")
246 parts.append(f
"- {item.note}")
247 return " ".join(parts)
251 """The three buckets, as ``list`` prints them."""
252 lines: list[str] = []
253 for title, attribute
in SECTIONS:
254 lines.append(f
"## {title}")
255 items = getattr(state, attribute)
257 lines.append(
" (none)")
258 for position, item
in enumerate(items, start=1):
259 lines.append(f
" {position}. {_describe(item)}")
261 return "\n".join(lines).rstrip()
267 agent_registry: Path |
None,
269 """The two forgetting cases, plus the counts.
271 A pending item is stale when its plan file is gone or when the plan
272 reached a terminal state and nobody dequeued it. An in-progress item
273 is stale when its instance is closed in ``agents.sqlite`` -- the
274 worker went away without the queue being told -- or when the
275 worktree its plan declares holds no ``.PLAN_STATUS.md``.
277 func =
"format_health_report"
278 lines = [
"## Queue health",
""]
280 f
"pending {len(state.pending)} in progress {len(state.in_progress)} "
281 f
"completed {len(state.completed)}"
284 stale_pending: list[str] = []
285 for item
in state.pending:
287 stale_pending.append(
288 f
"- {item.item_id}: the plan file is in a terminal state; "
289 "it was never dequeued or completed"
292 stale_pending.append(
293 f
"- {item.item_id}: missing plan file under {paths.plans_dir}"
296 lines.extend([
"",
"## Pending items that need attention", *stale_pending])
299 stale_running: list[str] = []
300 for item
in state.in_progress:
301 if registry
is not None and item.instance:
303 if state_text ==
"closed":
304 stale_running.append(
305 f
"- {item.item_id}: instance {item.instance} is closed; "
306 "nobody completed the item"
310 if worktree
is not None and not (worktree /
".PLAN_STATUS.md").is_file():
311 stale_running.append(
312 f
"- {item.item_id}: no .PLAN_STATUS.md under {worktree}"
315 lines.extend([
"",
"## In-progress items that need attention", *stale_running])
317 if not stale_pending
and not stale_running:
318 lines.extend([
"",
"Nothing needs attention."])
322 f
"{len(stale_pending)} pending, {len(stale_running)} running flagged",
325 return "\n".join(lines)
329 """The instance registry, or ``None`` when there is no file to read."""
330 if path
is None or not Path(path).is_file():
336 """*agent_id*'s state, or ``None`` when the registry does not know it."""
337 func =
"_instance_state"
339 return registry.get(agent_id).state
340 except (AgentRegistryError, ValueError)
as exc:
343 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {agent_id}: {exc}", 3)
348 """The drift list, as ``reconcile`` prints it."""
349 lines = [
"## Queue reconcile",
""]
351 lines.append(
"No drift detected.")
352 return "\n".join(lines)
354 mark =
"+" if getattr(drift,
"fixable",
False)
else "!"
356 f
"{mark} {getattr(drift, 'item_id', '?')}: "
357 f
"{getattr(drift, 'kind', '?')} - {getattr(drift, 'detail', '')}"
359 unfixable = [d
for d
in drifts
if not getattr(d,
"fixable",
False)]
363 f
"Applied {len(drifts) - len(unfixable)} edit(s) to the log as "
364 "operations attributed to reconcile."
368 f
"{len(unfixable)} deletion(s) could not be applied: the "
369 "operation set has no remove. The projection was re-rendered "
370 "from the log, so those bullets are back."
373 lines.append(
"(dry run; pass --apply to record these as operations)")
374 return "\n".join(lines)
380def _cmd_list(args: argparse.Namespace, backend: QueueBackend) -> int:
385def _cmd_add(args: argparse.Namespace, backend: QueueBackend) -> int:
386 """Queue a plan, refusing an id with no active plan file."""
392 f
"its only file is {terminal.name}, a terminal state"
393 if terminal
is not None
394 else f
"no {args.plan_id}.md"
397 f
"refused: {args.plan_id} has no active plan file under "
398 f
"{paths.plans_dir} ({detail})",
401 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {args.plan_id}", 3)
405 item_id=args.plan_id,
407 note=args.note
or _plan_title(paths, args.plan_id),
409 backend.enqueue(item, position=args.position)
410 print(f
"Queued {_describe(item)}")
414def _cmd_move(args: argparse.Namespace, backend: QueueBackend) -> int:
415 backend.move(args.plan_id, args.position, reason=args.reason)
416 print(f
"Moved {args.plan_id} to position {args.position}.")
420def _cmd_dequeue(args: argparse.Namespace, backend: QueueBackend) -> int:
421 backend.dequeue(args.plan_id, instance=args.instance, reason=args.reason)
422 print(f
"Dequeued {args.plan_id} to {args.instance}.")
427 """Finish an item. Already-complete is success, as the retired CLI had it."""
429 item.item_id
for item
in backend.status().completed
431 if args.plan_id
in already:
432 print(f
"{args.plan_id} is already complete.")
434 extra: dict[str, object] = {}
435 if args.round_no
is not None:
436 extra[
"round_no"] = args.round_no
437 if args.merge_commit_sha
is not None:
438 extra[
"merge_commit_sha"] = args.merge_commit_sha
439 backend.complete(args.plan_id, reason=args.reason, **extra)
440 print(f
"Completed {args.plan_id}.")
444def _cmd_next(args: argparse.Namespace, backend: QueueBackend) -> int:
445 """Print what the operator would dispatch. Never dispatch it.
447 The design note's autonomy boundary is the reason this prints
448 rather than runs, and automated dispatch is the hub's plan. The
449 ``<model>`` placeholder stays a placeholder: the model is the
450 operator's attestation.
453 state = backend.status()
454 if not state.pending:
455 print(
"Nothing pending.")
457 item = state.pending[0]
458 print(f
"next: {_describe(item)}")
459 print(f
"dispatch: {DISPATCH_LINE.format(number=_plan_number(item.item_id))}")
460 print(
"(this command prints the line; it does not run it)")
461 _dbg(
"OC_CHAT.MAIN", func, f
"next is {item.item_id}", 2)
465def _cmd_health(args: argparse.Namespace, backend: QueueBackend) -> int:
467 Path(args.agent_registry)
if args.agent_registry
else default_agent_registry()
474 """Compare the projection with the log. Dry-run unless ``--apply``."""
475 func =
"_cmd_reconcile"
476 reconcile = getattr(backend,
"reconcile",
None)
477 if reconcile
is None:
479 f
"refused: the {args.backend} backend has no projection to reconcile",
483 drifts = reconcile(apply=args.apply)
485 _dbg(
"OC_CHAT.MAIN", func, f
"{len(drifts)} drift(s), apply={args.apply}", 2)
489def _cmd_history(args: argparse.Namespace, backend: QueueBackend) -> int:
490 ops = backend.history(args.plan_id)
492 print(f
"No recorded operations for {args.plan_id}.")
495 print(f
"{op.seq:>4} {op.at} {op.op:<9} {op.actor} {dict(op.payload)}")
503 "dequeue": _cmd_dequeue,
504 "complete": _cmd_complete,
506 "health": _cmd_health,
507 "reconcile": _cmd_reconcile,
508 "history": _cmd_history,
519DEFAULT_PROG =
"python -m oc_orchestrator.work_queue_manage"
523 """The command shape, exposed so a witness can read the defaults."""
524 parser = argparse.ArgumentParser(
526 description=
"Manage the OC Chat work queue. This command never dispatches.",
530 choices=[
"file",
"registry"],
532 help=
"Queue backend (default: file, the tested mode)",
536 default=str(DEFAULT_QUEUE_DIR),
537 help=f
"File-backend queue directory (default: {DEFAULT_QUEUE_DIR})",
541 default=str(DEFAULT_PLANS_DIR),
542 help=f
"Where plan files live (default: {DEFAULT_PLANS_DIR})",
544 parser.add_argument(
"--registry", help=
"Registry-backend SQLite path")
545 parser.add_argument(
"--slot", help=
"Registry queue slot (default: the actor slot)")
547 "--actor-slot", help=
"Registry actor slot (default: the active operator)"
550 "--agent-registry", help=
"agents.sqlite path, read by health"
552 sub = parser.add_subparsers(dest=
"command", required=
True)
554 sub.add_parser(
"list", help=
"Print pending, in-progress and completed items")
556 add = sub.add_parser(
"add", help=
"Queue a plan")
557 add.add_argument(
"plan_id")
558 add.add_argument(
"--lane", help=
"Free-text preference, e.g. a model family")
559 add.add_argument(
"--position", type=int, help=
"1-based position (default: last)")
560 add.add_argument(
"--note", help=
"Free text (default: the plan's title)")
562 move = sub.add_parser(
"move", help=
"Move a pending item to another position")
563 move.add_argument(
"plan_id")
564 move.add_argument(
"--position", type=int, required=
True)
565 move.add_argument(
"--reason", required=
True)
567 dequeue = sub.add_parser(
"dequeue", help=
"Take an item into progress")
568 dequeue.add_argument(
"plan_id")
569 dequeue.add_argument(
"--instance", required=
True, help=
"agent-NNN")
570 dequeue.add_argument(
"--reason", required=
True)
572 complete = sub.add_parser(
"complete", help=
"Finish an item")
573 complete.add_argument(
"plan_id")
574 complete.add_argument(
"--reason", required=
True)
575 complete.add_argument(
576 "--round-no", type=int, help=
"Required by the registry backend"
578 complete.add_argument(
579 "--merge-commit-sha", help=
"Required by the registry backend"
584 help=
"Print the first pending item and the dispatch line. Never runs it.",
586 sub.add_parser(
"health", help=
"Report items that look forgotten")
588 reconcile = sub.add_parser(
589 "reconcile", help=
"Compare the projection with the log (dry run by default)"
591 reconcile.add_argument(
594 help=
"Record the hand edits as operations attributed to reconcile",
597 history = sub.add_parser(
"history", help=
"Print every operation for one item")
598 history.add_argument(
"plan_id")
603 argv: list[str] |
None =
None,
605 prog: str = DEFAULT_PROG,
606 backend_factory: Callable[[argparse.Namespace], QueueBackend] |
None =
None,
608 """Run one subcommand and return its exit code.
610 *prog* and *backend_factory* are the consumer seams (plan 271 P2).
611 Both default to this package standing alone: it names itself, and
612 ``--backend registry`` refuses because this package has no registry
613 to build one from. ``oc_chat``'s shim passes its own module path and
614 a factory that builds ``RegistryQueueBackend``, which is what keeps
615 every output and every queue transition identical across the split.
618 for stream
in (sys.stdout, sys.stderr):
620 stream.reconfigure(errors=
"replace")
621 except (AttributeError, ValueError):
625 _dbg(
"OC_CHAT.MAIN", func, f
"subcommand {args.command}", 2)
628 return _COMMANDS[args.command](args, backend)
629 except QueueBackendError
as exc:
633 print(f
"backend refused: {exc}", file=sys.stderr)
634 _dbg(
"OC_CHAT.MAIN", func, f
"SYSTEM_ERROR: {exc}", 1, override=
True)
636 except QueueError
as exc:
637 print(f
"refused: {exc}", file=sys.stderr)
638 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {exc}", 3)
642if __name__ ==
"__main__":
int _cmd_dequeue(argparse.Namespace args, QueueBackend backend)
str|None _instance_state(AgentInstanceRegistry registry, str agent_id)
str|None _plan_title(PlanPaths paths, str plan_id)
int _cmd_reconcile(argparse.Namespace args, QueueBackend backend)
int _cmd_complete(argparse.Namespace args, QueueBackend backend)
str _describe(WorkItem item)
str _plan_number(str plan_id)
str format_state(QueueState state)
Path|None _plan_worktree(PlanPaths paths, str plan_id)
int _cmd_add(argparse.Namespace args, QueueBackend backend)
argparse.ArgumentParser build_parser(str prog=DEFAULT_PROG)
int _cmd_list(argparse.Namespace args, QueueBackend backend)
str format_reconcile_report(tuple[object,...] drifts, bool applied)
Path|None _terminal_plan_file(PlanPaths paths, str plan_id)
str format_health_report(QueueState state, PlanPaths paths, Path|None agent_registry)
int main(list[str]|None argv=None, *, str prog=DEFAULT_PROG, Callable[[argparse.Namespace], QueueBackend]|None backend_factory=None)
QueueBackend _build_backend(argparse.Namespace args, Callable[[argparse.Namespace], QueueBackend]|None backend_factory=None)
int _cmd_move(argparse.Namespace args, QueueBackend backend)
Path|None _plan_file(PlanPaths paths, str plan_id)
int _cmd_health(argparse.Namespace args, QueueBackend backend)
int _cmd_history(argparse.Namespace args, QueueBackend backend)
PlanPaths _paths(argparse.Namespace args)
AgentInstanceRegistry|None _open_instance_registry(Path|None path)
int _cmd_next(argparse.Namespace args, QueueBackend backend)