7Fail-closed, one-shot plan dispatch orchestration.
11Plan 221 P2/P3 (design §4.1). Every step of dispatching a plan either
12goes through this module — which fills it correctly — or refuses loudly
13with the reason named. It resolves the plan by number, validates its
14``requires:`` block, creates branch and worktree idempotently, allocates
15the ``agent-NNN`` identity, renders the launch file from the ONE
16committed template, and refuses the WHOLE dispatch if any template field
17would render unfilled. Hand-authoring a launch file is a workflow
18violation from the moment this landed.
22- :class:`DispatchRequest` / :class:`DispatchResult` — the operator's
23 inputs and the committed outputs of one attempt.
24- :class:`DispatchHooks` / :data:`NO_HOOKS` — the plan-259 seam: one
25 announcement after a dispatch commits, and an empty default.
26- :class:`DispatchError` — the named refusal at the dispatch boundary.
27- The refusal matrix (W3): missing/incomplete ``requires:``, plan absent
28 from master, an open instance without ``--again``.
29- All-or-nothing rendering (W4): no instance allocated and no launch
30 file written when anything refuses.
36 L2 — lifecycle events (dispatch attempted, refused, completed).
37 L3 — semantic details (plan resolution, template rendering).
42- BASE_SHA is the point the branch actually diverges from master,
43 recorded in the launch file and never re-derived: master's SHA for a
44 branch created by this dispatch, and the merge-base of master and the
45 branch tip for a pre-existing one (plan 223 P4). The two values
46 coincide at creation, so a fresh dispatch is unchanged.
47- Refusals raise :class:`DispatchError` BEFORE any state mutates; the
48 registry allocation and the launch-file write share one transaction
49 (via :meth:`AgentInstanceRegistry.dispatch_allocation`).
50- Git operations run against the caller's ``repo_root`` only.
51- The plan-259 hook is told AFTER everything has committed and its
52 failure is reported on stderr, never raised: by then the worktree
53 exists, and unmaking a real worktree because a log was unreachable
54 would be the worse failure.
58- oc_diagnostics (structured debug logging via _dbg)
59- oc_orchestrator.agent_instances (the runtime instance registry)
60- yaml (plan frontmatter parsing; already a project dependency)
61- oc_orchestrator.worktree_env (the git anchors, the per-worktree venv prepare and the dispatch timing ledger)
64from __future__
import annotations
66from dataclasses
import dataclass, field
67from pathlib
import Path
73from typing
import Any, Protocol, runtime_checkable
77from oc_diagnostics
import _dbg
81 AgentInstanceRegistry,
89 ensure_branch_and_worktree,
94_dbg(
"OC_CHAT.MAIN",
"module",
"dispatch loaded", 4)
102_TEMPLATES_DIR = Path(__file__).resolve().parent /
"templates"
103DEFAULT_LAUNCH_TEMPLATE = _TEMPLATES_DIR /
"launch.md"
106SHARED_VENV_LAUNCH_TEMPLATE = _TEMPLATES_DIR /
"launch_shared_venv.md"
107_REQUIRED_REQUIRES = (
"tier",
"repo",
"thinking",
"session")
108_VALID_THINKING = frozenset({
"off",
"low",
"medium",
"high",
"max"})
109_VALID_SESSION = frozenset({
"fresh",
"continue",
"either"})
110_LEFTOVER_TOKEN_RE = re.compile(
r"\{[^{}\n]+\}|<[^<>\n]+>")
111_PLAN_NUMBER_RE = re.compile(
r"^[0-9]{3}$")
115 """A named refusal at the plan-dispatch boundary."""
130 """What a listener is told about one completed dispatch."""
133 """A plan was dispatched: the instance exists and the launch is written."""
137 """The default listener: it does nothing, and that is the whole point."""
140 """Ignore the dispatch. A caller that wants it recorded injects hooks."""
148@dataclass(frozen=True)
150 """All operator-controlled inputs to one dispatch attempt."""
160 template_path: Path |
None =
None
161 again_note: str |
None =
None
162 shared_venv: bool =
False
163 uv_executable: str =
"uv"
164 uv_cache_dir: Path |
None =
None
167 hooks: DispatchHooks = field(default=NO_HOOKS)
170@dataclass(frozen=True)
172 """The committed outputs from one successful dispatch."""
174 instance: AgentInstance
183 prepare: PrepareResult |
None =
None
186@dataclass(frozen=True)
197def _run_git(repo_root: Path, *args: str, check: bool =
True) -> subprocess.CompletedProcess[str]:
198 result = subprocess.run(
205 if check
and result.returncode != 0:
206 detail = (result.stderr
or result.stdout).strip()
207 raise DispatchError(f
"git {' '.join(args)} failed: {detail}")
212 if not _PLAN_NUMBER_RE.fullmatch(plan_number):
214 f
"invalid plan number {plan_number!r}; expected exactly three digits"
216 matches = sorted(Path(plans_dir).glob(f
"{plan_number}-*.md"))
219 f
"no active plan file resolves from plan number {plan_number}"
221 if len(matches) != 1:
222 names =
", ".join(path.name
for path
in matches)
224 f
"plan number {plan_number} is ambiguous: {names}"
226 return matches[0].resolve()
230 text = path.read_text(encoding=
"utf-8")
231 lines = text.splitlines(keepends=
True)
232 if not lines
or lines[0].rstrip(
"\r\n") !=
"---":
233 raise DispatchError(f
"plan {path.name} has no YAML frontmatter")
235 (index
for index, line
in enumerate(lines[1:], start=1)
if line.rstrip(
"\r\n") ==
"---"),
239 raise DispatchError(f
"plan {path.name} has unclosed YAML frontmatter")
241 loaded = yaml.safe_load(
"".join(lines[1:end]))
242 except yaml.YAMLError
as exc:
243 raise DispatchError(f
"plan {path.name} frontmatter is invalid YAML: {exc}")
from exc
244 if not isinstance(loaded, dict):
245 raise DispatchError(f
"plan {path.name} frontmatter must be a mapping")
249def _nonblank(data: dict[str, Any], field: str, *, context: str) -> str:
250 value = str(data.get(field)
or "").strip()
252 raise DispatchError(f
"{context} is missing required field {field!r}")
256def _load_plan(path: Path, plan_number: str, thinking_override: str |
None) -> _PlanDispatchData:
258 plan_id =
_nonblank(data,
"plan_id", context=f
"plan {path.name}")
259 if not plan_id.startswith(f
"{plan_number}-"):
261 f
"plan number {plan_number} does not match frontmatter plan_id {plan_id!r}"
263 branch =
_nonblank(data,
"branch", context=f
"plan {path.name}")
264 worktree = Path(
_nonblank(data,
"worktree", context=f
"plan {path.name}")).resolve()
265 requires = data.get(
"requires")
266 if not isinstance(requires, dict):
268 f
"plan {plan_id!r} requires: is missing or is not a mapping"
272 for field
in _REQUIRED_REQUIRES
273 if not str(requires.get(field)
or "").strip()
277 f
"plan {plan_id!r} has incomplete requires:; missing "
280 tier = str(requires[
"tier"]).strip()
281 thinking = (thinking_override
or str(requires[
"thinking"])).strip().lower()
282 session = str(requires[
"session"]).strip().lower()
283 if thinking
not in _VALID_THINKING:
285 f
"plan {plan_id!r} has invalid thinking requirement {thinking!r}"
287 if session
not in _VALID_SESSION:
289 f
"plan {plan_id!r} has invalid session requirement {session!r}"
302@dataclass(frozen=True)
304 """A plan's declared git anchors, read straight from its frontmatter."""
312 """Resolve one plan number to the branch and worktree it declares.
314 Plan 241 P1/P5. ``prepare-worktree`` and ``teardown-worktree`` act on
315 exactly the anchors ``dispatch`` would use, from the same parser, so the
316 three commands can never disagree about which tree they mean.
318 func =
"resolve_plan_anchors"
320 _dbg(
"OC_CHAT.MAIN", func, f
"{plan.plan_id} -> {plan.branch}", 3)
322 plan_id=plan.plan_id, branch=plan.branch, worktree=plan.worktree
328 relative = plan_path.relative_to(repo_root).as_posix()
329 except ValueError
as exc:
331 f
"plan file {plan_path} is outside repository {repo_root}"
337 f
"master:{relative}",
340 if probe.returncode != 0:
342 f
"plan {relative} is not committed on master"
363 if unstaged.returncode != 0
or staged.returncode != 0:
365 f
"plan {relative} differs from its committed master version"
367 return _run_git(repo_root,
"rev-parse",
"master").stdout.strip()
375 f
"refs/heads/{branch}",
378 return probe.returncode == 0
381def _base_sha(repo_root: Path, branch: str, master_sha: str) -> str:
382 """Return the SHA the worker's BASE diff should be measured against.
384 Plan 223 P4 (from the 225 gate finding). Recording master's CURRENT SHA
385 for a branch that was cut earlier is a lie the moment master moves: the
386 2026-09-01 dispatch rendered ``344fe8ce`` for a branch cut at
387 ``b7962f5f``, so the worker's "diff against BASE" silently spanned every
388 commit master had gained in between. The truthful anchor for an existing
389 branch is where it diverged — the merge-base.
391 A branch this dispatch is about to CREATE has no divergence yet, so
392 master's SHA is both values at once and the fresh path is unchanged.
397 _dbg(
"OC_CHAT.MAIN", func, f
"{branch} is new; base = master {master_sha[:8]}", 3)
399 probe =
_run_git(repo_root,
"merge-base",
"master", branch, check=
False)
400 merge_base = probe.stdout.strip()
401 if probe.returncode != 0
or not merge_base:
402 detail = (probe.stderr
or probe.stdout).strip()
404 f
"branch {branch!r} has no merge-base with master; "
405 f
"refusing to record a BASE_SHA that names no shared history: {detail}"
410 f
"{branch} exists; base = merge-base {merge_base[:8]} (master {master_sha[:8]})",
417 names: set[str] = set()
419 parts = string.Formatter().parse(template)
420 for _literal, field_name, format_spec, conversion
in parts:
421 if field_name
is None:
423 if not field_name
or not field_name.isidentifier():
425 f
"launch template contains invalid field {field_name!r}"
427 if format_spec
or conversion:
429 f
"launch template field {field_name!r} may not use formatting or conversion"
431 names.add(field_name)
432 except ValueError
as exc:
433 raise DispatchError(f
"launch template syntax is invalid: {exc}")
from exc
438 """Render the single launch template or refuse any unresolved field."""
441 template = Path(template_path).read_text(encoding=
"utf-8")
442 except OSError
as exc:
443 raise DispatchError(f
"cannot read launch template {template_path}: {exc}")
from exc
445 missing = sorted(name
for name
in required
if not str(fields.get(name)
or "").strip())
448 "launch template has unfillable field(s): " +
", ".join(missing)
450 rendered = template.format_map(fields)
451 leftover = _LEFTOVER_TOKEN_RE.search(rendered)
454 f
"rendered launch contains placeholder token {leftover.group(0)!r}"
460 request: DispatchRequest, plan: _PlanDispatchData, *, use_worktree_venv: bool
462 """Create the git anchors and, by default, the worktree's own venv.
464 Plan 241 P1/P4. ``--shared-venv`` (``use_worktree_venv=False``) keeps
465 the legacy behaviour — anchors only, the shared main venv in the brief.
466 Otherwise the anchors and the per-worktree environment are made in one
467 step and every refusal inside it aborts the whole dispatch: a silent
468 fallback to the shared venv would re-open the F17 shadow.
470 func =
"_prepare_worktree_env"
472 if not use_worktree_venv:
473 ensure_branch_and_worktree(
474 request.repo_root.resolve(), plan.branch, plan.worktree
477 worktree=plan.worktree,
485 already_prepared=
False,
487 skipped_reason=
"--shared-venv: the legacy shared-venv brief was requested",
490 result = prepare_worktree(
492 repo_root=request.repo_root.resolve(),
494 worktree=plan.worktree,
495 uv_executable=request.uv_executable,
496 uv_cache_dir=request.uv_cache_dir,
505 except WorktreeEnvError
as exc:
507 if result.skipped_reason
is not None:
514 f
"prepare skipped after the worktree-venv brief was rendered "
515 f
"({result.skipped_reason}); refusing to hand a worker a brief "
516 f
"naming an interpreter that was never built"
518 _dbg(
"OC_CHAT.MAIN", func, f
"env ready for {plan.plan_id}", 3)
523 path.parent.mkdir(parents=
True, exist_ok=
True)
524 with tempfile.NamedTemporaryFile(
533 tmp_path = Path(tmp.name)
534 tmp_path.replace(path)
538 request: DispatchRequest,
539 plan: _PlanDispatchData,
544 use_worktree_venv: bool,
546 repo_root = request.repo_root.resolve()
548 plan_path = plan.path.relative_to(repo_root).as_posix()
550 plan_path = str(plan.path)
553 venv = plan.worktree /
".venv" if use_worktree_venv
else repo_root /
".venv"
554 scripts = venv_scripts_dir(venv)
556 "agent_id": agent_id,
557 "base_sha": base_sha,
558 "branch": plan.branch,
559 "launch_path": str(launch_path),
560 "model_attested": request.model_attested.strip(),
561 "plan_id": plan.plan_id,
562 "plan_number": request.plan_number,
563 "plan_path": plan_path,
564 "registry_path": str(request.registry_path.resolve()),
565 "session": plan.session,
566 "thinking": plan.thinking,
567 "venv_oct": str(scripts /
"oct.exe"),
568 "venv_python": str(scripts /
"python.exe"),
569 "worktree": str(plan.worktree),
573def dispatch(request: DispatchRequest) -> DispatchResult:
574 """Validate, prepare git state, allocate an instance, and render launch."""
578 with _engine_timing(request.repo_root,
"engine.dispatch", request.plan_number):
579 _dbg(
"OC_CHAT.MAIN", func, f
"dispatch attempt for plan {request.plan_number}", 2)
580 if not request.model_attested.strip():
581 raise DispatchError(
"--model must be an operator-attested non-empty value")
582 repo_root = request.repo_root.resolve()
583 if not (repo_root /
".git").exists():
584 raise DispatchError(f
"repository root is not a git worktree: {repo_root}")
585 plan_path =
_resolve_plan(request.plans_dir, request.plan_number)
586 plan =
_load_plan(plan_path, request.plan_number, request.thinking)
590 base_sha =
_base_sha(repo_root, plan.branch, master_sha)
591 _dbg(
"OC_CHAT.MAIN", func, f
"resolved {plan.plan_id} at base {base_sha[:8]}", 3)
592 launch_path = request.launch_dir.resolve() / f
"{request.plan_number}.md"
599 use_worktree_venv =
not request.shared_venv
and bool(
600 discover_packages(repo_root)
602 template_path = request.template_path
or (
603 DEFAULT_LAUNCH_TEMPLATE
if use_worktree_venv
else SHARED_VENV_LAUNCH_TEMPLATE
610 launch_path=launch_path,
611 agent_id=
"agent-000",
612 use_worktree_venv=use_worktree_venv,
616 request, plan, use_worktree_venv=use_worktree_venv
620 previous = launch_path.read_text(encoding=
"utf-8")
if launch_path.exists()
else None
621 launch_written =
False
623 with registry.dispatch_allocation(
624 plan_id=plan.plan_id,
625 model_attested=request.model_attested.strip(),
626 thinking=plan.thinking,
627 harness=request.harness,
628 again_note=request.again_note,
634 launch_path=launch_path,
635 agent_id=instance.agent_id,
636 use_worktree_venv=use_worktree_venv,
640 launch_written =
True
641 _dbg(
"OC_CHAT.MAIN", func,
642 f
"dispatched {instance.agent_id} for {plan.plan_id}", 2)
646 launch_path.unlink(missing_ok=
True)
652 plan_id=plan.plan_id,
653 launch_path=launch_path,
655 worktree=plan.worktree,
658 thinking=plan.thinking,
659 session=plan.session,
667 """Tell the hooks once, after the dispatch has committed. Never raise.
669 Plan 259 P6. By the time this runs the branch, the worktree, its venv,
670 the instance row and the launch file all exist; a listener that cannot
671 record the fact is a reason to say so, not a reason to unmake any of
672 that. Undoing a real worktree because a log was unreachable would be
673 the worse failure by a wide margin.
675 func =
"_announce_dispatched"
677 hooks.dispatched(result)
678 except Exception
as exc:
680 f
"warning: dispatch hook for {result.plan_id} failed "
681 f
"({type(exc).__name__}: {exc}); the dispatch stands and "
682 f
"{result.launch_path} is written",
685 _dbg(
"OC_CHAT.MAIN", func,
686 f
"EXTERNAL_ERROR: hook failed for {result.plan_id}: {exc}", 2)
688 _dbg(
"OC_CHAT.MAIN", func, f
"announced {result.instance.agent_id}", 3)
695_SESSION_EXPLANATIONS = {
696 "continue":
"an existing worker session may be reused; fresh also works",
697 "fresh":
"a NEW session is required; an existing one may not be reused",
698 "either":
"any session satisfies this plan",
703 result: DispatchResult,
708 """Build the operator-facing suggestion lines for a completed dispatch.
710 Plan 223 P4b. The suggestion line spells out what the session field
711 constrains, and a following line names the instances that are actually
712 open right now — the concrete reuse candidates, rather than leaving the
713 operator to remember which windows are still alive.
715 NOTE (scope): these are candidate INSTANCES, not sessions. Naming the
716 actual session to continue requires ``agents.sqlite.session_uuid`` to be
717 populated, which is plan 224's session-linkage territory and deliberately
718 out of scope here; until then the operator maps instance to window.
721 func =
"format_dispatch_suggestion"
722 session_note = _SESSION_EXPLANATIONS.get(result.session,
"")
723 session_text = f
"session={result.session}"
725 session_text += f
" - {session_note}"
727 *(result.prepare.report_lines()
if result.prepare
is not None else []),
728 f
"Suggested assignment: {model_attested} "
729 f
"(tier={result.tier}, thinking={result.thinking}, {session_text}).",
730 f
"Open instances: {_open_instance_summary(registry_path, result.instance.agent_id)}",
731 f
"Allocated {result.instance.agent_id} (state={result.instance.state}).",
732 f
"Read {result.launch_path} and execute end-to-end",
734 _dbg(
"OC_CHAT.MAIN", func, f
"suggestion rendered for {result.plan_id}", 3)
739 """Render open instances as ``agent-NNN (plan-id, state)``, or ``none``.
741 The instance this dispatch just allocated is excluded: it is the thing
742 being launched, not a candidate to reuse for it. A registry that cannot
743 be read reports that fact rather than claiming an empty pool, because
744 "none" and "unknown" are different answers to the operator's question.
747 func =
"_open_instance_summary"
750 except Exception
as exc:
751 _dbg(
"OC_CHAT.MAIN", func, f
"registry unreadable: {exc}", 3)
752 return f
"(registry unreadable: {exc})"
754 f
"{instance.agent_id} ({instance.plan_id or '-'}, {instance.state})"
755 for instance
in instances
756 if instance.agent_id != exclude_agent_id
758 return ", ".join(rendered)
if rendered
else "none"
762 "DEFAULT_LAUNCH_TEMPLATE",
764 "SHARED_VENV_LAUNCH_TEMPLATE",
770 "format_dispatch_suggestion",
771 "render_launch_template",
772 "resolve_plan_anchors",
None dispatched(self, "DispatchResult" result)
None dispatched(self, "DispatchResult" result)
bool _branch_exists(Path repo_root, str branch)
Path _resolve_plan(Path plans_dir, str plan_number)
dict[str, str] _launch_fields(DispatchRequest request, _PlanDispatchData plan, *, str base_sha, Path launch_path, str agent_id, bool use_worktree_venv)
str render_launch_template(Path template_path, dict[str, str] fields)
set[str] _template_field_names(str template)
PrepareResult _prepare_worktree_env(DispatchRequest request, _PlanDispatchData plan, *, bool use_worktree_venv)
PlanAnchors resolve_plan_anchors(Path plans_dir, str plan_number)
str _nonblank(dict[str, Any] data, str field, *, str context)
list[str] format_dispatch_suggestion(DispatchResult result, *, str model_attested, Path registry_path)
str _open_instance_summary(Path registry_path, str exclude_agent_id)
None _announce_dispatched(DispatchHooks hooks, DispatchResult result)
str _base_sha(Path repo_root, str branch, str master_sha)
_PlanDispatchData _load_plan(Path path, str plan_number, str|None thinking_override)
dict[str, Any] _frontmatter(Path path)
None _write_launch(Path path, str text)
str _require_committed_on_master(Path repo_root, Path plan_path)
subprocess.CompletedProcess[str] _run_git(Path repo_root, *str args, bool check=True)