oc_orchestrator
Loading...
Searching...
No Matches
hub_manage.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/hub_manage.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_orchestrator/tests/test_259_hub_manage_cli.py"
6"""
7The hub's operator surface: send, ask, rule, inbox, asks, fleet, history.
8
9Purpose
10-------
11Plan 259 P8 and P7. This is where a human, a worker and the orchestrator
12all reach the hub. Two of its properties are the point; the rest is
13plumbing around them.
14
15**Who the process is comes from the BACKEND, never from a flag.** There
16is no ``--as``. In file mode the actor is the operator at the control
17centre and the correlation is the process user and working directory; in
18registry mode it is the active operator slot. A flag that set provenance
19would be exactly the mechanism by which an agent relays a yes, and the
20whole point of the hub's provenance rule is that such a yes is
21distinguishable. So the flag does not exist, and asking for one is an
22argparse usage error.
23
24**``fleet`` says "waiting" before anyone says "stalled".** One line per
25non-closed instance: what it is, what plan it carries, what state the
26registry has it in, and either ``waiting on ask <id> since <time>`` or
27``no message since <time>``. A worker blocked on a human answer is
28waiting on a decision, not going round in circles, and automated
29dispatch that could not tell the difference would halt every worker that
30ever asked a question. That is the fleet-level half of design-note
31invariant 3; the in-session stall detector's half belongs to the Tier-2
32follow-up and is not touched here.
33
34**This command dispatches nothing and relays nothing.** ``relay-check``
35prints the ``oc_connect relay hub`` line an operator would run, and that
36is the whole of it -- this module does not import ``subprocess``, so it
37could not do more if it tried. The same autonomy boundary
38``work_queue_manage next`` observes.
39
40**Two consumer seams, and nothing else outward** (plan 272 P2). This
41package holds no registry and declares no connector, so the two places
42the pre-272 module reached for one are now seams:
43:func:`main`'s ``prog``, the name the CLI gives itself in ``--help``, and
44``log_factory``, which builds the log ``--backend registry`` asks for.
45With neither supplied the package stands alone: it names itself, and
46``--backend registry`` refuses by name rather than pretending. The
47connector is the ``[telegram]`` extra, reached lazily from the two
48``rule --from-inbox`` sites and nowhere else, so a ``hub_manage asks``
49in a venv that has never heard of ``oc_connect`` runs exactly as it
50always did.
51
52Responsibilities
53----------------
54- :func:`build_parser` -- the command shape, exposed so a witness can
55 read the defaults off it.
56- :func:`main` -- argument dispatch and the exit-code policy, plus the
57 two consumer seams.
58- :func:`default_hooks` / :func:`hooks_for_dispatch` -- the live
59 :class:`~oc_orchestrator.dispatch.DispatchHooks` P6's caller registers,
60 which records a ``dispatch`` message and, for a PENDING item only, one
61 ``dequeue`` with ``reason="dispatched"``.
62- :func:`format_fleet_report` -- P7's lines, kept out of the command body.
63
64Diagnostics
65-----------
66Domain: OC_CHAT.MAIN
67Levels:
68 L2 - lifecycle events (which subcommand ran, a hook firing)
69 L3 - semantic details (a refusal and its exit code)
70 L4 - deep tracing (module load)
71
72Contracts
73---------
74- EXIT CODES, matching ``work_queue_manage``: ``0`` success, ``1`` a
75 SEMANTIC refusal (an unknown address, an unknown kind, a ruling on
76 nothing), ``2`` a BACKEND refusal (a corrupt log, a missing registry).
77 Argparse's own usage errors keep argparse's ``2`` via ``SystemExit``.
78- ``--backend file`` is the DEFAULT and the mode the package's own tests
79 run in (design note §7).
80- Every path is injected: ``--queue-dir``, ``--comms-root``,
81 ``--registry``, ``--agent-registry``, ``--actor-slot``. The defaults
82 are today's values and nothing is discovered except the comms root,
83 which is walked from the working directory exactly as every other lane
84 writer walks it.
85- :func:`_build_log`'s registry arm is a PLUGIN POINT, not an import.
86 Before plan 272 it reached four ``oc_chat.registry`` names on four
87 consecutive lines; they now live in the consumer's factory, exactly as
88 plan 271 did for the queue's ``_build_backend``. This module names no
89 registry at any depth, lazily or otherwise.
90- The connector is reached from :func:`_reply_from_inbox` and
91 :func:`_consume_inbound` and NOWHERE else, inside the function bodies,
92 and a missing ``[telegram]`` extra is a named refusal rather than a
93 traceback. ``tests/test_t1_leaf_guard.py`` holds both halves.
94- ``ingest`` runs implicitly before ``inbox``, ``asks`` and ``fleet``, so
95 a blocked worker is an open ask before anyone looks for one.
96- NO ``subprocess`` import, anywhere, at any depth.
97
98Dependencies
99------------
100- oc_diagnostics (structured debug logging via _dbg)
101- oc_orchestrator.hub (the contract)
102- oc_orchestrator.hub_file (the default backend and the lane carrier)
103- oc_orchestrator.agent_instances (the instance registry ``fleet`` reads)
104- oc_orchestrator.work_queue (the refusal class ``main`` maps)
105- oc_orchestrator.work_queue_file (the queue the dispatch hook records into)
106- oc_connect.audit (lazy; the ``[telegram]`` extra's audit store, for ``rule --from-inbox`` only)
107- oc_connect.hub_lane (lazy; the ``[telegram]`` extra's reply-to-hub-message correlation, for ``rule --from-inbox`` only)
108- oc_connect.inbound.inbox (lazy; the ``[telegram]`` extra's inbound store, read and marked acted by ``rule --from-inbox`` only)
109"""
110
111from __future__ import annotations
112
113import argparse
114import getpass
115import json
116import sys
117from datetime import datetime, timezone
118from pathlib import Path
119from typing import Any, Callable
120
121from oc_diagnostics import _dbg
122
124 AgentInstanceRegistry,
125 AgentRegistryError,
126 default_agent_registry,
127)
128from oc_orchestrator.hub import (
129 ADDRESS_OPERATOR_LOCAL,
130 ADDRESS_ORCHESTRATOR,
131 KIND_ASK,
132 KIND_DISPATCH,
133 RULING_ANSWERS,
134 SUPPORTED_KINDS,
135 SUPPORTED_REQUIRES,
136 Hub,
137 HubEntry,
138 HubError,
139 Provenance,
140 new_message,
141 parse_address,
142)
143from oc_orchestrator.hub_file import (
144 DEFAULT_QUEUE_DIR,
145 CorruptHubLogError,
146 FileHubLog,
147 FileLaneCarrier,
148 HubPaths,
149 find_comms_root,
150 ingest_lanes,
151)
152from oc_orchestrator.work_queue import QueueError
153from oc_orchestrator.work_queue_file import FileQueueBackend, QueuePaths
154
155_dbg("OC_CHAT.MAIN", "module", "hub_manage loaded", 4)
156
157
158#: Success, a semantic refusal, a backend refusal.
159EXIT_OK = 0
160EXIT_REFUSED = 1
161EXIT_BACKEND = 2
162
163#: What ``relay-check`` prints. It is a LINE, not a command this module
164#: runs: relaying holds a credential, and the autonomy boundary keeps
165#: that in the operator's own process.
166RELAY_LINE = "python -m oc_connect relay hub"
167
168#: The ``QUEUE_DEQUEUE_REASONS`` member a dispatch records (plan 259 P4,
169#: closing the 258 residual). The queue's own vocabulary now has a word
170#: for what happened, so the hook does not have to borrow one.
171DEQUEUE_REASON_DISPATCHED = "dispatched"
172
173#: What ``--backend registry`` says when no consumer supplied a factory
174#: (plan 272 P2, in plan 271's shape). It is a BACKEND refusal -- the
175#: same arm that already refused an unopenable registry -- so it keeps
176#: that arm's :data:`EXIT_BACKEND` and its one-line ``backend refused:``
177#: stderr shape.
178NO_LOG_FACTORY = (
179 "--backend registry needs a consumer-supplied log; "
180 "oc_chat supplies one through its shim"
181)
182
183#: What the two connector sites say when the ``[telegram]`` extra is not
184#: installed. The same refusal shape for the same reason: the package
185#: declares the connector optional, so its absence is a fact about this
186#: venv and not a crash.
187NO_TELEGRAM_EXTRA = (
188 "rule --from-inbox needs the [telegram] extra: "
189 "pip install oc_orchestrator[telegram]"
190)
191
192
193# ── Backends ─────────────────────────────────────────────────────────────
194
195
197 args: argparse.Namespace,
198 log_factory: "Callable[[argparse.Namespace], Any] | None" = None,
199) -> Any:
200 """The log the flags ask for. ``file`` unless told otherwise.
201
202 The ``registry`` arm is the PLUGIN POINT plan 272 P2 built, in the
203 shape plan 271 gave the queue's ``_build_backend``. Before the move
204 this function reached four ``oc_chat.registry`` names on four
205 consecutive lines, and the plan-259 receipt modelled them as a named
206 cut precisely because a later extraction would replace them with a
207 seam. This is that seam: the package holds no registry, so a consumer
208 that wants one passes *log_factory* and the package names nothing of
209 it at any depth.
210
211 Raises:
212 CorruptHubLogError: SYSTEM_ERROR (L1 for the caller) --
213 ``--backend registry`` with no factory to fill it. ``main``'s
214 handler owns the stderr line and the trace, so the refusal is
215 raised bare exactly as the pre-272 registry arm raised its
216 missing-operator-slot one.
217 """
218 func = "_build_log"
219 if args.backend == "file":
220 _dbg("OC_CHAT.MAIN", func, f"file log at {args.queue_dir}", 2)
221 return FileHubLog(HubPaths(queue_dir=Path(args.queue_dir)))
222
223 if log_factory is None:
224 raise CorruptHubLogError(NO_LOG_FACTORY)
225 _dbg("OC_CHAT.MAIN", func, "registry log from the consumer's factory", 2)
226 return log_factory(args)
227
228
229def _comms_root(args: argparse.Namespace) -> Path | None:
230 """The bus root the flags ask for, or the walk, or ``None``.
231
232 ``None`` means the lane carrier is not built: the log still records
233 every message, so a hub with no reachable bus is quieter rather than
234 broken.
235 """
236 func = "_comms_root"
237 if getattr(args, "comms_root", None):
238 return Path(args.comms_root).expanduser().resolve()
239 try:
240 return find_comms_root(Path.cwd())
241 except CorruptHubLogError as exc:
242 # EXTERNAL_ERROR (L2): running outside a workspace is a fact
243 # about where the operator is, not a refusal to make.
244 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: {exc}", 2)
245 return None
246
247
248def _build_hub(args: argparse.Namespace, log: Any) -> Hub:
249 """The hub the flags ask for, with the file lane as its one carrier.
250
251 The bus carrier is deliberately absent from a CLI process: nothing is
252 attached in a command that runs for a hundred milliseconds, so it
253 would never win a routing decision. A long-lived control centre
254 injects one; this tool is not that.
255 """
256 root = _comms_root(args)
257 carriers = (FileLaneCarrier(comms_root=root),) if root is not None else ()
258 return Hub(log=log, carriers=carriers)
259
260
261def _actor_provenance() -> Provenance:
262 """Who this PROCESS is, in the hub's own terms.
263
264 The correlation is the process user and the working directory --
265 the two things that say which terminal this ruling was typed into.
266 There is no flag anywhere that can change any of these four values;
267 see the module docstring for why.
268 """
269 return Provenance(
270 actor=ADDRESS_OPERATOR_LOCAL,
271 actor_type="human_operator",
272 channel="cli",
273 correlation={"user": _process_user(), "cwd": str(Path.cwd())},
274 )
275
276
277def _process_user() -> str:
278 """The OS user, or a named stand-in when the platform will not say."""
279 try:
280 return getpass.getuser()
281 except Exception: # noqa: BLE001 - EXTERNAL_ERROR (L3); a name, not a gate
282 return "unknown-user"
283
284
285# ── Rendering ────────────────────────────────────────────────────────────
286
287
288def _describe(entry: HubEntry) -> str:
289 """One recorded message as one line of operator-facing text."""
290 message = entry.message
291 parts = [
292 f"{entry.seq:>4}",
293 message.at,
294 f"{message.kind:<11}",
295 f"{message.sender} -> {message.recipient}",
296 ]
297 if message.subject:
298 parts.append(f"[{message.subject}]")
299 if message.requires:
300 parts.append(f"requires={message.requires}")
301 if message.answer:
302 parts.append(f"answer={message.answer}")
303 parts.append(message.message_id)
304 return " ".join(parts)
305
306
307def format_entries(entries: tuple[HubEntry, ...], *, empty: str) -> str:
308 """A list of messages, with each body indented under its own header.
309
310 The dense form: ``history`` keeps it because that is the audit view.
311 ``inbox`` and ``asks`` render cards (:func:`format_cards`) instead.
312 """
313 if not entries:
314 return empty
315 lines: list[str] = []
316 for entry in entries:
317 lines.append(_describe(entry))
318 for line in entry.message.body.splitlines():
319 lines.append(f" {line}")
320 return "\n".join(lines)
321
322
323# OCT-LINT: disable=no_hardcoded_secrets reason="Operator-facing footer prose naming the rule verb and the four reply-vocabulary answers; scores 4.95 bits/char and carries no credential. Operator-ruled 2026-09-13 (hub_manage card-rendering chore escalation, line-scoped)"
324RULE_HINT = "To answer: hub_manage rule <id> --answer YES|NO|ACK|HOLD [--body \"...\"]"
325
326
327def _age(at: str, *, now: datetime | None = None) -> str:
328 """``(3 days ago)`` for an ISO timestamp, or ``""`` when it will not parse."""
329 try:
330 then = datetime.fromisoformat(at.replace("Z", "+00:00"))
331 except ValueError:
332 return ""
333 if then.tzinfo is None:
334 then = then.replace(tzinfo=timezone.utc)
335 moment = now or datetime.now(timezone.utc)
336 seconds = max(0, int((moment - then).total_seconds()))
337 if seconds < 3600:
338 return f"({seconds // 60} min ago)"
339 if seconds < 86400:
340 return f"({seconds // 3600} h ago)"
341 days = seconds // 86400
342 return f"({days} day{'s' if days != 1 else ''} ago)"
343
344
345def _card_body(message: Any, *, full: bool) -> list[str]:
346 """The body as card lines: the first line and a count, or all of it."""
347 body_lines = message.body.splitlines()
348 if full or len(body_lines) <= 1:
349 return [f" {line}" for line in body_lines] or [" (no body)"]
350 first = next((line for line in body_lines if line.strip()), "")
351 rest = len(body_lines) - 1
352 where = f"`history {message.subject}`" if message.subject else "`--full`"
353 return [f" {first} (+{rest} lines; {where} for the body)"]
354
355
357 entries: tuple[HubEntry, ...],
358 *,
359 title: str,
360 empty: str,
361 source: str,
362 full: bool = False,
363 now: datetime | None = None,
364) -> str:
365 """One card per message under a header that says what the list is.
366
367 Header: ``## <title> (<count>) <source>``. Each card opens with a
368 bracketed ordinal, the kind and the id, then one aligned field per
369 line, then the body's first line with a line count (all of it under
370 ``full``). A blank line separates cards, so the seam between two
371 items is visible without reading either. When any card is an ask
372 the footer names the verb that answers it.
373 """
374 header = f"## {title} ({len(entries)}) {source}"
375 if not entries:
376 return f"{header}\n\n{empty}"
377 blocks: list[str] = [header]
378 for index, entry in enumerate(entries, start=1):
379 message = entry.message
380 lines = [
381 f"[{index}] {message.kind} {message.message_id} seq {entry.seq}",
382 f" from {message.sender} -> {message.recipient}",
383 ]
384 if message.subject:
385 lines.append(f" subject {message.subject}")
386 if message.requires:
387 lines.append(f" requires {message.requires}")
388 if message.answer:
389 lines.append(f" answer {message.answer}")
390 if message.in_reply_to:
391 lines.append(f" answers {message.in_reply_to}")
392 lines.append(f" sent {message.at} {_age(message.at, now=now)}".rstrip())
393 lines.extend(_card_body(message, full=full))
394 blocks.append("\n".join(lines))
395 if any(entry.message.kind == KIND_ASK for entry in entries):
396 blocks.append(RULE_HINT)
397 return "\n\n".join(blocks)
398
399
400def entries_as_json(entries: tuple[HubEntry, ...], *, command: str, source: str) -> str:
401 """The same list as one JSON document, for agents and tools."""
402 return json.dumps(
403 {
404 "command": command,
405 "source": source,
406 "count": len(entries),
407 "entries": [
408 {"seq": entry.seq, **entry.message.as_dict()} for entry in entries
409 ],
410 },
411 indent=2,
412 sort_keys=False,
413 )
414
415
416def _source_label(args: argparse.Namespace) -> str:
417 """Where the listing came from, so nobody wonders which store this is."""
418 if args.backend == "file":
419 return f"backend=file log={Path(args.queue_dir) / 'hub.ops.jsonl'}"
420 return f"backend=registry registry={args.registry or 'discovered'}"
421
422
424 args: argparse.Namespace,
425 entries: tuple[HubEntry, ...],
426 *,
427 command: str,
428 title: str,
429 empty: str,
430) -> None:
431 """Print a listing as cards, or as JSON when ``--json`` was given."""
432 source = _source_label(args)
433 if getattr(args, "json", False):
434 print(entries_as_json(entries, command=command, source=source))
435 return
436 print(format_cards(
437 entries, title=title, empty=empty, source=source,
438 full=getattr(args, "full", False),
439 ))
440
441
443 instances: list[Any],
444 entries: tuple[HubEntry, ...],
445) -> str:
446 """P7: one line per non-closed instance, saying whether it is WAITING.
447
448 The distinction the design note requires, made before anyone can call
449 a worker stalled: an instance with an open ask it sent is waiting on
450 a decision. The line names the ask and when it was sent, so the
451 operator can answer it rather than investigate a silence.
452 """
453 func = "format_fleet_report"
454 lines = ["## Fleet", ""]
455 if not instances:
456 lines.append("No open instances.")
457 return "\n".join(lines)
458
459 ruled = {
460 entry.message.in_reply_to
461 for entry in entries
462 if entry.message.kind == "ruling"
463 }
464 waiting = 0
465 for instance in instances:
466 open_ask = next(
467 (
468 entry
469 for entry in reversed(entries)
470 if entry.message.kind == KIND_ASK
471 and entry.message.sender == instance.agent_id
472 and entry.message.message_id not in ruled
473 ),
474 None,
475 )
476 last = next(
477 (
478 entry
479 for entry in reversed(entries)
480 if instance.agent_id in (entry.message.sender, entry.message.recipient)
481 ),
482 None,
483 )
484 head = (
485 f"- {instance.agent_id} {instance.plan_id or '-'} "
486 f"state={instance.state}"
487 )
488 if last is not None:
489 head += f" last={last.message.kind} at {last.message.at}"
490 if open_ask is not None:
491 waiting += 1
492 lines.append(
493 f"{head} waiting on ask {open_ask.message.message_id} since "
494 f"{open_ask.message.at}"
495 )
496 elif last is not None:
497 lines.append(f"{head} no message since {last.message.at}")
498 else:
499 lines.append(f"{head} no message since it was launched")
500
501 lines.extend(["", f"{waiting} of {len(instances)} waiting on a decision."])
502 _dbg("OC_CHAT.MAIN", func, f"{waiting}/{len(instances)} waiting", 3)
503 return "\n".join(lines)
504
505
506# ── Commands ─────────────────────────────────────────────────────────────
507
508
509def _ingest(args: argparse.Namespace, log: Any) -> int:
510 """Fold the two existing lanes in. Returns how many were new."""
511 root = _comms_root(args)
512 if root is None:
513 return 0
514 return len(ingest_lanes(root, log))
515
516
517def _cmd_send(args: argparse.Namespace, log: Any) -> int:
518 hub = _build_hub(args, log)
519 message = new_message(
520 kind=args.kind,
521 sender=args.sender,
522 recipient=args.recipient,
523 subject=args.subject,
524 body=args.body,
525 requires=args.requires if args.kind == KIND_ASK else None,
526 provenance=_actor_provenance(),
527 )
528 _deliver(hub, message)
529 print(f"Recorded {message.kind} {message.message_id} -> {message.recipient}.")
530 return EXIT_OK
531
532
533def _cmd_ask(args: argparse.Namespace, log: Any) -> int:
534 """A ``send`` shorthand: the kind is fixed and an answerer is required."""
535 args.kind = KIND_ASK
536 return _cmd_send(args, log)
537
538
539def _cmd_rule(args: argparse.Namespace, log: Any) -> int:
540 """Answer an open ask, from this process or from a stored reply."""
541 func = "_cmd_rule"
542 hub = _build_hub(args, log)
543 if args.from_inbox:
544 ask_id, provenance, answer = _reply_from_inbox(args)
545 body = args.body or f"{answer} (from the operator's reply)"
546 else:
547 if not args.ask_id:
548 print("rule needs an ask id, or --from-inbox", file=sys.stderr)
549 return EXIT_REFUSED
550 ask_id, provenance, answer = args.ask_id, _actor_provenance(), args.answer
551 body = args.body or f"{answer} (ruled at the control centre)"
552
553 entry = hub.rule(ask_id, answer=answer, body=body, provenance=provenance)
554 if args.from_inbox:
555 _consume_inbound(args, args.from_inbox, entry.message.message_id)
556 print(
557 f"Ruled {answer} on {ask_id} as {entry.message.message_id} "
558 f"(channel={provenance.channel})."
559 )
560 _dbg("OC_CHAT.MAIN", func, f"{answer} on {ask_id}", 2)
561 return EXIT_OK
562
563
564def _cmd_inbox(args: argparse.Namespace, log: Any) -> int:
565 """What is addressed to one party and not yet read. Advances the cursor."""
566 func = "_cmd_inbox"
567 parse_address(args.address)
568 _ingest(args, log)
569 if args.all:
570 entries = log.entries(recipient=args.address)
571 _emit(args, entries, command="inbox", title=f"Inbox for {args.address}, everything",
572 empty=f"Nothing addressed to {args.address}.")
573 return EXIT_OK
574
575 entries = _unread(log, args.address)
576 _emit(args, entries, command="inbox", title=f"Inbox for {args.address}, unread",
577 empty=f"Nothing new for {args.address}.")
578 if entries:
579 _advance(log, args.address, entries[-1].seq)
580 _dbg("OC_CHAT.MAIN", func,
581 f"{args.address} cursor -> {entries[-1].seq}", 2)
582 return EXIT_OK
583
584
585def _cmd_asks(args: argparse.Namespace, log: Any) -> int:
586 """Open questions, or every question ever asked."""
587 _ingest(args, log)
588 recipient = parse_address(args.address) if args.address else None
589 scope = f" for {recipient}" if recipient else ""
590 if args.open:
591 entries = log.open_asks(recipient=recipient)
592 title, empty = f"Open asks{scope}", "No open asks."
593 else:
594 entries = log.entries(kind=KIND_ASK, recipient=recipient)
595 title, empty = f"All asks{scope}", "No asks recorded."
596 _emit(args, entries, command="asks", title=title, empty=empty)
597 return EXIT_OK
598
599
600def _cmd_fleet(args: argparse.Namespace, log: Any) -> int:
601 """P7: one line per open instance, saying whether it is waiting."""
602 _ingest(args, log)
603 registry_path = (
604 Path(args.agent_registry) if args.agent_registry else default_agent_registry()
605 )
606 if not Path(registry_path).is_file():
607 print(f"## Fleet\n\nNo instance registry at {registry_path}.")
608 return EXIT_OK
609 try:
610 instances = AgentInstanceRegistry(Path(registry_path)).list(active_only=True)
611 except (AgentRegistryError, ValueError) as exc:
612 # EXTERNAL_ERROR (L2): "unreadable" and "none" are different
613 # answers to the operator's question, so say which one this is.
614 print(f"## Fleet\n\nThe instance registry is unreadable: {exc}")
615 return EXIT_OK
616 print(format_fleet_report(instances, log.entries()))
617 return EXIT_OK
618
619
620def _cmd_ingest(args: argparse.Namespace, log: Any) -> int:
621 count = _ingest(args, log)
622 print(
623 f"Ingested {count} lane event(s)." if count else "Nothing new on the lanes."
624 )
625 return EXIT_OK
626
627
628def _cmd_history(args: argparse.Namespace, log: Any) -> int:
629 _ingest(args, log)
630 entries = log.entries(subject=args.subject)
631 if args.json:
632 print(entries_as_json(entries, command="history", source=_source_label(args)))
633 return EXIT_OK
634 # Dense on purpose: history is the audit view (operator ruling, 2026-09-13).
635 print(format_entries(entries, empty=f"No recorded messages about {args.subject}."))
636 return EXIT_OK
637
638
639def _cmd_relay_check(args: argparse.Namespace, log: Any) -> int:
640 """Print the relay line an operator would run. Never run it.
641
642 Relaying holds a credential and reaches the transport; the autonomy
643 boundary keeps both in the operator's own process. This module does
644 not import ``subprocess``, so this could not spawn anything even by
645 accident.
646 """
647 _ingest(args, log)
648 pending = [
649 entry
650 for entry in log.entries(recipient="operator@remote")
651 ]
652 print(f"{len(pending)} hub message(s) addressed to operator@remote.")
653 print(f"relay: {RELAY_LINE}")
654 print("(this command prints the line; it does not run it)")
655 return EXIT_OK
656
657
658_COMMANDS = {
659 "send": _cmd_send,
660 "ask": _cmd_ask,
661 "rule": _cmd_rule,
662 "inbox": _cmd_inbox,
663 "asks": _cmd_asks,
664 "fleet": _cmd_fleet,
665 "ingest": _cmd_ingest,
666 "history": _cmd_history,
667 "relay-check": _cmd_relay_check,
668}
669
670
671# ── Internals the commands share ─────────────────────────────────────────
672
673
674def _deliver(hub: Hub, message: Any) -> None:
675 """Send, tolerating a hub with no carrier for that recipient.
676
677 The log is the truth and :meth:`Hub.send` records BEFORE it routes,
678 so a message nobody could carry is still a recorded message. A CLI
679 that exited non-zero here would be reporting the absence of a
680 listener as a failure to send.
681 """
682 func = "_deliver"
683 from oc_orchestrator.hub import CarrierError
684
685 try:
686 hub.send(message)
687 except CarrierError as exc:
688 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: {exc}", 2)
689 print(f"note: {exc}", file=sys.stderr)
690
691
692def _unread(log: Any, address: str) -> tuple[HubEntry, ...]:
693 """Unread entries for *address*, or everything when the log has no cursor.
694
695 The registry backend has no cursor file: its log is the operation
696 log, which no reader may write to. A cursor for it is the follow-up
697 the report names; until then ``inbox --backend registry`` shows
698 everything and says so rather than pretending to track a position.
699 """
700 reader = getattr(log, "unread_for", None)
701 if reader is None:
702 return log.entries(recipient=address)
703 return reader(address)
704
705
706def _advance(log: Any, address: str, seq: int) -> None:
707 """Move *address*'s cursor, where the backend has one."""
708 advance = getattr(log, "advance_cursor", None)
709 if advance is not None:
710 advance(address, seq)
711
712
713def _reply_from_inbox(args: argparse.Namespace) -> tuple[str, Provenance, str]:
714 """Resolve a stored inbound reply into (ask id, provenance, answer).
715
716 The ONE place this tool reaches the connector for a reply, and it is
717 a lazy import because the correlation being walked is the
718 connector's: a ``hub_manage asks`` must not load a package that
719 handles credentials, and since plan 272 it must not even REQUIRE one
720 to be installed. ``oc_connect`` is the ``[telegram]`` extra, so an
721 ``ImportError`` here means the operator asked for a sub-command this
722 venv was not built for, and it is a named refusal rather than a
723 traceback.
724
725 The provenance built here is ``telegram`` with the three correlation
726 fields the hub's human rule demands -- and every one of them comes
727 from the STORED message, never from the command line.
728
729 Raises:
730 HubError: VALIDATION_ERROR (L3) -- no comms bus, an uncorrelated
731 reply, an answer outside the vocabulary, or the
732 ``[telegram]`` extra absent.
733 """
734 func = "_reply_from_inbox"
735 try:
736 from oc_connect.audit import AuditStore
737 from oc_connect.hub_lane import UncorrelatedReplyError, resolve_hub_reply
738 from oc_connect.inbound.inbox import InboxStore
739 except ImportError as exc:
740 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {NO_TELEGRAM_EXTRA}", 3)
741 raise HubError(NO_TELEGRAM_EXTRA) from exc
742
743 root = _comms_root(args)
744 if root is None:
745 raise HubError(
746 "--from-inbox needs a comms bus, and none was found above the "
747 "working directory; pass --comms-root"
748 )
749 audit = AuditStore(root / "audit" / "audit.jsonl")
750 inbox = InboxStore(root / "inbound" / "telegram", audit=audit)
751 try:
752 reply = resolve_hub_reply(args.from_inbox, inbox=inbox, audit=audit)
753 except UncorrelatedReplyError as exc:
754 raise HubError(str(exc)) from exc
755 if reply.answer not in RULING_ANSWERS:
756 raise HubError(
757 f"inbound message {args.from_inbox!r} carries {reply.answer!r}, which "
758 f"is not one of {', '.join(RULING_ANSWERS)}; a ruling needs a "
759 "vocabulary token, and free text is not one"
760 )
761 provenance = Provenance(
762 actor=ADDRESS_OPERATOR_LOCAL,
763 actor_type="human_operator",
764 channel="telegram",
765 correlation=dict(reply.correlation),
766 )
767 _dbg("OC_CHAT.MAIN", func,
768 f"{args.from_inbox} answers {reply.hub_message_id}", 2)
769 return reply.hub_message_id, provenance, reply.answer
770
771
772def _consume_inbound(args: argparse.Namespace, message_id: str, decision: str) -> None:
773 """Mark the inbound reply consumed by the ruling it produced.
774
775 Best-effort: the ruling is already recorded, and failing to annotate
776 the connector's copy of the operator's tap must not un-rule it. That
777 covers the ``[telegram]`` extra being absent too -- unreachable in
778 practice, because only ``--from-inbox`` calls this and that arm
779 already refused without the extra, but a best-effort annotation that
780 could raise on an import would be best-effort in name only.
781 """
782 func = "_consume_inbound"
783 try:
784 from oc_connect.audit import AuditStore
785 from oc_connect.inbound.inbox import InboxStore
786 except ImportError:
787 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: {NO_TELEGRAM_EXTRA}", 2)
788 return
789
790 root = _comms_root(args)
791 if root is None:
792 return
793 try:
794 audit = AuditStore(root / "audit" / "audit.jsonl")
795 InboxStore(root / "inbound" / "telegram", audit=audit).mark_acted(
796 message_id, decision
797 )
798 except Exception as exc: # noqa: BLE001 - EXTERNAL_ERROR (L2); the ruling stands
799 _dbg("OC_CHAT.MAIN", func,
800 f"EXTERNAL_ERROR: could not mark {message_id} acted: {exc}", 2)
801 print(
802 f"warning: inbound message {message_id} was not marked acted ({exc}); "
803 f"the ruling {decision} is recorded regardless",
804 file=sys.stderr,
805 )
806
807
808# ── The dispatch hooks (P6) ──────────────────────────────────────────────
809
810
812 """Record a dispatch, and dequeue the item only if it was pending."""
813
814 def __init__(self, *, log: Any, queue: Any | None, comms_root: Path | None) -> None:
815 self.log = log
816 self.queue = queue
817 self.comms_root = comms_root
818
819 def dispatched(self, result: Any) -> None:
820 """Record the ``dispatch`` message, then the queue operation."""
821 func = "_HubDispatchHooks.dispatched"
822 carriers = (
823 (FileLaneCarrier(comms_root=self.comms_root),)
824 if self.comms_root is not None
825 else ()
826 )
827 message = new_message(
828 kind=KIND_DISPATCH,
829 sender=ADDRESS_ORCHESTRATOR,
830 recipient=result.instance.agent_id,
831 subject=result.plan_id,
832 body=(
833 f"Dispatched {result.plan_id} to {result.instance.agent_id} on "
834 f"{result.branch}.\nLaunch: {result.launch_path}\n"
835 f"Worktree: {result.worktree}\nBASE: {result.base_sha}"
836 ),
837 provenance=Provenance(
838 actor=ADDRESS_ORCHESTRATOR,
839 actor_type="orchestrator_agent",
840 channel="session",
841 correlation={},
842 ),
843 )
844 _deliver(Hub(log=self.log, carriers=carriers), message)
845 _dbg("OC_CHAT.MAIN", func,
846 f"recorded dispatch of {result.plan_id} to {result.instance.agent_id}", 2)
847 self._dequeue_if_pending(result)
848
849 def _dequeue_if_pending(self, result: Any) -> None:
850 """Take the item into progress -- only if the queue held it as pending.
851
852 A plan that is NOT on the queue is logged as a dispatch and never
853 enqueued: inventing queue membership at dispatch time is the
854 silent projection plan 258 filed as a residual, and this is the
855 side of it this plan controls.
856 """
857 func = "_HubDispatchHooks._dequeue_if_pending"
858 if self.queue is None:
859 return
860 pending = [item.item_id for item in self.queue.status().pending]
861 if result.plan_id not in pending:
862 _dbg("OC_CHAT.MAIN", func,
863 f"{result.plan_id} is not pending; recorded, not enqueued", 2)
864 return
865 self.queue.dequeue(
866 result.plan_id,
867 instance=result.instance.agent_id,
868 reason=DEQUEUE_REASON_DISPATCHED,
869 )
870 _dbg("OC_CHAT.MAIN", func,
871 f"dequeued {result.plan_id} to {result.instance.agent_id}", 2)
872
873
875 *,
876 log: Any,
877 queue: Any | None = None,
878 comms_root: Path | None = None,
879) -> Any:
880 """The live :class:`~oc_orchestrator.dispatch.DispatchHooks` for P6.
881
882 Registered by the CALLER -- ``plan_manage._cmd_dispatch`` -- exactly
883 as ``plan_status`` registers its state observer, so ``dispatch.py``
884 names neither the hub nor the queue. That direction survived the move
885 unchanged: the hooks live here, beside the hub they announce into,
886 and :mod:`oc_orchestrator.dispatch` still imports nothing from this
887 module (plan 272 P3; ``test_270_standalone_dispatch.py`` holds it).
888 """
889 return _HubDispatchHooks(log=log, queue=queue, comms_root=comms_root)
890
891
893 *,
894 queue_dir: Path,
895 comms_root: Path | None = None,
896) -> Any:
897 """:func:`default_hooks` over the FILE backends under *queue_dir*.
898
899 The convenience ``plan_manage`` calls, so the dispatch path names one
900 directory rather than assembling two backends. The file mode is the
901 default everywhere else and it is the default here.
902 """
903 paths = HubPaths(queue_dir=Path(queue_dir))
904 return default_hooks(
905 log=FileHubLog(paths),
906 queue=FileQueueBackend(QueuePaths(queue_dir=Path(queue_dir))),
907 comms_root=comms_root,
908 )
909
910
911# ── Parser ───────────────────────────────────────────────────────────────
912
913
914def _listing_flags(parser: argparse.ArgumentParser) -> None:
915 """``--full`` and ``--json`` on the two card-rendering listings."""
916 parser.add_argument(
917 "--full", action="store_true",
918 help="Print every body in full instead of its first line and a count",
919 )
920 parser.add_argument(
921 "--json", action="store_true", help="One JSON document instead of cards",
922 )
923
924
925#: How this CLI names itself in ``--help``. A consumer that exposes the
926#: tool under its own module path passes its own (plan 272 P2): the
927#: shim's help output must stay byte-identical to what it printed before
928#: the split, and ``prog`` is the only line in it that could differ.
929DEFAULT_PROG = "python -m oc_orchestrator.hub_manage"
930
931
932def build_parser(prog: str = DEFAULT_PROG) -> argparse.ArgumentParser:
933 """The command shape, exposed so a witness can read the defaults.
934
935 There is deliberately no ``--as`` and no way to set provenance from
936 the command line. See the module docstring.
937 """
938 parser = argparse.ArgumentParser(
939 prog=prog,
940 description=(
941 "Send, answer and read hub messages. This command dispatches "
942 "nothing and relays nothing."
943 ),
944 )
945 parser.add_argument(
946 "--backend", choices=["file", "registry"], default="file",
947 help="Hub log backend (default: file, the tested mode)",
948 )
949 parser.add_argument(
950 "--queue-dir", default=str(DEFAULT_QUEUE_DIR),
951 help=f"File-backend directory for the log and cursors (default: {DEFAULT_QUEUE_DIR})",
952 )
953 parser.add_argument(
954 "--comms-root", help="Comms bus root (default: the workspace walk)"
955 )
956 parser.add_argument("--registry", help="Registry-backend SQLite path")
957 parser.add_argument(
958 "--actor-slot", help="Registry actor slot (default: the active operator)"
959 )
960 parser.add_argument(
961 "--agent-registry", help="agents.sqlite path, read by fleet"
962 )
963 sub = parser.add_subparsers(dest="command", required=True)
964
965 send = sub.add_parser("send", help="Record and route one message")
966 send.add_argument("--kind", required=True, choices=sorted(SUPPORTED_KINDS))
967 send.add_argument("--from", dest="sender", required=True, metavar="ADDRESS")
968 send.add_argument("--to", dest="recipient", required=True, metavar="ADDRESS")
969 send.add_argument("--subject", help="A work item id, such as 259-hub")
970 send.add_argument(
971 "--requires", choices=list(SUPPORTED_REQUIRES),
972 help="For an ask: who may answer it",
973 )
974 send.add_argument("body")
975
976 ask = sub.add_parser("ask", help="Record a question that expects a ruling")
977 ask.add_argument("--from", dest="sender", required=True, metavar="ADDRESS")
978 ask.add_argument("--to", dest="recipient", required=True, metavar="ADDRESS")
979 ask.add_argument("--subject", help="A work item id, such as 259-hub")
980 ask.add_argument("--requires", required=True, choices=list(SUPPORTED_REQUIRES))
981 ask.add_argument("body")
982
983 rule = sub.add_parser("rule", help="Answer an open ask")
984 rule.add_argument("ask_id", nargs="?", help="The ask being answered")
985 rule.add_argument(
986 "--answer", choices=list(RULING_ANSWERS),
987 help="Required unless --from-inbox, which reads the operator's own word",
988 )
989 rule.add_argument("--body", help="Free text; defaults to naming the answer")
990 rule.add_argument(
991 "--from-inbox", metavar="INBOUND_ID",
992 help=(
993 "Build the ruling from a stored operator reply, taking the answer "
994 "and all three correlation fields from the message itself"
995 ),
996 )
997
998 inbox = sub.add_parser("inbox", help="Read what is addressed to one party")
999 inbox.add_argument("--for", dest="address", required=True, metavar="ADDRESS")
1000 inbox.add_argument(
1001 "--all", action="store_true",
1002 help="Show everything and leave the cursor where it is",
1003 )
1004 _listing_flags(inbox)
1005
1006 asks = sub.add_parser("asks", help="List questions")
1007 asks.add_argument("--for", dest="address", metavar="ADDRESS")
1008 asks.add_argument("--open", action="store_true", help="Only unanswered ones")
1009 _listing_flags(asks)
1010
1011 sub.add_parser("fleet", help="One line per open instance; says who is waiting")
1012 sub.add_parser("ingest", help="Fold the worker-report and session-alert lanes in")
1013
1014 history = sub.add_parser("history", help="Every message about one work item")
1015 history.add_argument("subject")
1016 history.add_argument(
1017 "--json", action="store_true", help="One JSON document instead of text",
1018 )
1019
1020 sub.add_parser(
1021 "relay-check",
1022 help="Print the oc_connect relay line for the hub lane. Never runs it.",
1023 )
1024 return parser
1025
1026
1028 argv: list[str] | None = None,
1029 *,
1030 prog: str = DEFAULT_PROG,
1031 log_factory: "Callable[[argparse.Namespace], Any] | None" = None,
1032) -> int:
1033 """Run one subcommand and return its exit code.
1034
1035 *prog* and *log_factory* are the consumer seams (plan 272 P2). Both
1036 default to this package standing alone: it names itself, and
1037 ``--backend registry`` refuses because this package has no registry
1038 to build a log from. ``oc_chat``'s shim passes its own module path
1039 and a factory that builds ``RegistryHubLog``, which is what keeps
1040 every output and every recorded row identical across the split.
1041 """
1042 func = "main"
1043 for stream in (sys.stdout, sys.stderr):
1044 try:
1045 stream.reconfigure(errors="replace")
1046 except (AttributeError, ValueError):
1047 pass # non-reconfigurable stream (tests, pipes wrapped by harnesses)
1048
1049 args = build_parser(prog).parse_args(argv)
1050 _dbg("OC_CHAT.MAIN", func, f"subcommand {args.command}", 2)
1051 if args.command == "rule" and not args.from_inbox and not args.answer:
1052 print("rule needs --answer, or --from-inbox", file=sys.stderr)
1053 return EXIT_REFUSED
1054 try:
1055 log = _build_log(args, log_factory)
1056 return _COMMANDS[args.command](args, log)
1057 except CorruptHubLogError as exc:
1058 # The BACKEND refused: a corrupt log, a missing registry, a
1059 # storage constraint. A different backend would not have refused,
1060 # so it gets its own exit code.
1061 print(f"backend refused: {exc}", file=sys.stderr)
1062 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: {exc}", 1, override=True)
1063 return EXIT_BACKEND
1064 except HubError as exc:
1065 print(f"refused: {exc}", file=sys.stderr)
1066 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {exc}", 3)
1067 return EXIT_REFUSED
1068 except QueueError as exc:
1069 print(f"backend refused: {exc}", file=sys.stderr)
1070 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: {exc}", 1, override=True)
1071 return EXIT_BACKEND
1072 except (OSError, ValueError) as exc:
1073 print(f"backend refused: {exc}", file=sys.stderr)
1074 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: {exc}", 1, override=True)
1075 return EXIT_BACKEND
1076
1077
1078if __name__ == "__main__":
1079 sys.exit(main())
None __init__(self, *, Any log, Any|None queue, Path|None comms_root)
argparse.ArgumentParser build_parser(str prog=DEFAULT_PROG)
int _cmd_history(argparse.Namespace args, Any log)
None _listing_flags(argparse.ArgumentParser parser)
str _source_label(argparse.Namespace args)
int _cmd_asks(argparse.Namespace args, Any log)
str format_fleet_report(list[Any] instances, tuple[HubEntry,...] entries)
None _advance(Any log, str address, int seq)
tuple[str, Provenance, str] _reply_from_inbox(argparse.Namespace args)
str _age(str at, *, datetime|None now=None)
Any hooks_for_dispatch(*, Path queue_dir, Path|None comms_root=None)
Any default_hooks(*, Any log, Any|None queue=None, Path|None comms_root=None)
int _cmd_ingest(argparse.Namespace args, Any log)
Hub _build_hub(argparse.Namespace args, Any log)
int _cmd_send(argparse.Namespace args, Any log)
Any _build_log(argparse.Namespace args, "Callable[[argparse.Namespace], Any] | None" log_factory=None)
Provenance _actor_provenance()
str format_entries(tuple[HubEntry,...] entries, *, str empty)
int _cmd_rule(argparse.Namespace args, Any log)
int _cmd_inbox(argparse.Namespace args, Any log)
Path|None _comms_root(argparse.Namespace args)
int _cmd_fleet(argparse.Namespace args, Any log)
int _ingest(argparse.Namespace args, Any log)
int _cmd_relay_check(argparse.Namespace args, Any log)
str _describe(HubEntry entry)
int _cmd_ask(argparse.Namespace args, Any log)
int main(list[str]|None argv=None, *, str prog=DEFAULT_PROG, "Callable[[argparse.Namespace], Any] | None" log_factory=None)
str entries_as_json(tuple[HubEntry,...] entries, *, str command, str source)
str format_cards(tuple[HubEntry,...] entries, *, str title, str empty, str source, bool full=False, datetime|None now=None)
None _consume_inbound(argparse.Namespace args, str message_id, str decision)
None _deliver(Hub hub, Any message)
tuple[HubEntry,...] _unread(Any log, str address)
list[str] _card_body(Any message, *, bool full)
None _emit(argparse.Namespace args, tuple[HubEntry,...] entries, *, str command, str title, str empty)