oc_orchestrator
Loading...
Searching...
No Matches
dispatch.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/dispatch.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by test_221_dispatch.py"
6"""
7Fail-closed, one-shot plan dispatch orchestration.
8
9Purpose
10-------
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.
19
20Responsibilities
21----------------
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.
31
32Diagnostics
33-----------
34Domain: OC_CHAT.MAIN
35Levels:
36 L2 — lifecycle events (dispatch attempted, refused, completed).
37 L3 — semantic details (plan resolution, template rendering).
38 L4 — deep tracing.
39
40Contracts
41---------
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.
55
56Dependencies
57------------
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)
62"""
63
64from __future__ import annotations
65
66from dataclasses import dataclass, field
67from pathlib import Path
68import re
69import string
70import subprocess
71import sys
72import tempfile
73from typing import Any, Protocol, runtime_checkable
74
75import yaml
76
77from oc_diagnostics import _dbg
78
80 AgentInstance,
81 AgentInstanceRegistry,
82 HarnessKind,
83)
85 PrepareRequest,
86 PrepareResult,
87 WorktreeEnvError,
88 discover_packages,
89 ensure_branch_and_worktree,
90 prepare_worktree,
91 venv_scripts_dir,
92)
93
94_dbg("OC_CHAT.MAIN", "module", "dispatch loaded", 4)
95
96
97#: The launch briefs ship WITH this package (plan 270 P3): they are the
98#: data half of what a dispatch produces, so a consumer that installs the
99#: package gets a working default and overrides it per dispatch through
100#: ``DispatchRequest.template_path``. Their prose still names oc_chat's
101#: module paths, which is the shims' business and not this module's.
102_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
103DEFAULT_LAUNCH_TEMPLATE = _TEMPLATES_DIR / "launch.md"
104#: The pre-plan-241 brief, kept byte-identical so ``--shared-venv`` renders
105#: the legacy form exactly rather than a lookalike (plan 241 P4/W5).
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}$")
112
113
114class DispatchError(RuntimeError):
115 """A named refusal at the plan-dispatch boundary."""
116
117
118# ── The hub seam (plan 259 P6) ───────────────────────────────────────────
119#
120# The plan-257 idiom, verbatim: a Protocol, a ``NO_*`` default, and the
121# live default registered by the CALLER rather than by this layer. A
122# dispatch is a hub KIND -- the orchestrator's picture of the fleet is
123# the log and not a guess -- but this module announces that one thing
124# happened and knows nothing about who is listening, so ``core/hub.py``
125# never appears in the dispatch layer's own imports.
126
127
128@runtime_checkable
129class DispatchHooks(Protocol):
130 """What a listener is told about one completed dispatch."""
131
132 def dispatched(self, result: "DispatchResult") -> None:
133 """A plan was dispatched: the instance exists and the launch is written."""
134
135
137 """The default listener: it does nothing, and that is the whole point."""
138
139 def dispatched(self, result: "DispatchResult") -> None:
140 """Ignore the dispatch. A caller that wants it recorded injects hooks."""
141
142
143#: The default when a caller wants no announcement at all, so ``dispatch``
144#: behaves exactly as it did before plan 259.
145NO_HOOKS: DispatchHooks = _NoHooks()
146
147
148@dataclass(frozen=True)
150 """All operator-controlled inputs to one dispatch attempt."""
151
152 plan_number: str
153 plans_dir: Path
154 repo_root: Path
155 registry_path: Path
156 launch_dir: Path
157 model_attested: str
158 thinking: str | None
159 harness: HarnessKind
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
165 #: Plan 259 P6. Told once, after the dispatch has committed. The
166 #: default reaches nothing.
167 hooks: DispatchHooks = field(default=NO_HOOKS)
168
169
170@dataclass(frozen=True)
172 """The committed outputs from one successful dispatch."""
173
174 instance: AgentInstance
175 plan_id: str
176 launch_path: Path
177 branch: str
178 worktree: Path
179 base_sha: str
180 tier: str
181 thinking: str
182 session: str
183 prepare: PrepareResult | None = None
184
185
186@dataclass(frozen=True)
188 plan_id: str
189 branch: str
190 worktree: Path
191 tier: str
192 thinking: str
193 session: str
194 path: Path
195
196
197def _run_git(repo_root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
198 result = subprocess.run(
199 ["git", *args],
200 cwd=repo_root,
201 text=True,
202 capture_output=True,
203 check=False,
204 )
205 if check and result.returncode != 0:
206 detail = (result.stderr or result.stdout).strip()
207 raise DispatchError(f"git {' '.join(args)} failed: {detail}")
208 return result
209
210
211def _resolve_plan(plans_dir: Path, plan_number: str) -> Path:
212 if not _PLAN_NUMBER_RE.fullmatch(plan_number):
213 raise DispatchError(
214 f"invalid plan number {plan_number!r}; expected exactly three digits"
215 )
216 matches = sorted(Path(plans_dir).glob(f"{plan_number}-*.md"))
217 if not matches:
218 raise DispatchError(
219 f"no active plan file resolves from plan number {plan_number}"
220 )
221 if len(matches) != 1:
222 names = ", ".join(path.name for path in matches)
223 raise DispatchError(
224 f"plan number {plan_number} is ambiguous: {names}"
225 )
226 return matches[0].resolve()
227
228
229def _frontmatter(path: Path) -> dict[str, Any]:
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")
234 end = next(
235 (index for index, line in enumerate(lines[1:], start=1) if line.rstrip("\r\n") == "---"),
236 None,
237 )
238 if end is None:
239 raise DispatchError(f"plan {path.name} has unclosed YAML frontmatter")
240 try:
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")
246 return loaded
247
248
249def _nonblank(data: dict[str, Any], field: str, *, context: str) -> str:
250 value = str(data.get(field) or "").strip()
251 if not value:
252 raise DispatchError(f"{context} is missing required field {field!r}")
253 return value
254
255
256def _load_plan(path: Path, plan_number: str, thinking_override: str | None) -> _PlanDispatchData:
257 data = _frontmatter(path)
258 plan_id = _nonblank(data, "plan_id", context=f"plan {path.name}")
259 if not plan_id.startswith(f"{plan_number}-"):
260 raise DispatchError(
261 f"plan number {plan_number} does not match frontmatter plan_id {plan_id!r}"
262 )
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):
267 raise DispatchError(
268 f"plan {plan_id!r} requires: is missing or is not a mapping"
269 )
270 missing = [
271 field
272 for field in _REQUIRED_REQUIRES
273 if not str(requires.get(field) or "").strip()
274 ]
275 if missing:
276 raise DispatchError(
277 f"plan {plan_id!r} has incomplete requires:; missing "
278 + ", ".join(missing)
279 )
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:
284 raise DispatchError(
285 f"plan {plan_id!r} has invalid thinking requirement {thinking!r}"
286 )
287 if session not in _VALID_SESSION:
288 raise DispatchError(
289 f"plan {plan_id!r} has invalid session requirement {session!r}"
290 )
291 return _PlanDispatchData(
292 plan_id=plan_id,
293 branch=branch,
294 worktree=worktree,
295 tier=tier,
296 thinking=thinking,
297 session=session,
298 path=path,
299 )
300
301
302@dataclass(frozen=True)
304 """A plan's declared git anchors, read straight from its frontmatter."""
305
306 plan_id: str
307 branch: str
308 worktree: Path
309
310
311def resolve_plan_anchors(plans_dir: Path, plan_number: str) -> PlanAnchors:
312 """Resolve one plan number to the branch and worktree it declares.
313
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.
317 """
318 func = "resolve_plan_anchors"
319 plan = _load_plan(_resolve_plan(plans_dir, plan_number), plan_number, None)
320 _dbg("OC_CHAT.MAIN", func, f"{plan.plan_id} -> {plan.branch}", 3)
321 return PlanAnchors(
322 plan_id=plan.plan_id, branch=plan.branch, worktree=plan.worktree
323 )
324
325
326def _require_committed_on_master(repo_root: Path, plan_path: Path) -> str:
327 try:
328 relative = plan_path.relative_to(repo_root).as_posix()
329 except ValueError as exc:
330 raise DispatchError(
331 f"plan file {plan_path} is outside repository {repo_root}"
332 ) from exc
333 probe = _run_git(
334 repo_root,
335 "cat-file",
336 "-e",
337 f"master:{relative}",
338 check=False,
339 )
340 if probe.returncode != 0:
341 raise DispatchError(
342 f"plan {relative} is not committed on master"
343 )
344 unstaged = _run_git(
345 repo_root,
346 "diff",
347 "--quiet",
348 "master",
349 "--",
350 relative,
351 check=False,
352 )
353 staged = _run_git(
354 repo_root,
355 "diff",
356 "--cached",
357 "--quiet",
358 "master",
359 "--",
360 relative,
361 check=False,
362 )
363 if unstaged.returncode != 0 or staged.returncode != 0:
364 raise DispatchError(
365 f"plan {relative} differs from its committed master version"
366 )
367 return _run_git(repo_root, "rev-parse", "master").stdout.strip()
368
369
370def _branch_exists(repo_root: Path, branch: str) -> bool:
371 probe = _run_git(
372 repo_root,
373 "show-ref",
374 "--verify",
375 f"refs/heads/{branch}",
376 check=False,
377 )
378 return probe.returncode == 0
379
380
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.
383
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.
390
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.
393 """
394
395 func = "_base_sha"
396 if not _branch_exists(repo_root, branch):
397 _dbg("OC_CHAT.MAIN", func, f"{branch} is new; base = master {master_sha[:8]}", 3)
398 return master_sha
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()
403 raise DispatchError(
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}"
406 )
407 _dbg(
408 "OC_CHAT.MAIN",
409 func,
410 f"{branch} exists; base = merge-base {merge_base[:8]} (master {master_sha[:8]})",
411 3,
412 )
413 return merge_base
414
415
416def _template_field_names(template: str) -> set[str]:
417 names: set[str] = set()
418 try:
419 parts = string.Formatter().parse(template)
420 for _literal, field_name, format_spec, conversion in parts:
421 if field_name is None:
422 continue
423 if not field_name or not field_name.isidentifier():
424 raise DispatchError(
425 f"launch template contains invalid field {field_name!r}"
426 )
427 if format_spec or conversion:
428 raise DispatchError(
429 f"launch template field {field_name!r} may not use formatting or conversion"
430 )
431 names.add(field_name)
432 except ValueError as exc:
433 raise DispatchError(f"launch template syntax is invalid: {exc}") from exc
434 return names
435
436
437def render_launch_template(template_path: Path, fields: dict[str, str]) -> str:
438 """Render the single launch template or refuse any unresolved field."""
439
440 try:
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
444 required = _template_field_names(template)
445 missing = sorted(name for name in required if not str(fields.get(name) or "").strip())
446 if missing:
447 raise DispatchError(
448 "launch template has unfillable field(s): " + ", ".join(missing)
449 )
450 rendered = template.format_map(fields)
451 leftover = _LEFTOVER_TOKEN_RE.search(rendered)
452 if leftover:
453 raise DispatchError(
454 f"rendered launch contains placeholder token {leftover.group(0)!r}"
455 )
456 return rendered
457
458
460 request: DispatchRequest, plan: _PlanDispatchData, *, use_worktree_venv: bool
461) -> PrepareResult:
462 """Create the git anchors and, by default, the worktree's own venv.
463
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.
469 """
470 func = "_prepare_worktree_env"
471 try:
472 if not use_worktree_venv:
473 ensure_branch_and_worktree(
474 request.repo_root.resolve(), plan.branch, plan.worktree
475 )
476 return PrepareResult(
477 worktree=plan.worktree,
478 branch=plan.branch,
479 venv=None,
480 python=None,
481 installed=(),
482 verified=(),
483 uv_cache_dir=None,
484 link_mode="n/a",
485 already_prepared=False,
486 repaired=(),
487 skipped_reason="--shared-venv: the legacy shared-venv brief was requested",
488 elapsed_seconds=0.0,
489 )
490 result = prepare_worktree(
492 repo_root=request.repo_root.resolve(),
493 branch=plan.branch,
494 worktree=plan.worktree,
495 uv_executable=request.uv_executable,
496 uv_cache_dir=request.uv_cache_dir,
497 # Plan 243 P4: every dispatched worktree is smoke-tested. The
498 # brief tells the worker to run plan_status at STEP-0, so
499 # dispatch runs it first: a brief handed out before that
500 # command is known to work is the hand-repair loop ENV-02
501 # exists to end.
502 smoke=True,
503 )
504 )
505 except WorktreeEnvError as exc:
506 raise DispatchError(str(exc)) from exc
507 if result.skipped_reason is not None:
508 # The brief has already been rendered naming <worktree>\.venv, because
509 # the repo root declared packages. A worktree that turns out to carry
510 # none would leave that brief pointing at an interpreter nothing
511 # built — the brief lying about the environment is the exact failure
512 # this plan exists to end, so it fails closed instead.
513 raise DispatchError(
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"
517 )
518 _dbg("OC_CHAT.MAIN", func, f"env ready for {plan.plan_id}", 3)
519 return result
520
521
522def _write_launch(path: Path, text: str) -> None:
523 path.parent.mkdir(parents=True, exist_ok=True)
524 with tempfile.NamedTemporaryFile(
525 mode="w",
526 encoding="utf-8",
527 dir=path.parent,
528 prefix=".launch_",
529 suffix=".tmp",
530 delete=False,
531 ) as tmp:
532 tmp.write(text)
533 tmp_path = Path(tmp.name)
534 tmp_path.replace(path)
535
536
538 request: DispatchRequest,
539 plan: _PlanDispatchData,
540 *,
541 base_sha: str,
542 launch_path: Path,
543 agent_id: str,
544 use_worktree_venv: bool,
545) -> dict[str, str]:
546 repo_root = request.repo_root.resolve()
547 try:
548 plan_path = plan.path.relative_to(repo_root).as_posix()
549 except ValueError:
550 plan_path = str(plan.path)
551 # Plan 241 P3: the brief names the interpreter whose editable installs
552 # point at THIS worktree, so the worker never has to juggle PYTHONPATH.
553 venv = plan.worktree / ".venv" if use_worktree_venv else repo_root / ".venv"
554 scripts = venv_scripts_dir(venv)
555 return {
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),
570 }
571
572
573def dispatch(request: DispatchRequest) -> DispatchResult:
574 """Validate, prepare git state, allocate an instance, and render launch."""
575
576 from oc_orchestrator.worktree_env import _engine_timing
577 func = "dispatch"
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)
587 master_sha = _require_committed_on_master(repo_root, plan.path)
588 # Derived BEFORE the branch is created so a fresh dispatch reads master
589 # rather than a merge-base against a branch this call is about to make.
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"
593
594 # Plan 241: the environment shape is decided BEFORE anything is rendered
595 # or created, so the preflight render exercises the template the real
596 # render will use. A tree carrying none of the declared packages (every
597 # dispatch fixture) has no editable shadow to close, so it keeps the
598 # legacy brief rather than being told about a venv it will not get.
599 use_worktree_venv = not request.shared_venv and bool(
600 discover_packages(repo_root)
601 )
602 template_path = request.template_path or (
603 DEFAULT_LAUNCH_TEMPLATE if use_worktree_venv else SHARED_VENV_LAUNCH_TEMPLATE
604 )
605
606 preflight_fields = _launch_fields(
607 request,
608 plan,
609 base_sha=base_sha,
610 launch_path=launch_path,
611 agent_id="agent-000",
612 use_worktree_venv=use_worktree_venv,
613 )
614 render_launch_template(template_path, preflight_fields)
615 prepared = _prepare_worktree_env(
616 request, plan, use_worktree_venv=use_worktree_venv
617 )
618
619 registry = AgentInstanceRegistry(request.registry_path)
620 previous = launch_path.read_text(encoding="utf-8") if launch_path.exists() else None
621 launch_written = False
622 try:
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,
629 ) as instance:
630 fields = _launch_fields(
631 request,
632 plan,
633 base_sha=base_sha,
634 launch_path=launch_path,
635 agent_id=instance.agent_id,
636 use_worktree_venv=use_worktree_venv,
637 )
638 rendered = render_launch_template(template_path, fields)
639 _write_launch(launch_path, rendered)
640 launch_written = True
641 _dbg("OC_CHAT.MAIN", func,
642 f"dispatched {instance.agent_id} for {plan.plan_id}", 2)
643 except Exception:
644 if launch_written:
645 if previous is None:
646 launch_path.unlink(missing_ok=True)
647 else:
648 _write_launch(launch_path, previous)
649 raise
650 result = DispatchResult(
651 instance=instance,
652 plan_id=plan.plan_id,
653 launch_path=launch_path,
654 branch=plan.branch,
655 worktree=plan.worktree,
656 base_sha=base_sha,
657 tier=plan.tier,
658 thinking=plan.thinking,
659 session=plan.session,
660 prepare=prepared,
661 )
662 _announce_dispatched(request.hooks, result)
663 return result
664
665
666def _announce_dispatched(hooks: DispatchHooks, result: DispatchResult) -> None:
667 """Tell the hooks once, after the dispatch has committed. Never raise.
668
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.
674 """
675 func = "_announce_dispatched"
676 try:
677 hooks.dispatched(result)
678 except Exception as exc: # noqa: BLE001 - EXTERNAL_ERROR (L2); the dispatch stands
679 print(
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",
683 file=sys.stderr,
684 )
685 _dbg("OC_CHAT.MAIN", func,
686 f"EXTERNAL_ERROR: hook failed for {result.plan_id}: {exc}", 2)
687 return
688 _dbg("OC_CHAT.MAIN", func, f"announced {result.instance.agent_id}", 3)
689
690
691#: What each ``requires.session`` value CONSTRAINS, in the operator's terms.
692#: Plan 223 P4b: ``session=continue`` read like it named a target session; it
693#: never did. It is the plan's requirement, and the operator picks a session
694#: that satisfies it.
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",
699}
700
701
703 result: DispatchResult,
704 *,
705 model_attested: str,
706 registry_path: Path,
707) -> list[str]:
708 """Build the operator-facing suggestion lines for a completed dispatch.
709
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.
714
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.
719 """
720
721 func = "format_dispatch_suggestion"
722 session_note = _SESSION_EXPLANATIONS.get(result.session, "")
723 session_text = f"session={result.session}"
724 if session_note:
725 session_text += f" - {session_note}"
726 lines = [
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",
733 ]
734 _dbg("OC_CHAT.MAIN", func, f"suggestion rendered for {result.plan_id}", 3)
735 return lines
736
737
738def _open_instance_summary(registry_path: Path, exclude_agent_id: str) -> str:
739 """Render open instances as ``agent-NNN (plan-id, state)``, or ``none``.
740
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.
745 """
746
747 func = "_open_instance_summary"
748 try:
749 instances = AgentInstanceRegistry(registry_path).list(active_only=True)
750 except Exception as exc: # noqa: BLE001 - a view must never fail a dispatch
751 _dbg("OC_CHAT.MAIN", func, f"registry unreadable: {exc}", 3)
752 return f"(registry unreadable: {exc})"
753 rendered = [
754 f"{instance.agent_id} ({instance.plan_id or '-'}, {instance.state})"
755 for instance in instances
756 if instance.agent_id != exclude_agent_id
757 ]
758 return ", ".join(rendered) if rendered else "none"
759
760
761__all__ = [
762 "DEFAULT_LAUNCH_TEMPLATE",
763 "NO_HOOKS",
764 "SHARED_VENV_LAUNCH_TEMPLATE",
765 "DispatchError",
766 "DispatchHooks",
767 "DispatchRequest",
768 "DispatchResult",
769 "dispatch",
770 "format_dispatch_suggestion",
771 "render_launch_template",
772 "resolve_plan_anchors",
773]
None dispatched(self, "DispatchResult" result)
Definition dispatch.py:139
None dispatched(self, "DispatchResult" result)
Definition dispatch.py:132
bool _branch_exists(Path repo_root, str branch)
Definition dispatch.py:370
Path _resolve_plan(Path plans_dir, str plan_number)
Definition dispatch.py:211
dict[str, str] _launch_fields(DispatchRequest request, _PlanDispatchData plan, *, str base_sha, Path launch_path, str agent_id, bool use_worktree_venv)
Definition dispatch.py:545
str render_launch_template(Path template_path, dict[str, str] fields)
Definition dispatch.py:437
set[str] _template_field_names(str template)
Definition dispatch.py:416
PrepareResult _prepare_worktree_env(DispatchRequest request, _PlanDispatchData plan, *, bool use_worktree_venv)
Definition dispatch.py:461
PlanAnchors resolve_plan_anchors(Path plans_dir, str plan_number)
Definition dispatch.py:311
str _nonblank(dict[str, Any] data, str field, *, str context)
Definition dispatch.py:249
list[str] format_dispatch_suggestion(DispatchResult result, *, str model_attested, Path registry_path)
Definition dispatch.py:707
str _open_instance_summary(Path registry_path, str exclude_agent_id)
Definition dispatch.py:738
None _announce_dispatched(DispatchHooks hooks, DispatchResult result)
Definition dispatch.py:666
str _base_sha(Path repo_root, str branch, str master_sha)
Definition dispatch.py:381
_PlanDispatchData _load_plan(Path path, str plan_number, str|None thinking_override)
Definition dispatch.py:256
dict[str, Any] _frontmatter(Path path)
Definition dispatch.py:229
None _write_launch(Path path, str text)
Definition dispatch.py:522
str _require_committed_on_master(Path repo_root, Path plan_path)
Definition dispatch.py:326
subprocess.CompletedProcess[str] _run_git(Path repo_root, *str args, bool check=True)
Definition dispatch.py:197