oc_chat
Loading...
Searching...
No Matches
round.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/cli/round.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_round_close_e2e.py"
6"""
7Registry-native round-close commands.
8
9Purpose
10-------
11Expose the seven-phase round-close orchestration beneath
12``python -m oc_chat.registry round``: close, resume, abort, and status.
13
14Responsibilities
15----------------
16- Parse round-close command arguments and plan specs.
17- Resolve the registry, orchestrator actor, and the git seam.
18- Drive run_round_close / resume_round / run_abort and render results.
19- Render the versioned escalation document when a job stops.
20
21Diagnostics
22-----------
23Domain: OC_CHAT.MAIN
24Levels:
25 L2 - lifecycle events
26 L3 - semantic details
27 L4 - deep tracing
28
29Contracts
30---------
31- ``status`` is read-only; it never mutates the registry or git.
32- Interactive prompting is the default; ``--non-interactive`` disables it.
33
34Dependencies
35------------
36- oc_diagnostics (structured debug logging via _dbg)
37- oc_chat.registry.backends.sqlite (SQLite backend)
38- oc_chat.registry.discovery (registry path discovery)
39- oc_chat.registry (Registry facade carried in the round context)
40- oc_chat.registry.errors (PreflightFailedError reported by the resume command)
41- oc_chat.registry.round.executors (round context, plan specs, test and lint results and run_abort)
42- oc_chat.registry.round.git_shim (GitShim git seam over the workspace repository)
43- oc_chat.registry.round.resume (resume_round for the resume command)
44- oc_chat.registry.round.state_machine (run_round_close, step definitions and job ids)
45- pytest (not imported; python -m pytest subprocess run by the default round-close test runner)
46- oct_lint (not imported; python -m oct_lint subprocess run by the default round-close lint runner)
47"""
48from __future__ import annotations
49
50import argparse
51import json
52from pathlib import Path
53import subprocess
54import sys
55
56from oc_diagnostics import _dbg
57
58from oc_chat.registry import Registry
59from oc_chat.registry.backends.sqlite import SQLiteBackend
60from oc_chat.registry.discovery import discover_registry_location
61from oc_chat.registry.errors import PreflightFailedError
63 LintRunResult,
64 PlanSpec,
65 RoundContext,
66 TestRunResult,
67 run_abort,
68)
69from oc_chat.registry.round.git_shim import GitShim
71 RoundCloseResult,
72 StepDef,
73 new_job_id,
74 run_round_close,
75)
76from oc_chat.registry.round.resume import resume_round
77
78
79_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
80
81
82def add_round_subcommands(round_parser: argparse.ArgumentParser) -> None:
83 """Attach registry-native round-close subcommands."""
84
85 round_parser.set_defaults(handler=_cmd_round_group, round_parser=round_parser)
86 subcommands = round_parser.add_subparsers(dest="round_command")
87
88 close = subcommands.add_parser("close", help="Drive a fresh round-close")
89 _add_common(close)
90 close.add_argument("round_no", type=int)
91 close.add_argument(
92 "--plan",
93 action="append",
94 default=[],
95 metavar="PLAN_ID:BRANCH:WORKTREE[:ORDER]",
96 help="A plan to merge; repeatable. Order defaults to argument order.",
97 )
98 close.add_argument("--staging-branch", help="Override the staging branch name.")
99 close.add_argument("--base-ref", default="main")
100 close.add_argument("--main-branch", default="main")
101 close.add_argument("--app-version")
102 close.add_argument("--roadmap-dir", help="ROADMAP artifact directory.")
103 close.add_argument(
104 "--allow-untracked-roadmap-edits",
105 action="store_true",
106 help="Proceed through cleanup after recording a ROADMAP audit override.",
107 )
108 close.add_argument(
109 "--non-interactive",
110 action="store_true",
111 help="Do not prompt at phase boundaries (default is interactive).",
112 )
113 close.set_defaults(handler=_cmd_close)
114
115 resume = subcommands.add_parser("resume", help="Resume a crashed round-close")
116 _add_common(resume)
117 resume.add_argument("round_no", type=int)
118 resume.add_argument("--plan", action="append", default=[])
119 resume.add_argument("--staging-branch")
120 resume.add_argument("--base-ref", default="main")
121 resume.add_argument("--main-branch", default="main")
122 resume.add_argument("--roadmap-dir", help="ROADMAP artifact directory.")
123 resume.add_argument(
124 "--allow-untracked-roadmap-edits",
125 action="store_true",
126 help="Proceed through cleanup after recording a ROADMAP audit override.",
127 )
128 resume.add_argument("--non-interactive", action="store_true")
129 resume.set_defaults(handler=_cmd_resume)
130
131 abort = subcommands.add_parser("abort", help="Abort a round and reset its plans")
132 _add_common(abort)
133 abort.add_argument("round_no", type=int)
134 abort.add_argument("--plan", action="append", default=[])
135 abort.add_argument("--staging-branch")
136 abort.add_argument("--base-ref", default="main")
137 abort.add_argument("--reason", required=True)
138 abort.set_defaults(handler=_cmd_abort)
139
140 status = subcommands.add_parser("status", help="Show round and job status")
141 status.add_argument("round_no", type=int)
142 status.add_argument("--registry", help="Registry SQLite path.")
143 status.add_argument("--json", action="store_true")
144 status.set_defaults(handler=_cmd_status)
145
146
147def _cmd_round_group(args: argparse.Namespace) -> int:
148 args.round_parser.print_help()
149 return 1
150
151
152def _interactive_prompter(step: StepDef, _ctx: RoundContext) -> str:
153 """Prompt the operator at a phase boundary; default is to continue."""
154
155 reply = input(f"[round] proceed with phase {step.step_name}? [Y/abort] ").strip()
156 return "abort" if reply.lower() in {"abort", "a", "n", "no"} else "continue"
157
158
159def _cmd_close(args: argparse.Namespace) -> int:
160 ctx = _build_context(args)
161 prompter = None if args.non_interactive else _interactive_prompter
162 result = run_round_close(
163 ctx,
164 interactive=not args.non_interactive,
165 prompter=prompter,
166 )
167 return _render_result(result)
168
169
170def _cmd_resume(args: argparse.Namespace) -> int:
171 ctx = _build_context(args)
172 prompter = None if args.non_interactive else _interactive_prompter
173 try:
174 result = resume_round(
175 ctx,
176 interactive=not args.non_interactive,
177 prompter=prompter,
178 )
179 except PreflightFailedError as exc:
180 print(f"resume preflight failed: {'; '.join(exc.reasons)}", file=sys.stderr)
181 return 1
182 return _render_result(result)
183
184
185def _cmd_abort(args: argparse.Namespace) -> int:
186 ctx = _build_context(args)
187 outcome = run_abort(ctx, reason=args.reason)
188 print(f"aborted round {ctx.round_no}: {outcome.result_summary}")
189 return 0
190
191
192def _cmd_status(args: argparse.Namespace) -> int:
193 registry, backend = _registry(args.registry)
194 round_row = backend.get_round(args.round_no)
195 if round_row is None:
196 print(f"round not found: {args.round_no}")
197 return 1
198 job = backend.latest_round_job(args.round_no)
199 steps = backend.list_round_job_steps(str(job["job_id"])) if job else []
200 if args.json:
201 payload = {"round": round_row, "job": job, "steps": steps}
202 print(json.dumps(payload, indent=2, sort_keys=True, default=str))
203 return 0
204 print(f"round {args.round_no}: {round_row['status']} / {round_row['cleanup_status']}")
205 if job is not None:
206 print(f"job {job['job_id']}: {job['status']} @ {job['current_step'] or '-'}")
207 for step in steps:
208 print(f" {step['step_no']:>2} {step['step_name']:<24} {step['status']}")
209 return 0
210
211
212def _build_context(args: argparse.Namespace) -> RoundContext:
213 registry, backend = _registry(args.registry)
214 actor_slot = _resolve_orchestrator(backend, getattr(args, "actor_slot", None))
215 actor_type = _actor_type_for_slot(backend, actor_slot)
216 location = discover_registry_location(registry_path=args.registry)
217 repo_dir = Path(location.workspace_root)
218 git = GitShim(repo_dir)
219 staging = args.staging_branch or f"round/{args.round_no}/staging"
220 specs = _parse_plan_specs(args.plan)
221 roadmap_dir = getattr(args, "roadmap_dir", None)
222 if roadmap_dir is None and hasattr(args, "allow_untracked_roadmap_edits"):
223 roadmap_dir = str(repo_dir / "oc_chat" / "roadmap")
224 return RoundContext(
225 registry=registry,
226 git=git,
227 round_no=args.round_no,
228 job_id=new_job_id(),
229 actor_slot=actor_slot,
230 staging_branch=staging,
231 plan_specs=specs,
232 actor_type=actor_type,
233 main_branch=getattr(args, "main_branch", "main"),
234 base_ref=getattr(args, "base_ref", "main"),
235 test_runner=_default_test_runner(repo_dir),
236 lint_runner=_default_lint_runner(repo_dir),
237 roadmap_bumper=_default_roadmap_bumper(actor_slot, actor_type),
238 roadmap_dir=roadmap_dir,
239 allow_untracked_roadmap_edits=bool(
240 getattr(args, "allow_untracked_roadmap_edits", False)
241 ),
242 app_version=getattr(args, "app_version", None),
243 )
244
245
246def _parse_plan_specs(raw_specs: list[str]) -> list[PlanSpec]:
247 specs: list[PlanSpec] = []
248 for index, raw in enumerate(raw_specs, start=1):
249 parts = raw.split(":")
250 if len(parts) < 3:
251 raise ValueError(
252 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI help and SQL field names are not secrets"
253 f"--plan must be PLAN_ID:BRANCH:WORKTREE[:ORDER], got {raw!r}"
254 )
255 order = int(parts[3]) if len(parts) >= 4 and parts[3] else index
256 specs.append(
257 PlanSpec(
258 plan_id=parts[0],
259 branch=parts[1],
260 worktree=parts[2],
261 merge_order=order,
262 )
263 )
264 return specs
265
266
267def _default_test_runner(repo_dir: Path):
268 def _run(ctx: RoundContext) -> TestRunResult:
269 clock = ctx.registry.clock
270 started_ns = clock.now_monotonic_ns()
271 completed = subprocess.run(
272 [sys.executable, "-m", "pytest", "-q"],
273 cwd=str(repo_dir),
274 capture_output=True,
275 text=True,
276 check=False,
277 )
278 duration = (clock.now_monotonic_ns() - started_ns) / 1_000_000_000
279 return TestRunResult(
280 passed=completed.returncode == 0,
281 duration_seconds=duration,
282 )
283
284 return _run
285
286
287def _default_lint_runner(repo_dir: Path):
288 def _run(_ctx: RoundContext) -> LintRunResult:
289 completed = subprocess.run(
290 [sys.executable, "-m", "oct_lint", "--profile", "strict"],
291 cwd=str(repo_dir),
292 capture_output=True,
293 text=True,
294 check=False,
295 )
296 return LintRunResult(passed=completed.returncode == 0)
297
298 return _run
299
300
301def _default_roadmap_bumper(actor_slot: str, actor_type: str):
302 func = "_default_roadmap_bumper"
303 _dbg("OC_CHAT.MAIN", func, "roadmap bump deferred to operator", 2)
304
305 def _bump(_ctx: RoundContext) -> str | None:
306 # Reuse the M11.6 roadmap-version bump in-process (ADR: no separate
307 # round.bump_roadmap operation). Wired by the operator at call time.
308 return None
309
310 return _bump
311
312
313def _render_result(result: RoundCloseResult) -> int:
314 if result.escalation is not None:
315 print(json.dumps(result.escalation.to_dict(), indent=2, sort_keys=True))
316 return 2
317 print(f"round-close {result.status}: {len(result.completed_steps)} steps")
318 return 0 if result.status == "done" else 1
319
320
321def _resolve_orchestrator(backend: SQLiteBackend, actor_slot: str | None) -> str:
322 if actor_slot:
323 return actor_slot
324 with backend.connect() as conn:
325 row = conn.execute(
326 "SELECT orchestrator_slot FROM orchestration_state WHERE id = 1"
327 ).fetchone()
328 if row is not None and row["orchestrator_slot"]:
329 return str(row["orchestrator_slot"])
330 operator_slot = backend.active_operator_slot()
331 if operator_slot is None:
332 raise ValueError("no active orchestrator or operator slot")
333 return operator_slot
334
335
336def _actor_type_for_slot(backend: SQLiteBackend, slot: str) -> str:
337 slot_kind = backend.actor_slot_kind(slot)
338 return {
339 "operator": "human_operator",
340 "orchestrator": "orchestrator_agent",
341 "worker": "worker_agent",
342 }[slot_kind]
343
344
345def _registry(registry_path: str | None) -> tuple[Registry, SQLiteBackend]:
346 location = discover_registry_location(registry_path=registry_path)
347 backend = SQLiteBackend(location.registry_path)
348 return Registry(backend), backend
349
350
351def _add_common(parser: argparse.ArgumentParser) -> None:
352 parser.add_argument("--registry", help="Registry SQLite path.")
353 parser.add_argument(
354 "--actor-slot",
355 help="Orchestrator slot. Defaults to the current orchestrator.",
356 )
int _cmd_resume(argparse.Namespace args)
Definition round.py:170
_default_roadmap_bumper(str actor_slot, str actor_type)
Definition round.py:301
str _actor_type_for_slot(SQLiteBackend backend, str slot)
Definition round.py:336
list[PlanSpec] _parse_plan_specs(list[str] raw_specs)
Definition round.py:246
int _render_result(RoundCloseResult result)
Definition round.py:313
RoundContext _build_context(argparse.Namespace args)
Definition round.py:212
str _interactive_prompter(StepDef step, RoundContext _ctx)
Definition round.py:152
int _cmd_abort(argparse.Namespace args)
Definition round.py:185
None add_round_subcommands(argparse.ArgumentParser round_parser)
Definition round.py:82
str _resolve_orchestrator(SQLiteBackend backend, str|None actor_slot)
Definition round.py:321
int _cmd_close(argparse.Namespace args)
Definition round.py:159
_default_test_runner(Path repo_dir)
Definition round.py:267
int _cmd_status(argparse.Namespace args)
Definition round.py:192
_default_lint_runner(Path repo_dir)
Definition round.py:287
tuple[Registry, SQLiteBackend] _registry(str|None registry_path)
Definition round.py:345
int _cmd_round_group(argparse.Namespace args)
Definition round.py:147
None _add_common(argparse.ArgumentParser parser)
Definition round.py:351