the-custodian/scripts/monitor_dependency_triage.py
codex 84f2e1899c
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Python Tests / pytest (push) Successful in 24s
CUST-WP-0075: add repeatable dependency monitor and handoff evidence
2026-10-03 23:10:54 +02:00

140 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Read-only CUST-WP-0075 snapshot. Requires PyYAML for the file scanner.
Writes evidence only to --output; never changes owner records or contacts owners.
"""
import argparse
from collections import Counter
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import subprocess
import sys
from urllib.parse import urlencode
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
SCANNER = ROOT / "docs/evidence/2026-10-03-dependency-graph-triage/scan_workplan_files.py"
OPEN = {"proposed", "ready", "active", "blocked", "backlog"}
HANDOFFS = {
"state-hub": "b4825539-1425-4028-a2e9-9243785a344b",
"railiance-platform": "f84637fa-2355-4971-a2dc-7f1fd01710a8",
"ops-warden": "79549660-4fc8-44d5-989e-074f6c7b6f07",
}
def get_rows(base, route, **filters):
# Only /tasks has offset pagination. /messages uses a caller-selected
# limit; /repos returns the complete registry without paging parameters.
if route != "tasks":
query = urlencode(dict(filters, limit=5000)) if route == "messages" else urlencode(filters)
with urlopen(f"{base.rstrip('/')}/{route}/?{query}", timeout=30) as response:
rows = json.load(response)
if isinstance(rows, dict):
rows = rows["items"]
if route == "messages" and len(rows) >= 5000:
raise ValueError("message limit reached; narrow the owner filter before scanning")
return rows
rows = []
while True:
query = urlencode(dict(filters, limit=200, offset=len(rows)))
with urlopen(f"{base.rstrip('/')}/{route}/?{query}", timeout=30) as response:
page = json.load(response)
if isinstance(page, dict):
page = page["items"]
if not isinstance(page, list):
raise ValueError(f"{route}: expected a list")
rows.extend(page)
if len(page) < 200:
return rows
def audit(records, hub_tasks):
plans, tasks = records["WP"], records["T"]
def terminal(token):
if token in tasks:
return tasks[token]["status"] in {"done", "cancel"}
return records["WP"].get(token, {}).get("status") in {"finished", "archived"}
plans = {k: v for k, v in plans.items() if v["status"] in OPEN and v["flavor"] != "residual"}
waits = {k: v for k, v in tasks.items() if v["wp"] in plans and v["status"] == "wait"}
by_id = {t["id"]: t for t in hub_tasks}
drift = []
for tid, task in sorted(waits.items()):
hub = by_id.get(task["hub_id"])
if hub is None:
drift.append({"task": tid, "field": "projection", "file": task["hub_id"], "hub": None})
continue
fields = {"status": "status", "needs_human": "needs_human", "reason": "blocking_reason",
"decision_id": "decision_id", "blocked_on": "blocked_on", "wait_until": "wait_until"}
for file_key, hub_key in fields.items():
expected = task[file_key]
actual = hub.get(hub_key)
if file_key == "needs_human":
actual = bool(actual)
elif file_key != "status":
expected, actual = expected or None, actual or None
if file_key == "wait_until":
expected = str(expected)[:10] if expected else None
actual = str(actual)[:10] if actual else None
if expected != actual:
drift.append({"task": tid, "field": hub_key, "file": expected, "hub": actual})
return {
"open_non_residual_workplans": len(plans),
"workplan_statuses": dict(sorted(Counter(p["status"] for p in plans.values()).items())),
"wait_tasks": len(waits),
"human_gates": sum(t["needs_human"] for t in waits.values()),
"unrouted_human_gates": sorted(k for k, t in waits.items() if t["needs_human"] and not t["decision_id"]),
"unqualified_waits": sorted(k for k, t in waits.items() if not any(
t[field] for field in ("depends_on", "needs_human", "blocked_on", "wait_until"))),
"owner_return_waits": sum(str(t["blocked_on"] or "").startswith("message-from:") for t in waits.values()),
"terminal_dependency_waits": [
{"task": k, "depends_on": t["depends_on"], "needs_human": t["needs_human"],
"blocked_on": t["blocked_on"], "wait_until": t["wait_until"]}
for k, t in sorted(waits.items()) if t["depends_on"] and all(terminal(x) for x in t["depends_on"])],
"qualifier_drift": drift,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api-base", default="http://127.0.0.1:8000")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
repos = get_rows(args.api_base, "repos")
(args.output / "repos.json").write_text(json.dumps(repos))
result = subprocess.run([sys.executable, str(SCANNER)], capture_output=True, text=True,
env=dict(os.environ, TRIAGE_SCRATCH=str(args.output.resolve())), check=True)
(args.output / "file-view.txt").write_text(result.stdout)
records = json.loads((args.output / "files.json").read_text())
report = audit(records, get_rows(args.api_base, "tasks"))
report["parse_warnings"] = records.get("parse_warnings", [])
report["captured_at"] = datetime.now(timezone.utc).isoformat()
report["registered_repos"] = len(repos)
report["unavailable_checkouts"] = sorted(r["slug"] for r in repos
if not r.get("local_path") or not Path(r["local_path"]).is_dir())
report["handoffs"] = []
for owner, message_id in HANDOFFS.items():
incoming = get_rows(args.api_base, "messages", to_agent="the-custodian", from_agent=owner)
outgoing = get_rows(args.api_base, "messages", to_agent=owner, from_agent="the-custodian")
original = next(m for m in outgoing if m["id"] == message_id)
candidates = [m for m in incoming if m["from_agent"] == owner
and m["created_at"] > original["created_at"]]
report["handoffs"].append({
"case_id": f"case:cross-owner:CUST-WP-0075-T06:{owner}",
"scenario_type": "cross_owner_wait",
"beneficiary_workplan_id": "CUST-WP-0075",
"obligor_repo": owner,
"obligor_workplan_id": None,
"state": "waiting", "reason": "Owner acceptance or decline not yet recorded",
"proposal_message_id": message_id, "proposal_read_at": original.get("read_at"),
"reply_candidates": [{"id": m["id"], "subject": m["subject"], "thread_id": m.get("thread_id")}
for m in candidates],
})
(args.output / "monitor.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()