oc_chat
Loading...
Searching...
No Matches
migrate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/migrate.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_migration.py"
6"""
7Legacy queue/status migration into the M11 registry.
8
9Purpose
10-------
11Project the Markdown-era orchestration state into the SQLite registry
12without making the registry depend on those legacy files afterward.
13
14Responsibilities
15----------------
16- Parse the legacy agent queue, plan headers, status files, and git evidence.
17- Map legacy lifecycle names onto the ADR-00Z registry statuses.
18- Build a deterministic projection for dry-run, import, verify, and rollback.
19- Delegate all current-state SQL and operation-log writes to SQLiteBackend.
20
21Diagnostics
22-----------
23Domain: OC_CHAT.MAIN
24Levels:
25 L2 - lifecycle events
26 L3 - semantic details
27 L4 - deep tracing
28
29Contracts
30---------
31- No raw wall-clock imports are used here; timestamps come from the backend Clock.
32- Import uses one SQLite transaction for projected rows and audit rows.
33- Unknown legacy statuses fail the import before writing audit rows.
34
35Dependencies
36------------
37- oc_diagnostics (structured debug logging via _dbg)
38- oc_chat.registry._legacy_queue (read-only legacy queue parser; plan 223
39 moved it here when the queue machinery retired)
40- oc_chat.scripts.plan_status (legacy status file parser)
41- oc_chat.registry.backends.sqlite (SQL owner for migration writes)
42- oc_chat.registry (Registry facade passed to the import, verify and rollback entry points)
43- yaml (fallback parse of legacy status-file frontmatter)
44- git (not imported; run as a subprocess for worktree list, the first-parent log of main and rev-parse main, the legacy status-file and round evidence)
45"""
46from __future__ import annotations
47
48from dataclasses import dataclass
49import hashlib
50import json
51from pathlib import Path
52import re
53import subprocess
54from typing import Mapping, Sequence
55
56import yaml
57
58from oc_diagnostics import _dbg
59
60from oc_chat.registry import Registry
61from oc_chat.registry import _legacy_queue
62from oc_chat.registry.backends.sqlite import SQLiteBackend
63from oc_chat.scripts import plan_status
64
65
66_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
67
68# OCT-LINT: disable=no_hardcoded_secrets reason="Regexes and model-family literals are parser fixtures, not secrets"
69PLAN_ID_RE = re.compile(r"^(?P<number>\d{3})-(?P<slug>.+)$")
70LEGACY_PLAN_ID_RE = re.compile(r"\b\d{3}-[A-Za-z0-9][A-Za-z0-9_.-]*\b")
71ROUND_RE = re.compile(r"\bround[-\s]*(?P<round>\d+)\b", re.IGNORECASE)
72HEADER_PATTERNS = {
73 "branch": re.compile(
74 # OCT-LINT: disable=no_hardcoded_secrets reason="Regexes and model-family literals are parser fixtures, not secrets"
75 r"^\*\*Branch:\*\*\s*(?:`(?P<ticks>[^`\n]+)`|(?P<plain>\S+))",
76 re.IGNORECASE | re.MULTILINE,
77 ),
78 "worktree": re.compile(
79 # OCT-LINT: disable=no_hardcoded_secrets reason="Regexes and model-family literals are parser fixtures, not secrets"
80 r"^\*\*Worktree:\*\*\s*(?:`(?P<ticks>[^`\n]+)`|(?P<plain>\S+))",
81 re.IGNORECASE | re.MULTILINE,
82 ),
83 "plan_type": re.compile(
84 r"^\*\*Plan Type:\*\*\s*(?P<value>.+?)\s*$",
85 re.IGNORECASE | re.MULTILINE,
86 ),
87 "status": re.compile(
88 r"^\*\*Status:\*\*\s*(?P<value>.+?)\s*$",
89 re.IGNORECASE | re.MULTILINE,
90 ),
91 "suggested_model": re.compile(
92 r"^\*\*Suggested model:\*\*\s*(?P<value>.+?)\s*$",
93 re.IGNORECASE | re.MULTILINE,
94 ),
95}
96H1_RE = re.compile(r"^#\s+(?P<value>.+?)\s*$", re.MULTILINE)
97VALID_LEGACY_STATUSES = {
98 "Completed",
99 "In Progress",
100 "Blocked",
101 "Failed",
102 "Partial",
103 "Pending",
104}
105STATUS_NORMALISATIONS = {
106 "queued": "Pending",
107 "queue": "Pending",
108 "pending": "Pending",
109 "implementing": "In Progress",
110 "in progress": "In Progress",
111 "in-progress": "In Progress",
112 "completed": "Completed",
113 "partially completed": "Partial",
114 "partial": "Partial",
115 "failed": "Failed",
116 "blocked": "Blocked",
117}
118PRE_ROUND_SYSTEM_PLAN_CEILING = 50
119
120
121@dataclass(frozen=True)
123 """Evidence that a plan was integrated by a closed mainline round."""
124
125 plan_id: str
126 round_no: int
127 merge_commit_sha: str
128 closed_at: str
129
130
131@dataclass(frozen=True)
133 """Filesystem inputs used by migration commands."""
134
135 workspace_root: Path
136 queue_path: Path | None = None
137 plan_dir: Path | None = None
138 status_paths: tuple[Path, ...] | None = None
139 round_evidence: tuple[RoundEvidence, ...] | None = None
140 discovered_in_main_at_sha: str | None = None
141
142
143@dataclass(frozen=True)
145 ok: bool
146 dry_run: bool
147 summary: dict[str, object]
148 errors: tuple[str, ...] = ()
149 warnings: tuple[str, ...] = ()
150 backup_path: str | None = None
151
152 def to_dict(self) -> dict[str, object]:
153 return _result_dict(self)
154
155
156@dataclass(frozen=True)
158 ok: bool
159 summary: dict[str, object]
160 errors: tuple[str, ...] = ()
161 warnings: tuple[str, ...] = ()
162 diff: dict[str, object] | None = None
163
164 def to_dict(self) -> dict[str, object]:
165 return _result_dict(self)
166
167
168@dataclass(frozen=True)
170 ok: bool
171 summary: dict[str, object]
172 errors: tuple[str, ...] = ()
173 restored_from: str | None = None
174
175 def to_dict(self) -> dict[str, object]:
176 return _result_dict(self)
177
178
179@dataclass(frozen=True)
181 ok: bool
182 exit_code: int
183 message: str
184
185 def to_dict(self) -> dict[str, object]:
186 return _result_dict(self)
187
188
189@dataclass(frozen=True)
191 plan_id: str
192 legacy_status: str
193 source_path: Path
194 source_file: str
195 branch: str | None
196 worktree: str | None
197 agent_label: str | None
198 model: str | None
199 thinking: str | None
200 session: str | None
201 body_observed_at: str
202 body: str
203 blocked_reason: str | None
204
205
206@dataclass(frozen=True)
208 plan_id: str
209 plan_number: int
210 title: str
211 path: Path
212 source_file: str
213 plan_type: str | None
214 branch: str | None
215 worktree: str | None
216 model: str | None
217 thinking: str | None
218 session: str | None
219 legacy_status: str | None
220 body_sha256: str
221
222
223@dataclass(frozen=True)
225 plan_id: str
226 slot: str
227 position: int
228 source_file: str
229 model: str | None
230 thinking: str | None
231 session: str | None
232
233
234@dataclass(frozen=True)
236 agents: tuple[dict[str, object], ...]
237 plans: tuple[dict[str, object], ...]
238 queue_entries: tuple[dict[str, object], ...]
239 rounds: tuple[dict[str, object], ...]
240 round_plans: tuple[dict[str, object], ...]
241 errors: tuple[str, ...]
242 warnings: tuple[str, ...]
243
244 def to_backend_payload(self) -> dict[str, object]:
245 return {
246 "agents": list(self.agents),
247 "plans": list(self.plans),
248 "queue_entries": list(self.queue_entries),
249 "rounds": list(self.rounds),
250 "round_plans": list(self.round_plans),
251 }
252
253 def expected_snapshot(self) -> dict[str, object]:
254 return {
255 "agents": _comparable_agents(self.agents),
256 "plans": _comparable_plans(self.plans),
257 "queue_entries": _comparable_queue_entries(self.queue_entries),
258 "rounds": _comparable_rounds(self.rounds),
259 "round_plans": _comparable_round_plans(self.round_plans),
260 }
261
262 def summary(self) -> dict[str, object]:
263 status_counts: dict[str, int] = {}
264 for plan in self.plans:
265 status = str(plan["status"])
266 status_counts[status] = status_counts.get(status, 0) + 1
267 return {
268 "agents": len(self.agents),
269 "plans": len(self.plans),
270 "queue_entries": len(self.queue_entries),
271 "rounds": len(self.rounds),
272 "round_plans": len(self.round_plans),
273 "status_counts": dict(sorted(status_counts.items())),
274 }
275
276
278 registry: Registry,
279 options: MigrationOptions,
280) -> MigrationProjection:
281 """Build the deterministic legacy-to-registry projection."""
282
283 backend = _sqlite_backend(registry)
284 root = options.workspace_root.resolve()
285 queue_path = (options.queue_path or root / _legacy_queue.DEFAULT_AGENT_QUEUE).resolve()
286 plan_dir = (options.plan_dir or root / "oc_chat" / "plans").resolve()
287 warnings: list[str] = []
288 errors: list[str] = []
289
290 agents = _build_agent_rows()
291 headers = _read_plan_headers(plan_dir, root)
292 pending, completed = _read_queue_entries(queue_path, root)
293 status_sources = _read_status_sources(
294 options.status_paths,
295 root,
296 backend.clock.now_wall(),
297 warnings,
298 )
299 evidence = _round_evidence_by_plan(options, root)
300 discovered_sha = options.discovered_in_main_at_sha or _main_sha(root)
301
302 plan_ids = (
303 set(headers)
304 | set(pending)
305 | set(status_sources)
306 | (set(completed) & set(headers))
307 )
309 plan_ids,
310 headers,
311 pending,
312 completed,
313 status_sources,
314 warnings,
315 )
316 plan_rows: list[dict[str, object]] = []
317 queue_rows: list[dict[str, object]] = []
318 rounds_by_no: dict[int, dict[str, object]] = {}
319 round_plans: list[dict[str, object]] = []
320 round_orders: dict[int, int] = {}
321
322 for plan_id in sorted(plan_ids, key=_plan_sort_key):
323 header = headers.get(plan_id) or _placeholder_header(plan_id, plan_dir, root)
324 source = status_sources.get(plan_id)
325 queue_entry = pending.get(plan_id)
326 legacy_status = _select_legacy_status(plan_id, header, source, pending, completed)
327 mapped = _map_legacy_status(
328 plan_id,
329 header.plan_number,
330 legacy_status,
331 source,
332 evidence.get(plan_id),
333 discovered_sha,
334 errors,
335 )
336 if mapped is None:
337 continue
338
339 assigned_slot = _assigned_slot(queue_entry, source, completed.get(plan_id))
340 queue_position = (
341 queue_entry.position
342 if queue_entry and mapped["status"] in {"pending", "implementing", "ready_for_merge"}
343 else None
344 )
345 body_observed_at = source.body_observed_at if source is not None else None
346 model, thinking, session = _select_overrides(header, source, queue_entry)
347 branch = _normalise_branch(
348 _first_text(source.branch if source else None, header.branch)
349 )
350 worktree = _first_text(source.worktree if source else None, header.worktree)
351 row = {
352 "plan_id": plan_id,
353 "plan_number": header.plan_number,
354 "title": header.title,
355 "plan_type": header.plan_type,
356 "status": mapped["status"],
357 "assigned_slot": assigned_slot,
358 "queue_position": queue_position,
359 "branch": branch,
360 "worktree": worktree,
361 "body_path": header.source_file,
362 "body_sha256": header.body_sha256,
363 "body_observed_at": body_observed_at,
364 "model_override": model,
365 "thinking_override": thinking,
366 "session_override": session,
367 "ready_for_merge_at": mapped["ready_for_merge_at"],
368 "completed_at": mapped["completed_at"],
369 "closed_round": mapped["closed_round"],
370 "legacy_status": legacy_status,
371 "payload": _plan_payload(
372 plan_id,
373 header,
374 legacy_status,
375 mapped,
376 source.source_file if source else header.source_file,
377 ),
378 }
379 if mapped["backfill_payload"] is not None:
380 row["backfill_payload"] = mapped["backfill_payload"]
381 if mapped["blocked_payload"] is not None:
382 row["blocked_payload"] = mapped["blocked_payload"]
383 plan_rows.append(row)
384
385 plan_evidence = evidence.get(plan_id)
386 if plan_evidence is not None and mapped["closed_round"] is not None:
387 rounds_by_no[plan_evidence.round_no] = _round_row(plan_evidence)
388 round_orders[plan_evidence.round_no] = round_orders.get(
389 plan_evidence.round_no,
390 0,
391 ) + 1
392 round_plans.append(
393 {
394 "round_no": plan_evidence.round_no,
395 "plan_id": plan_id,
396 "merge_order": round_orders[plan_evidence.round_no],
397 "merge_commit_sha": plan_evidence.merge_commit_sha,
398 "test_result": "pass",
399 }
400 )
401
402 if queue_entry is not None:
403 queue_rows.append(
404 {
405 "plan_id": plan_id,
406 "slot": queue_entry.slot,
407 "position": queue_entry.position,
408 "source_file": queue_entry.source_file,
409 "payload": {
410 "version": 1,
411 "plan_id": plan_id,
412 "slot": queue_entry.slot,
413 "position": queue_entry.position,
414 "source_file": queue_entry.source_file,
415 },
416 }
417 )
418
419 if queue_path.is_file() is False:
420 warnings.append(f"legacy queue not found: {queue_path}")
421 if not headers:
422 warnings.append(f"no plan headers found in {plan_dir}")
423 _clear_duplicate_branches(plan_rows, warnings)
424
425 return MigrationProjection(
426 agents=tuple(agents),
427 plans=tuple(plan_rows),
428 queue_entries=tuple(sorted(queue_rows, key=lambda row: (row["slot"], row["position"]))),
429 rounds=tuple(rounds_by_no[key] for key in sorted(rounds_by_no)),
430 round_plans=tuple(sorted(round_plans, key=lambda row: (row["round_no"], row["merge_order"]))),
431 errors=tuple(errors),
432 warnings=tuple(warnings),
433 )
434
435
437 registry: Registry,
438 options: MigrationOptions,
439 *,
440 dry_run: bool = False,
441) -> ImportResult:
442 """Dry-run or apply the legacy migration projection."""
443
444 projection = build_projection(registry, options)
445 if projection.errors:
446 return ImportResult(
447 ok=False,
448 dry_run=dry_run,
449 summary=projection.summary(),
450 errors=projection.errors,
451 warnings=projection.warnings,
452 )
453 if dry_run:
454 return ImportResult(
455 ok=True,
456 dry_run=True,
457 summary=projection.summary(),
458 warnings=projection.warnings,
459 )
460
461 backend = _sqlite_backend(registry)
462 actor_slot = backend.active_operator_slot()
463 if actor_slot is None:
464 return ImportResult(
465 ok=False,
466 dry_run=False,
467 summary=projection.summary(),
468 errors=("no active human operator slot exists",),
469 warnings=projection.warnings,
470 )
471
472 backup_path = _backup_path(options.workspace_root.resolve(), backend.clock.now_wall())
473 backend.backup(backup_path)
474 registry.acquire_lock(
475 "migrate-import",
476 actor_slot,
477 3600,
478 {"command": "migrate.import", "version": 1},
479 )
480 try:
481 backend.apply_migration_projection(
482 projection.to_backend_payload(),
483 actor_slot=actor_slot,
484 actor_type="human_operator",
485 )
486 finally:
487 registry.release_lock("migrate-import", actor_slot)
488
489 return ImportResult(
490 ok=True,
491 dry_run=False,
492 summary=projection.summary(),
493 warnings=projection.warnings,
494 backup_path=str(backup_path),
495 )
496
497
498def run_verify(registry: Registry, options: MigrationOptions) -> VerifyResult:
499 """Compare the projected legacy state with the registry tables."""
500
501 projection = build_projection(registry, options)
502 if projection.errors:
503 return VerifyResult(
504 ok=False,
505 summary=projection.summary(),
506 errors=projection.errors,
507 warnings=projection.warnings,
508 )
509 backend = _sqlite_backend(registry)
510 expected = projection.expected_snapshot()
511 actual = backend.migration_snapshot()
512 diff = _snapshot_diff(expected, actual)
513 backfill_violations = backend.migration_null_round_backfill_violations()
514 if backfill_violations:
515 diff["null_round_backfill_violations"] = backfill_violations
516 ok = not diff
517 return VerifyResult(
518 ok=ok,
519 summary=projection.summary(),
520 warnings=projection.warnings,
521 diff=None if ok else diff,
522 )
523
524
525def run_rollback(registry: Registry, options: MigrationOptions) -> RollbackResult:
526 """Restore the most recent pre-migration backup."""
527
528 backend = _sqlite_backend(registry)
529 backups = sorted(_backup_dir(options.workspace_root.resolve()).glob("pre-migrate-*.sqlite"))
530 if not backups:
531 return RollbackResult(
532 ok=False,
533 summary={"backups": 0},
534 errors=("no pre-migration backup found",),
535 )
536 latest = backups[-1]
537 backend.restore(latest)
538 return RollbackResult(
539 ok=True,
540 summary={"restored": str(latest)},
541 restored_from=str(latest),
542 )
543
544
545def run_cutover_stub() -> CutoverResult:
546 """Return the v0.2.x cutover placeholder."""
547
548 return CutoverResult(
549 ok=False,
550 exit_code=2,
551 message="migrate cutover not implemented in v0.2.x; see M11.8",
552 )
553
554
555def audit_null_closed_round_backfills(registry: Registry) -> dict[str, object]:
556 """Check the migration backfill marker post-condition."""
557
558 violations = _sqlite_backend(registry).migration_null_round_backfill_violations()
559 return {"ok": not violations, "violations": violations}
560
561
562def _build_agent_rows() -> list[dict[str, object]]:
563 rows: list[dict[str, object]] = []
564 for label, info in _legacy_queue.LEGACY_AGENT_POOLS.items():
565 slot = _agent_label_to_slot(label)
566 role = str(info.get("role", "Worker"))
567 slot_kind = "orchestrator" if "orchestrator" in role.lower() else "worker"
568 model = _first_model(info.get("models"))
569 model_class = "frontier"
570 if slot_kind == "worker" and _looks_small_model(model):
571 model_class = "small"
572 orchestrator_eligible = slot_kind == "orchestrator"
573 row = {
574 "slot": slot,
575 "slot_kind": slot_kind,
576 "identity_kind": "agent",
577 "model_class": model_class,
578 "orchestrator_eligible": 1 if orchestrator_eligible else 0,
579 "provider": _provider_from_model(model),
580 "profile": str(info.get("profile", "")) or None,
581 }
582 row["payload"] = {
583 "version": 1,
584 "slot": slot,
585 "slot_kind": slot_kind,
586 "identity_kind": "agent",
587 "model_class": model_class,
588 "orchestrator_eligible": orchestrator_eligible,
589 "provider": row["provider"],
590 "profile": row["profile"],
591 # The gitignored ``.oc_chat/agent_pool.json`` override sidecar
592 # retired with the queue machinery (plan 223 P3): it had no
593 # readers left and no copy on disk, so the committed roster is
594 # now the only provenance this projection can name.
595 # OCT-LINT: disable=no_hardcoded_secrets reason="module-path provenance label recorded in the migration payload, not a secret"
596 "source_file": "oc_chat.registry._legacy_queue.LEGACY_AGENT_POOLS",
597 }
598 rows.append(row)
599 return sorted(rows, key=lambda row: str(row["slot"]))
600
601
603 queue_path: Path,
604 root: Path,
605) -> tuple[dict[str, QueueEntry], dict[str, str]]:
606 pending: dict[str, QueueEntry] = {}
607 completed: dict[str, str] = {}
608 queue = _legacy_queue.load_legacy_queue(queue_path)
609 source_file = _display_path(queue_path, root)
610 for label, section in queue.items():
611 slot_id = _agent_label_to_slot(label)
612 for index, entry in enumerate(section["pending"], start=1):
613 plan_id = _legacy_queue.entry_plan_id(entry)
614 model, thinking, session = _legacy_queue.parse_entry_overrides(entry)
615 pending[plan_id] = QueueEntry(
616 plan_id=plan_id,
617 slot=slot_id,
618 position=index,
619 source_file=source_file,
620 model=model,
621 thinking=thinking,
622 session=session,
623 )
624 for plan_id in section["completed"]:
625 completed.setdefault(plan_id, slot_id)
626 return pending, completed
627
628
629def _read_plan_headers(plan_dir: Path, root: Path) -> dict[str, PlanHeader]:
630 headers: dict[str, PlanHeader] = {}
631 if not plan_dir.is_dir():
632 return headers
633 for path in sorted(plan_dir.glob("[0-9][0-9][0-9]-*.md")):
634 plan_id = path.stem
635 match = PLAN_ID_RE.match(plan_id)
636 if match is None:
637 continue
638 text = path.read_text(encoding="utf-8")
639 model, thinking, session = _parse_suggested_model(text)
640 legacy_status = _normalise_header_status(_header_value(text, "status"))
641 headers[plan_id] = PlanHeader(
642 plan_id=plan_id,
643 plan_number=int(match.group("number")),
644 title=_parse_title(text, plan_id),
645 path=path,
646 source_file=_display_path(path, root),
647 plan_type=_header_value(text, "plan_type"),
648 branch=_header_value(text, "branch"),
649 worktree=_header_value(text, "worktree"),
650 model=model,
651 thinking=thinking,
652 session=session,
653 legacy_status=legacy_status,
654 body_sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
655 )
656 return headers
657
658
660 explicit_paths: tuple[Path, ...] | None,
661 root: Path,
662 observed_at: str,
663 warnings: list[str],
664) -> dict[str, StatusSource]:
665 paths = explicit_paths if explicit_paths is not None else tuple(_discover_status_paths(root))
666 sources: dict[str, StatusSource] = {}
667 for path in paths:
668 if not path.is_file():
669 continue
670 try:
671 source = _read_status_source(path, root, observed_at)
672 except Exception as exc:
673 warnings.append(f"could not parse {path}: {type(exc).__name__}: {exc}")
674 continue
675 sources[source.plan_id] = source
676 return sources
677
678
679def _read_status_source(path: Path, root: Path, observed_at: str) -> StatusSource:
680 try:
681 status, body = plan_status.read(path)
682 data = status.model_dump(mode="json")
683 except Exception:
684 text = path.read_text(encoding="utf-8")
685 frontmatter, body = plan_status._split_frontmatter(text)
686 loaded = yaml.safe_load(frontmatter)
687 if not isinstance(loaded, dict):
688 raise ValueError("frontmatter must be a YAML mapping")
689 data = loaded
690
691 raw_agent = data.get("agent")
692 agent_label = _agent_label(str(raw_agent)) if raw_agent else None
693 legacy_status = _normalise_legacy_status(data.get("status"))
694 blocked_reason = _first_text(
695 data.get("blocked_reason"),
696 data.get("blocker"),
697 _first_nonempty_line(body) if legacy_status == "Blocked" else None,
698 )
699 body_observed_at = _first_text(
700 _string_timestamp(data.get("finished_at")),
701 _string_timestamp(data.get("started_at")),
702 _string_timestamp(data.get("queued_at")),
703 observed_at,
704 )
705 return StatusSource(
706 plan_id=str(data["plan_id"]),
707 legacy_status=legacy_status,
708 source_path=path,
709 source_file=_display_path(path, root),
710 branch=_optional_text(data.get("branch")),
711 worktree=_optional_text(data.get("worktree")),
712 agent_label=agent_label,
713 model=_optional_text(data.get("model")),
714 thinking=_optional_text(data.get("thinking")),
715 session=_optional_text(data.get("session")),
716 body_observed_at=body_observed_at,
717 body=body,
718 blocked_reason=blocked_reason,
719 )
720
721
722def _discover_status_paths(root: Path) -> list[Path]:
723 paths = {root / plan_status.DEFAULT_FILENAME}
724 try:
725 result = subprocess.run(
726 ["git", "worktree", "list", "--porcelain"],
727 cwd=str(root),
728 check=False,
729 capture_output=True,
730 text=True,
731 encoding="utf-8",
732 errors="replace",
733 )
734 except (OSError, ValueError):
735 result = None
736 if result is not None and result.returncode == 0:
737 for line in result.stdout.splitlines():
738 if line.startswith("worktree "):
739 paths.add(Path(line.removeprefix("worktree ")).resolve() / plan_status.DEFAULT_FILENAME)
740 return sorted(path for path in paths if path.is_file())
741
742
744 options: MigrationOptions,
745 root: Path,
746) -> dict[str, RoundEvidence]:
747 evidence = list(options.round_evidence or ())
748 if options.round_evidence is None:
749 evidence.extend(_discover_round_evidence(root))
750 by_plan: dict[str, RoundEvidence] = {}
751 for item in evidence:
752 by_plan[item.plan_id] = item
753 return by_plan
754
755
756def _discover_round_evidence(root: Path) -> list[RoundEvidence]:
757 try:
758 result = subprocess.run(
759 [
760 "git",
761 "log",
762 "--first-parent",
763 "main",
764 "--pretty=format:%H%x1f%aI%x1f%s%x1f%b%x1e",
765 ],
766 cwd=str(root),
767 check=False,
768 capture_output=True,
769 text=True,
770 encoding="utf-8",
771 errors="replace",
772 )
773 except (OSError, ValueError):
774 return []
775 if result.returncode != 0:
776 return []
777
778 evidence: list[RoundEvidence] = []
779 for record in result.stdout.split("\x1e"):
780 fields = record.strip().split("\x1f", 3)
781 if len(fields) != 4:
782 continue
783 commit_sha, closed_at, subject, body = fields
784 text = f"{subject}\n{body}"
785 round_match = ROUND_RE.search(text)
786 if round_match is None:
787 continue
788 round_no = int(round_match.group("round"))
789 for plan_id in sorted(set(LEGACY_PLAN_ID_RE.findall(text))):
790 evidence.append(
792 plan_id=plan_id,
793 round_no=round_no,
794 merge_commit_sha=commit_sha,
795 closed_at=closed_at,
796 )
797 )
798 return evidence
799
800
801def _main_sha(root: Path) -> str | None:
802 try:
803 result = subprocess.run(
804 ["git", "rev-parse", "main"],
805 cwd=str(root),
806 check=False,
807 capture_output=True,
808 text=True,
809 encoding="utf-8",
810 errors="replace",
811 )
812 except (OSError, ValueError):
813 return None
814 if result.returncode != 0:
815 return None
816 return result.stdout.strip() or None
817
818
820 plan_id: str,
821 header: PlanHeader,
822 source: StatusSource | None,
823 pending: Mapping[str, QueueEntry],
824 completed: Mapping[str, str],
825) -> str:
826 if source is not None:
827 return source.legacy_status
828 if header.legacy_status is not None:
829 return header.legacy_status
830 if plan_id in pending:
831 return "Pending"
832 if plan_id in completed:
833 return "Completed"
834 return "Pending"
835
836
838 plan_ids: set[str],
839 headers: Mapping[str, PlanHeader],
840 pending: Mapping[str, QueueEntry],
841 completed: Mapping[str, str],
842 status_sources: Mapping[str, StatusSource],
843 warnings: list[str],
844) -> set[str]:
845 chosen: dict[int, str] = {}
846 for plan_id in sorted(plan_ids, key=_plan_sort_key):
847 number = _plan_number(plan_id, headers)
848 previous = chosen.get(number)
849 if previous is None:
850 chosen[number] = plan_id
851 continue
852 winner = max(
853 (previous, plan_id),
854 key=lambda item: _plan_priority(item, pending, completed, status_sources),
855 )
856 loser = plan_id if winner == previous else previous
857 chosen[number] = winner
858 warnings.append(
859 f"duplicate plan_number {number}: kept {winner}, skipped {loser}"
860 )
861 return set(chosen.values())
862
863
864def _plan_number(plan_id: str, headers: Mapping[str, PlanHeader]) -> int:
865 header = headers.get(plan_id)
866 if header is not None:
867 return header.plan_number
868 match = PLAN_ID_RE.match(plan_id)
869 return int(match.group("number")) if match else 0
870
871
873 plan_id: str,
874 pending: Mapping[str, QueueEntry],
875 completed: Mapping[str, str],
876 status_sources: Mapping[str, StatusSource],
877) -> tuple[int, str]:
878 if plan_id in status_sources or plan_id in pending:
879 return (3, plan_id)
880 if plan_id in completed:
881 return (2, plan_id)
882 return (1, plan_id)
883
884
886 plan_id: str,
887 plan_number: int,
888 legacy_status: str,
889 source: StatusSource | None,
890 evidence: RoundEvidence | None,
891 discovered_sha: str | None,
892 errors: list[str],
893) -> dict[str, object] | None:
894 if legacy_status not in VALID_LEGACY_STATUSES:
895 errors.append(f"{plan_id}: unknown legacy status {legacy_status!r}")
896 return None
897
898 observed_at = source.body_observed_at if source is not None else None
899 base = {
900 "status": "pending",
901 "ready_for_merge_at": None,
902 "completed_at": None,
903 "closed_round": None,
904 "backfill_payload": None,
905 "blocked_payload": None,
906 }
907 if legacy_status == "Completed":
908 if evidence is not None:
909 base.update(
910 {
911 "status": "completed",
912 "completed_at": evidence.closed_at,
913 "closed_round": evidence.round_no,
914 }
915 )
916 elif plan_number < PRE_ROUND_SYSTEM_PLAN_CEILING:
917 completed_at = observed_at
918 base.update({"status": "completed", "completed_at": completed_at})
919 base["backfill_payload"] = {
920 "version": 1,
921 "plan_id": plan_id,
922 "reason": "pre-round-system completed plan",
923 "body_observed_at": completed_at,
924 "discovered_in_main_at_sha": discovered_sha,
925 }
926 else:
927 base.update(
928 {
929 "status": "ready_for_merge",
930 "ready_for_merge_at": observed_at,
931 }
932 )
933 elif legacy_status == "In Progress":
934 base["status"] = "implementing"
935 elif legacy_status == "Blocked":
936 reason = source.blocked_reason if source is not None else None
937 base["blocked_payload"] = {
938 "version": 1,
939 "plan_id": plan_id,
940 "original_blocked_reason": reason or "legacy status was Blocked",
941 }
942 elif legacy_status == "Failed":
943 base["status"] = "failed"
944 elif legacy_status == "Partial":
945 base["status"] = "partial"
946 return base
947
948
950 plan_id: str,
951 header: PlanHeader,
952 legacy_status: str,
953 mapped: Mapping[str, object],
954 source_file: str,
955) -> dict[str, object]:
956 return {
957 "version": 1,
958 "plan_id": plan_id,
959 "plan_number": header.plan_number,
960 "legacy_status": legacy_status,
961 "mapped_status": mapped["status"],
962 "source_file": source_file,
963 "body_path": header.source_file,
964 "body_sha256": header.body_sha256,
965 "ready_for_merge_at": mapped["ready_for_merge_at"],
966 "closed_round": mapped["closed_round"],
967 "completed_at": mapped["completed_at"],
968 }
969
970
971def _round_row(evidence: RoundEvidence) -> dict[str, object]:
972 return {
973 "round_no": evidence.round_no,
974 "status": "closed",
975 "cleanup_status": "clean",
976 "orchestrator_slot": "agent-a",
977 "opened_at": evidence.closed_at,
978 "closed_at": evidence.closed_at,
979 "final_commit_sha": evidence.merge_commit_sha,
980 }
981
982
983def _placeholder_header(plan_id: str, plan_dir: Path, root: Path) -> PlanHeader:
984 match = PLAN_ID_RE.match(plan_id)
985 number = int(match.group("number")) if match else 0
986 path = plan_dir / f"{plan_id}.md"
987 return PlanHeader(
988 plan_id=plan_id,
989 plan_number=number,
990 title=plan_id,
991 path=path,
992 source_file=_display_path(path, root),
993 plan_type=None,
994 branch=None,
995 worktree=None,
996 model=None,
997 thinking=None,
998 session=None,
999 legacy_status=None,
1000 body_sha256="",
1001 )
1002
1003
1005 queue_entry: QueueEntry | None,
1006 source: StatusSource | None,
1007 completed_slot: str | None,
1008) -> str | None:
1009 if queue_entry is not None:
1010 return queue_entry.slot
1011 if source is not None and source.agent_label is not None:
1012 return _agent_label_to_slot(source.agent_label)
1013 return completed_slot
1014
1015
1017 header: PlanHeader,
1018 source: StatusSource | None,
1019 queue_entry: QueueEntry | None,
1020) -> tuple[str | None, str | None, str | None]:
1021 return (
1022 _first_text(source.model if source else None, queue_entry.model if queue_entry else None, header.model),
1023 _first_text(source.thinking if source else None, queue_entry.thinking if queue_entry else None, header.thinking),
1024 _first_text(source.session if source else None, queue_entry.session if queue_entry else None, header.session),
1025 )
1026
1027
1028def _parse_title(text: str, plan_id: str) -> str:
1029 match = H1_RE.search(text)
1030 return plan_id if match is None else match.group("value").strip()
1031
1032
1033def _header_value(text: str, name: str) -> str | None:
1034 match = HEADER_PATTERNS[name].search(text)
1035 if match is None:
1036 return None
1037 if "value" in match.groupdict():
1038 return _clean_header_value(match.group("value"))
1039 return _clean_header_value(match.group("ticks") or match.group("plain"))
1040
1041
1042def _parse_suggested_model(text: str) -> tuple[str | None, str | None, str | None]:
1043 value = _header_value(text, "suggested_model")
1044 if value is None:
1045 return None, None, None
1046 # OCT-LINT: disable=no_hardcoded_secrets reason="Regexes and model-family literals are parser fixtures, not secrets"
1047 model_match = re.search(r"\b(?:gpt|claude|deepseek)[A-Za-z0-9_.-]*\b", value)
1048 # OCT-LINT: disable=no_hardcoded_secrets reason="Regexes and model-family literals are parser fixtures, not secrets"
1049 thinking_match = re.search(r"\bthinking\s*[=:]\s*(off|low|medium|high|max)\b", value)
1050 session_match = re.search(r"\bsession\s*[=:]\s*(fresh|continue|either)\b", value)
1051 return (
1052 None if model_match is None else model_match.group(0),
1053 None if thinking_match is None else thinking_match.group(1),
1054 None if session_match is None else session_match.group(1),
1055 )
1056
1057
1058def _clean_header_value(value: str | None) -> str | None:
1059 if value is None:
1060 return None
1061 text = value.strip().strip("`").strip()
1062 return text or None
1063
1064
1065def _normalise_branch(value: str | None) -> str | None:
1066 text = _optional_text(value)
1067 if text is None:
1068 return None
1069 if text.lower() in {"main", "none", "n/a", "na", "tbd"}:
1070 return None
1071 return text
1072
1073
1075 plan_rows: Sequence[dict[str, object]],
1076 warnings: list[str],
1077) -> None:
1078 by_branch: dict[str, list[dict[str, object]]] = {}
1079 for row in plan_rows:
1080 branch = _optional_text(row.get("branch"))
1081 if branch is not None:
1082 by_branch.setdefault(branch, []).append(row)
1083 for branch, rows in by_branch.items():
1084 if len(rows) < 2:
1085 continue
1086 plan_ids = ", ".join(str(row["plan_id"]) for row in rows)
1087 warnings.append(f"duplicate branch {branch}: cleared for {plan_ids}")
1088 for row in rows:
1089 row["branch"] = None
1090 payload = row.get("payload")
1091 if isinstance(payload, dict):
1092 payload["branch"] = None
1093
1094
1095def _normalise_legacy_status(value: object) -> str:
1096 text = _optional_text(value)
1097 if text is None:
1098 return "Pending"
1099 key = text.strip().lower()
1100 return STATUS_NORMALISATIONS.get(key, text.strip())
1101
1102
1103def _normalise_header_status(value: object) -> str | None:
1104 if value is None:
1105 return None
1106 status = _normalise_legacy_status(value)
1107 return status if status in VALID_LEGACY_STATUSES else None
1108
1109
1110def _agent_label(value: str) -> str:
1111 text = value.strip()
1112 if text.lower().startswith("agent "):
1113 return f"Agent {text.split()[-1].upper()}"
1114 if text.lower().startswith("agent-"):
1115 return f"Agent {text.rsplit('-', 1)[-1].upper()}"
1116 if len(text) == 1:
1117 return f"Agent {text.upper()}"
1118 return text
1119
1120
1121def _agent_label_to_slot(label: str) -> str:
1122 text = label.strip().lower()
1123 if text.startswith("agent-"):
1124 return text
1125 if text.startswith("agent "):
1126 return f"agent-{text.split()[-1]}"
1127 if len(text) == 1:
1128 return f"agent-{text}"
1129 return text.replace(" ", "-")
1130
1131
1132def _first_model(value: object) -> str | None:
1133 if isinstance(value, str):
1134 return value
1135 if isinstance(value, Sequence):
1136 for item in value:
1137 text = _optional_text(item)
1138 if text is not None:
1139 return text
1140 return None
1141
1142
1143def _looks_small_model(model: str | None) -> bool:
1144 if model is None:
1145 return False
1146 lowered = model.lower()
1147 return any(marker in lowered for marker in ("mini", "flash", "haiku", "small"))
1148
1149
1150def _provider_from_model(model: str | None) -> str | None:
1151 if model is None:
1152 return None
1153 lowered = model.lower()
1154 if lowered.startswith("gpt"):
1155 return "openai"
1156 if lowered.startswith("claude"):
1157 return "anthropic"
1158 if lowered.startswith("deepseek"):
1159 return "deepseek"
1160 return None
1161
1162
1163def _display_path(path: Path, root: Path) -> str:
1164 try:
1165 return path.resolve().relative_to(root.resolve()).as_posix()
1166 except ValueError:
1167 return str(path)
1168
1169
1170def _string_timestamp(value: object) -> str | None:
1171 if value is None:
1172 return None
1173 if hasattr(value, "isoformat"):
1174 return str(value.isoformat())
1175 text = str(value)
1176 return text if text else None
1177
1178
1179def _optional_text(value: object) -> str | None:
1180 if value is None:
1181 return None
1182 if hasattr(value, "value"):
1183 value = value.value
1184 text = str(value).strip()
1185 return text or None
1186
1187
1188def _first_text(*values: object) -> str | None:
1189 for value in values:
1190 text = _optional_text(value)
1191 if text is not None:
1192 return text
1193 return None
1194
1195
1196def _first_nonempty_line(text: str) -> str | None:
1197 for line in text.splitlines():
1198 stripped = line.strip()
1199 if stripped:
1200 return stripped
1201 return None
1202
1203
1204def _plan_sort_key(plan_id: str) -> tuple[int, str]:
1205 match = PLAN_ID_RE.match(plan_id)
1206 return (int(match.group("number")) if match else 999999, plan_id)
1207
1208
1209def _backup_dir(root: Path) -> Path:
1210 return root / ".option_c" / "orchestration" / "backups"
1211
1212
1213def _backup_path(root: Path, now: str) -> Path:
1214 safe = now.replace(":", "-").replace("+", "_").replace("/", "-")
1215 return _backup_dir(root) / f"pre-migrate-{safe}.sqlite"
1216
1217
1218def _sqlite_backend(registry: Registry) -> SQLiteBackend:
1219 backend = registry.backend
1220 if not isinstance(backend, SQLiteBackend):
1221 raise TypeError("migrate commands require SQLiteBackend")
1222 return backend
1223
1224
1225def _comparable_agents(rows: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
1226 return [
1227 {
1228 "slot": row["slot"],
1229 "slot_kind": row["slot_kind"],
1230 "identity_kind": row["identity_kind"],
1231 "model_class": row["model_class"],
1232 "orchestrator_eligible": int(row["orchestrator_eligible"]),
1233 "provider": row.get("provider"),
1234 "profile": row.get("profile"),
1235 "active": 1,
1236 }
1237 for row in sorted(rows, key=lambda item: str(item["slot"]))
1238 ]
1239
1240
1241def _comparable_plans(rows: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
1242 keys = (
1243 "plan_id",
1244 "plan_number",
1245 "title",
1246 "plan_type",
1247 "status",
1248 "assigned_slot",
1249 "queue_position",
1250 "branch",
1251 "worktree",
1252 "body_path",
1253 "body_sha256",
1254 "body_observed_at",
1255 "model_override",
1256 "thinking_override",
1257 "session_override",
1258 "ready_for_merge_at",
1259 "completed_at",
1260 "closed_round",
1261 )
1262 return [
1263 {key: row.get(key) for key in keys}
1264 for row in sorted(rows, key=lambda item: str(item["plan_id"]))
1265 ]
1266
1267
1269 rows: Sequence[Mapping[str, object]],
1270) -> list[dict[str, object]]:
1271 return [
1272 {
1273 "plan_id": row["plan_id"],
1274 "slot": row["slot"],
1275 "position": row["position"],
1276 }
1277 for row in sorted(rows, key=lambda item: (str(item["slot"]), int(item["position"])))
1278 ]
1279
1280
1281def _comparable_rounds(rows: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
1282 keys = (
1283 "round_no",
1284 "status",
1285 "cleanup_status",
1286 "orchestrator_slot",
1287 "opened_at",
1288 "closed_at",
1289 "final_commit_sha",
1290 )
1291 return [
1292 {key: row.get(key) for key in keys}
1293 for row in sorted(rows, key=lambda item: int(item["round_no"]))
1294 ]
1295
1296
1298 rows: Sequence[Mapping[str, object]],
1299) -> list[dict[str, object]]:
1300 keys = ("round_no", "plan_id", "merge_order", "merge_commit_sha", "test_result")
1301 return [
1302 {key: row.get(key) for key in keys}
1303 for row in sorted(
1304 rows,
1305 key=lambda item: (int(item["round_no"]), int(item["merge_order"])),
1306 )
1307 ]
1308
1309
1311 expected: Mapping[str, object],
1312 actual: Mapping[str, object],
1313) -> dict[str, object]:
1314 diff: dict[str, object] = {}
1315 for section in ("agents", "plans", "queue_entries", "rounds", "round_plans"):
1316 exp = expected.get(section)
1317 act = actual.get(section)
1318 if exp != act:
1319 diff[section] = {
1320 "expected_count": len(exp) if isinstance(exp, list) else None,
1321 "actual_count": len(act) if isinstance(act, list) else None,
1322 "expected": exp,
1323 "actual": act,
1324 }
1325 return diff
1326
1327
1328def _result_dict(value: object) -> dict[str, object]:
1329 result: dict[str, object] = {}
1330 for name in getattr(value, "__dataclass_fields__", {}):
1331 item = getattr(value, name)
1332 if isinstance(item, tuple):
1333 result[name] = list(item)
1334 else:
1335 result[name] = item
1336 return result
dict[str, object] to_dict(self)
Definition migrate.py:185
dict[str, object] to_dict(self)
Definition migrate.py:152
dict[str, object] to_backend_payload(self)
Definition migrate.py:244
dict[str, object] expected_snapshot(self)
Definition migrate.py:253
Definition migrate.py:224
dict[str, object] to_dict(self)
Definition migrate.py:175
dict[str, object] to_dict(self)
Definition migrate.py:164
ImportResult run_import(Registry registry, MigrationOptions options, *, bool dry_run=False)
Definition migrate.py:441
tuple[int, str] _plan_priority(str plan_id, Mapping[str, QueueEntry] pending, Mapping[str, str] completed, Mapping[str, StatusSource] status_sources)
Definition migrate.py:877
tuple[str|None, str|None, str|None] _select_overrides(PlanHeader header, StatusSource|None source, QueueEntry|None queue_entry)
Definition migrate.py:1020
list[RoundEvidence] _discover_round_evidence(Path root)
Definition migrate.py:756
Path _backup_path(Path root, str now)
Definition migrate.py:1213
dict[str, PlanHeader] _read_plan_headers(Path plan_dir, Path root)
Definition migrate.py:629
dict[str, StatusSource] _read_status_sources(tuple[Path,...]|None explicit_paths, Path root, str observed_at, list[str] warnings)
Definition migrate.py:664
SQLiteBackend _sqlite_backend(Registry registry)
Definition migrate.py:1218
dict[str, object] _snapshot_diff(Mapping[str, object] expected, Mapping[str, object] actual)
Definition migrate.py:1313
list[dict[str, object]] _comparable_round_plans(Sequence[Mapping[str, object]] rows)
Definition migrate.py:1299
RollbackResult run_rollback(Registry registry, MigrationOptions options)
Definition migrate.py:525
int _plan_number(str plan_id, Mapping[str, PlanHeader] headers)
Definition migrate.py:864
PlanHeader _placeholder_header(str plan_id, Path plan_dir, Path root)
Definition migrate.py:983
StatusSource _read_status_source(Path path, Path root, str observed_at)
Definition migrate.py:679
tuple[int, str] _plan_sort_key(str plan_id)
Definition migrate.py:1204
dict[str, object] _round_row(RoundEvidence evidence)
Definition migrate.py:971
str|None _normalise_branch(str|None value)
Definition migrate.py:1065
bool _looks_small_model(str|None model)
Definition migrate.py:1143
dict[str, RoundEvidence] _round_evidence_by_plan(MigrationOptions options, Path root)
Definition migrate.py:746
str|None _assigned_slot(QueueEntry|None queue_entry, StatusSource|None source, str|None completed_slot)
Definition migrate.py:1008
str|None _normalise_header_status(object value)
Definition migrate.py:1103
str|None _header_value(str text, str name)
Definition migrate.py:1033
tuple[str|None, str|None, str|None] _parse_suggested_model(str text)
Definition migrate.py:1042
str|None _first_model(object value)
Definition migrate.py:1132
str|None _string_timestamp(object value)
Definition migrate.py:1170
str|None _first_text(*object values)
Definition migrate.py:1188
list[dict[str, object]] _comparable_agents(Sequence[Mapping[str, object]] rows)
Definition migrate.py:1225
dict[str, object] _plan_payload(str plan_id, PlanHeader header, str legacy_status, Mapping[str, object] mapped, str source_file)
Definition migrate.py:955
list[dict[str, object]] _build_agent_rows()
Definition migrate.py:562
VerifyResult run_verify(Registry registry, MigrationOptions options)
Definition migrate.py:498
Path _backup_dir(Path root)
Definition migrate.py:1209
set[str] _dedupe_plan_ids_by_number(set[str] plan_ids, Mapping[str, PlanHeader] headers, Mapping[str, QueueEntry] pending, Mapping[str, str] completed, Mapping[str, StatusSource] status_sources, list[str] warnings)
Definition migrate.py:844
None _clear_duplicate_branches(Sequence[dict[str, object]] plan_rows, list[str] warnings)
Definition migrate.py:1077
list[dict[str, object]] _comparable_queue_entries(Sequence[Mapping[str, object]] rows)
Definition migrate.py:1270
str _agent_label(str value)
Definition migrate.py:1110
str _parse_title(str text, str plan_id)
Definition migrate.py:1028
dict[str, object] _result_dict(object value)
Definition migrate.py:1328
str|None _first_nonempty_line(str text)
Definition migrate.py:1196
list[dict[str, object]] _comparable_rounds(Sequence[Mapping[str, object]] rows)
Definition migrate.py:1281
MigrationProjection build_projection(Registry registry, MigrationOptions options)
Definition migrate.py:280
str|None _optional_text(object value)
Definition migrate.py:1179
str|None _main_sha(Path root)
Definition migrate.py:801
str _agent_label_to_slot(str label)
Definition migrate.py:1121
str _display_path(Path path, Path root)
Definition migrate.py:1163
str _select_legacy_status(str plan_id, PlanHeader header, StatusSource|None source, Mapping[str, QueueEntry] pending, Mapping[str, str] completed)
Definition migrate.py:825
dict[str, object]|None _map_legacy_status(str plan_id, int plan_number, str legacy_status, StatusSource|None source, RoundEvidence|None evidence, str|None discovered_sha, list[str] errors)
Definition migrate.py:893
str|None _clean_header_value(str|None value)
Definition migrate.py:1058
list[Path] _discover_status_paths(Path root)
Definition migrate.py:722
list[dict[str, object]] _comparable_plans(Sequence[Mapping[str, object]] rows)
Definition migrate.py:1241
tuple[dict[str, QueueEntry], dict[str, str]] _read_queue_entries(Path queue_path, Path root)
Definition migrate.py:605
dict[str, object] audit_null_closed_round_backfills(Registry registry)
Definition migrate.py:555
str _normalise_legacy_status(object value)
Definition migrate.py:1095
str|None _provider_from_model(str|None model)
Definition migrate.py:1150
CutoverResult run_cutover_stub()
Definition migrate.py:545