7Operator doctor checks for the orchestration registry.
11Provide the health checks and small repairs owned by M11.1c: local file
12permissions, stray temp-file cleanup, expired-lock sweep auditing, and
13credential fingerprint consistency.
17- Inspect and optionally repair `.option_c` and `agent_secrets.json` modes.
18- Remove stray `.tmp-*` files below `.option_c/orchestration`.
19- Sweep expired lock rows and emit a `doctor.repair` audit operation.
20- Compare on-disk credential fingerprints with `agent_credentials`.
32- Audit rows go through `Registry.execute_domain_command`.
33- Secret values are never returned in structured output.
34- Windows permission inspection is a graceful v0.2.x skip.
38- oc_diagnostics (structured debug logging via _dbg)
39- oc_chat.registry.api (Registry facade)
40- oc_chat.registry.backends.sqlite (SQLite backend)
41- oc_chat.registry.domain (plan_reimport for the trust-local snapshot repair)
42- oc_chat.registry.render (render_all for snapshot drift detection and trust-registry repair)
43- oc_chat.registry.render._common (RenderResult and plan_for_worktree lookup)
45from __future__
import annotations
47from dataclasses
import dataclass
52from pathlib
import Path
54from typing
import Mapping
56from oc_diagnostics
import _dbg
65_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
67OPTION_C_DIR =
".option_c"
68ORCHESTRATION_DIR =
"orchestration"
70SECRETS_FILENAME =
"agent_secrets.json"
71EXPECTED_OPTION_C_MODE = 0o700
72EXPECTED_SECRETS_MODE = 0o600
75@dataclass(frozen=True)
77 """Runtime flags for `doctor`."""
79 fix_perms: bool =
False
80 skip_perms: bool =
False
81 sweep_temps: bool =
True
82 lock_sweep: bool =
True
83 credential_check: bool =
True
84 resolve_snapshot_conflict: bool =
False
85 yes_trust_registry: bool =
False
86 yes_trust_local: bool =
False
91 backend: SQLiteBackend,
93 options: DoctorOptions,
94) -> dict[str, object]:
95 """Run all enabled doctor checks and return a JSON-safe report."""
97 option_c = workspace_root / OPTION_C_DIR
100 fix=options.fix_perms,
101 skip=options.skip_perms,
105 if options.sweep_temps
106 else _skipped(
"stray temp-file sweep skipped")
110 if options.lock_sweep
111 else _skipped(
"lock-expiry sweep skipped")
115 if options.credential_check
116 else _skipped(
"credential consistency check skipped")
119 sections = [permissions, temp_files, locks, credentials, round_state]
120 report: dict[str, object] = {
122 "workspace_root": str(workspace_root),
123 "registry_path": str(backend.path),
124 "permissions": permissions,
125 "temp_files": temp_files,
127 "credentials": credentials,
128 "round_state": round_state,
131 if auth_events[
"failures"]:
132 report[
"auth_events"] = auth_events
133 if options.resolve_snapshot_conflict:
140 sections.append(snapshots)
141 report[
"snapshot_conflicts"] = snapshots
143 report[
"ok"] =
not warnings
144 report[
"warnings"] = warnings
149 """Check POSIX modes for `.option_c` and `agent_secrets.json`."""
152 return _skipped(
"perms check skipped by flag")
154 return _skipped(
"perms check skipped on this platform")
156 (option_c, EXPECTED_OPTION_C_MODE),
157 (option_c / SECRETS_FILENAME, EXPECTED_SECRETS_MODE),
159 checked: list[dict[str, object]] = []
160 fixed: list[str] = []
161 warnings: list[str] = []
162 for path, expected_mode
in targets:
163 if not path.exists():
164 warnings.append(f
"{path} is missing")
166 actual_mode = stat.S_IMODE(path.stat().st_mode)
170 "actual": oct(actual_mode),
171 "expected": oct(expected_mode),
174 if actual_mode == expected_mode:
177 f
"{path} mode is {oct(actual_mode)}; expected {oct(expected_mode)}"
180 path.chmod(expected_mode)
181 fixed.append(str(path))
183 warnings.append(message)
193 "warnings": warnings,
199 """Remove stray `.tmp-*` files below the orchestration directory."""
201 removed: list[str] = []
202 warnings: list[str] = []
203 if not orchestration_dir.exists():
207 "warnings": warnings,
210 for path
in sorted(orchestration_dir.rglob(
".tmp-*")):
211 if not path.is_file()
and not path.is_symlink():
212 warnings.append(f
"skipped non-file temp path: {path}")
216 removed.append(str(path))
217 except OSError
as exc:
218 warnings.append(f
"failed to remove {path}: {exc}")
220 "status":
"warn" if warnings
else "ok",
222 "warnings": warnings,
229 backend: SQLiteBackend,
230) -> dict[str, object]:
231 """Sweep expired locks and audit the repair when rows were removed."""
233 swept = backend.startup_sweep_with_names()
234 result: dict[str, object] = {
238 "audit_operation_id":
None,
244 operator_slot = backend.active_operator_slot()
245 if operator_slot
is None:
246 result[
"status"] =
"warn"
247 result[
"warnings"] = [
"no active operator slot available for doctor audit"]
249 row = registry.execute_domain_command(
254 "repair_kind":
"lock_expiry_sweep",
255 "items_repaired": swept,
258 actor_type=
"human_operator",
260 result[
"audit_operation_id"] = row.id
265 backend: SQLiteBackend,
267) -> dict[str, object]:
268 """Compare non-revoked DB credential fingerprints with the secrets file."""
271 row
for row
in backend.credential_fingerprints()
if row.get(
"revoked_at")
is None
273 if not secrets_path.is_file():
277 "warnings": [f
"{secrets_path} is missing"],
281 data = json.loads(secrets_path.read_text(encoding=
"utf-8"))
282 except (OSError, json.JSONDecodeError)
as exc:
284 "status":
"unreadable",
286 "warnings": [f
"{secrets_path} could not be read as JSON: {exc}"],
289 warnings: list[str] = []
291 for credential
in credentials:
293 slot = str(credential[
"slot"])
294 phase = str(credential[
"rotation_phase"])
297 warnings.append(f
"missing secret block for {slot}/{phase}")
299 expected_key = str(credential[
"hmac_key_id"])
300 actual_key = block.get(
"hmac_key_id")
301 if isinstance(actual_key, str)
and actual_key != expected_key:
302 warnings.append(f
"hmac_key_id mismatch for {slot}/{phase}")
304 if actual_fingerprint != credential[
"secret_fingerprint"]:
305 warnings.append(f
"secret_fingerprint mismatch for {slot}/{phase}")
307 "status":
"warn" if warnings
else "ok",
309 "warnings": warnings,
315 """Audit round/round_job invariants (read-only).
317 Flags the OI-324 invariant (a ``closed`` round must be ``clean``) and any
318 round-job left in ``failed`` status that needs an operator resume or abort.
321 func =
"check_round_state"
322 warnings: list[str] = []
323 with backend.connect()
as conn:
324 bad_close = conn.execute(
326 "SELECT round_no, cleanup_status FROM rounds "
327 "WHERE status = 'closed' AND cleanup_status != 'clean'"
329 failed_jobs = conn.execute(
331 "SELECT job_id, round_no, current_step FROM round_jobs "
332 "WHERE status = 'failed' ORDER BY round_no"
337 "SELECT COUNT(*) FROM round_jobs WHERE status IN ('running', 'paused')"
340 for row
in bad_close:
342 f
"round {row['round_no']} is closed but cleanup_status="
343 f
"{row['cleanup_status']!r} (OI-324 invariant)"
345 for row
in failed_jobs:
347 f
"round {row['round_no']} job {str(row['job_id'])[:8]} failed at "
348 f
"{row['current_step'] or '-'}; resume or abort required"
350 _dbg(
"OC_CHAT.MAIN", func, f
"warnings={len(warnings)} in_flight={in_flight}", 2)
352 "status":
"warn" if warnings
else "ok",
353 "checked": len(bad_close) + len(failed_jobs),
354 "in_flight": in_flight,
355 "failed_jobs": len(failed_jobs),
356 "warnings": warnings,
362 """Return auth-event counts for doctor output."""
364 with backend.connect()
as conn:
365 total = int(conn.execute(
"SELECT COUNT(*) FROM auth_events").fetchone()[0])
369 "SELECT COUNT(*) FROM auth_events WHERE auth_ok = 0"
373 "status":
"warn" if failures
else "ok",
375 "failures": failures,
377 "messages": [f
"failed_auth_events={failures}"]
if failures
else [],
383 backend: SQLiteBackend,
384 workspace_root: Path,
385 options: DoctorOptions,
386) -> dict[str, object]:
387 """Detect and optionally resolve rendered artifact drift."""
389 dry_report = render_all(registry, target_root=workspace_root, dry_run=
True)
390 drifted = [result
for result
in dry_report.results
if result.drift_detected]
394 "checked": len(dry_report.results),
399 warnings = [f
"snapshot drift: {result.artifact_path}" for result
in drifted]
400 if not options.resolve_snapshot_conflict:
403 "checked": len(dry_report.results),
405 "warnings": warnings,
408 actor_slot = backend.active_operator_slot()
409 if actor_slot
is None:
412 "checked": len(dry_report.results),
414 "warnings": [
"no active operator slot available for snapshot repair"],
421 "checked": len(dry_report.results),
423 "warnings": warnings,
424 "messages": [
"snapshot repair skipped"],
426 lock = registry.acquire_lock(
427 "legacy-compat-write",
430 metadata={
"command":
"doctor --resolve-snapshot-conflict"},
433 if mode ==
"trust_registry":
434 render_report = render_all(registry, target_root=workspace_root)
437 for result
in render_report.results
438 if result.drift_detected
443 for result
in drifted
447 unresolved = [item
for item
in resolved
if item.get(
"status") !=
"resolved"]
449 "status":
"fixed" if not unresolved
else "warn",
450 "checked": len(dry_report.results),
451 "resolved": resolved,
452 "warnings": []
if not unresolved
else [str(item[
"message"])
for item
in unresolved],
453 "messages": [f
"resolved {len(resolved) - len(unresolved)} snapshot conflict(s)"],
458 """Return a compact human-readable doctor summary."""
461 f
"registry: {report['registry_path']}",
462 f
"workspace: {report['workspace_root']}",
469 if "auth_events" in report:
470 lines.append(
_section_line(
"auth events", report[
"auth_events"]))
471 if "snapshot_conflicts" in report:
472 lines.append(
_section_line(
"snapshot conflicts", report[
"snapshot_conflicts"]))
473 warnings = report.get(
"warnings", [])
474 if isinstance(warnings, list)
and warnings:
475 lines.append(
"warnings:")
476 lines.extend(f
" - {warning}" for warning
in warnings)
478 lines.append(
"doctor ok")
479 return "\n".join(lines)
483 if not isinstance(section, Mapping):
484 return f
"{label}: unknown"
485 status = section.get(
"status",
"unknown")
486 details: list[str] = []
487 if "removed" in section:
488 details.append(f
"removed={len(section.get('removed', []))}")
489 if "count" in section:
490 details.append(f
"swept={section.get('count')}")
491 if "checked" in section:
492 details.append(f
"checked={section.get('checked')}")
493 suffix =
"" if not details
else f
" ({', '.join(details)})"
494 messages = section.get(
"messages", [])
496 if isinstance(messages, list)
and messages:
497 message = f
" - {messages[0]}"
498 return f
"{label}: {status}{suffix}{message}"
505) -> Mapping[str, object] |
None:
506 if not isinstance(data, Mapping):
508 slot_block = data.get(slot)
509 if not isinstance(slot_block, Mapping):
511 phase_block = slot_block.get(phase)
512 if not isinstance(phase_block, Mapping):
518 secret_b64 = block.get(
"secret_b64")
519 if isinstance(secret_b64, str):
521 raw = base64.b64decode(secret_b64.encode(
"ascii"), validate=
True)
522 except (ValueError, UnicodeEncodeError):
525 return hashlib.sha256(raw).hexdigest()
526 return block.get(
"secret_fingerprint")
530 warnings: list[str] = []
531 for section
in sections:
532 section_warnings = section.get(
"warnings", [])
533 if not isinstance(section_warnings, list):
535 warnings.extend(str(warning)
for warning
in section_warnings)
543 "messages": [message],
548 if options.yes_trust_registry
and options.yes_trust_local:
549 raise ValueError(
"choose only one of --yes-trust-registry/--yes-trust-local")
550 if options.yes_trust_registry:
551 return "trust_registry"
552 if options.yes_trust_local:
554 answer = input(
"Resolve snapshot drift? [r=trust-registry, l=trust-local, s=skip] ")
555 if answer.strip().lower()
in {
"r",
"registry",
"trust-registry"}:
556 return "trust_registry"
557 if answer.strip().lower()
in {
"l",
"local",
"trust-local"}:
565 result: RenderResult,
567) -> dict[str, object]:
568 row = registry.execute_domain_command(
573 "repair_kind":
"snapshot_conflict",
575 "artifact_kind": result.artifact_kind,
576 "artifact_path": str(result.artifact_path),
577 "content_sha256": result.content_sha256,
579 actor_type=
"human_operator",
580 command=
"doctor --resolve-snapshot-conflict",
583 "status":
"resolved",
585 "artifact_path": str(result.artifact_path),
586 "audit_operation_id": row.id,
593 result: RenderResult,
594) -> dict[str, object]:
595 if result.artifact_kind !=
"plan_status":
597 "status":
"unsupported",
598 "action":
"trust_local",
599 "artifact_path": str(result.artifact_path),
601 "message":
"trust-local is only available for .PLAN_STATUS.md",
603 row = plan_for_worktree(registry, result.artifact_path.parent)
606 "status":
"unsupported",
607 "action":
"trust_local",
608 "artifact_path": str(result.artifact_path),
609 "message":
"no registry plan is assigned to this worktree",
611 previous_sha = str(row.get(
"body_sha256")
or "")
612 previous_observed = str(row.get(
"body_observed_at")
or registry.clock.now_wall())
613 new_sha = hashlib.sha256(result.artifact_path.read_bytes()).hexdigest()
614 plan_row = plan_reimport(
616 plan_id=str(row[
"plan_id"]),
617 previous_body_sha256=previous_sha,
618 new_body_sha256=new_sha,
619 previous_body_observed_at=previous_observed,
620 new_body_observed_at=registry.clock.now_wall(),
621 actor_slot=actor_slot,
622 actor_type=
"human_operator",
623 command=
"doctor trust-local plan-status",
626 repair[
"plan_reimport_operation_id"] = plan_row.id
dict[str, object] check_permissions(Path option_c, *, bool fix, bool skip)
str _resolution_mode(DoctorOptions options)
str _section_line(str label, object section)
dict[str, object] auth_event_summary(SQLiteBackend backend)
dict[str, object] check_round_state(SQLiteBackend backend)
dict[str, object] sweep_stray_temp_files(Path orchestration_dir)
dict[str, object] _audit_snapshot_repair(Registry registry, str actor_slot, RenderResult result, str action)
Mapping[str, object]|None _secret_block(object data, str slot, str phase)
dict[str, object] sweep_expired_locks(Registry registry, SQLiteBackend backend)
object _fingerprint_for_block(Mapping[str, object] block)
dict[str, object] check_credential_consistency(SQLiteBackend backend, Path secrets_path)
dict[str, object] _trust_local(Registry registry, str actor_slot, RenderResult result)
dict[str, object] check_snapshot_conflicts(Registry registry, SQLiteBackend backend, Path workspace_root, DoctorOptions options)
dict[str, object] _skipped(str message)
list[str] _warnings_from(*Mapping[str, object] sections)
dict[str, object] run_doctor(Registry registry, SQLiteBackend backend, Path workspace_root, DoctorOptions options)
str format_doctor_report(Mapping[str, object] report)