CUST-WP-0075: add repeatable dependency monitor and handoff evidence
This commit is contained in:
parent
c4558d4d10
commit
84f2e1899c
6 changed files with 548 additions and 8 deletions
|
|
@ -25,3 +25,39 @@ Run with `TRIAGE_SCRATCH=<dir>` holding `repos.json`, `wps.json`, `waits.json`,
|
|||
- Top roots by transitive fan-in: `SAND-WP-0015-T04` (7 workplans),
|
||||
`SECRETS-WP-0009-T03` (5), `GLAS-WP-0012` (4), `NK-WP-0040` (4),
|
||||
`FEP-WP-0002-T03` (4, a `todo` — not blocked at all), `RAPPS-WP-0014-T03` (3).
|
||||
|
||||
## Repeatable monitor (CUST-WP-0075-T05)
|
||||
|
||||
From the Custodian checkout, with Python and PyYAML installed:
|
||||
|
||||
```bash
|
||||
python3 scripts/monitor_dependency_triage.py --output /tmp/dependency-triage
|
||||
```
|
||||
|
||||
The monitor makes GET requests only. It captures the repository registry,
|
||||
scans available owner files, compares every open non-residual wait's status
|
||||
and qualifiers with its hub projection, and records the three T06 handoffs.
|
||||
`monitor.json` contains counts and actionable findings; `file-view.txt` contains
|
||||
the graph analysis. Raw registry and file indexes stay in the scratch directory.
|
||||
No schedule, worker injection, owner-file change, or decision resolution is enabled.
|
||||
|
||||
`unavailable_checkouts` and `parse_warnings` make coverage limits explicit.
|
||||
The scanner now reads YAML lists and quoted text correctly; historic task
|
||||
fences containing prose use a reported fallback. The detailed file view includes
|
||||
residuals for observation; the monitor's actionable counts exclude them.
|
||||
Terminal predecessors are candidates for review: independent human and owner
|
||||
gates remain binding. Replies newer than a proposal are candidates, not evidence
|
||||
of acceptance; reviewed owner workplan IDs or declines still belong in T06.
|
||||
|
||||
The refreshed snapshot is [monitor-2026-10-03.json](monitor-2026-10-03.json).
|
||||
It supersedes the earlier headline counts for the visible checkouts. Four new
|
||||
unrouted human gates, sixteen unqualified waits, and twelve qualifier differences
|
||||
need follow-up through T05; the earlier T02/T03 completion records are historical.
|
||||
Six registered checkouts were unavailable. The sixteen degraded parses are in
|
||||
historic Vergabe workplans. These results do not establish fleet-wide completeness.
|
||||
|
||||
Focused regression checks:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p test_dependency_triage.py
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
{
|
||||
"open_non_residual_workplans": 100,
|
||||
"workplan_statuses": {
|
||||
"active": 1,
|
||||
"backlog": 5,
|
||||
"blocked": 94
|
||||
},
|
||||
"wait_tasks": 232,
|
||||
"human_gates": 79,
|
||||
"unrouted_human_gates": [
|
||||
"FEP-WP-0009-T01",
|
||||
"INFD-WP-0005-T05",
|
||||
"INFD-WP-0005-T10",
|
||||
"RPF-WP-0035-T08"
|
||||
],
|
||||
"unqualified_waits": [
|
||||
"EMERGENT-WP-0002-T02",
|
||||
"EMERGENT-WP-0002-T05",
|
||||
"EMERGENT-WP-0002-T07",
|
||||
"EMERGENT-WP-0002-T08",
|
||||
"EMERGENT-WP-0002-T09",
|
||||
"EMERGENT-WP-0002-T10",
|
||||
"NK-WP-0011-T02",
|
||||
"NK-WP-0011-T03",
|
||||
"NK-WP-0011-T04",
|
||||
"NK-WP-0011-T05",
|
||||
"NK-WP-0011-T06",
|
||||
"NK-WP-0011-T07",
|
||||
"NK-WP-0011-T08",
|
||||
"NK-WP-0011-T09",
|
||||
"QONTO-WP-0006-T02",
|
||||
"QONTO-WP-0006-T03"
|
||||
],
|
||||
"owner_return_waits": 56,
|
||||
"terminal_dependency_waits": [
|
||||
{
|
||||
"task": "ACTIVITY-WP-0042-T05",
|
||||
"depends_on": [
|
||||
"ACTIVITY-WP-0042-T01"
|
||||
],
|
||||
"needs_human": false,
|
||||
"blocked_on": null,
|
||||
"wait_until": null
|
||||
},
|
||||
{
|
||||
"task": "ACTIVITY-WP-0042-T08",
|
||||
"depends_on": [
|
||||
"RPF-WP-0049-T05"
|
||||
],
|
||||
"needs_human": false,
|
||||
"blocked_on": null,
|
||||
"wait_until": null
|
||||
},
|
||||
{
|
||||
"task": "INFD-WP-0005-T05",
|
||||
"depends_on": [
|
||||
"INFD-WP-0005-T04"
|
||||
],
|
||||
"needs_human": true,
|
||||
"blocked_on": "message-from:railiance-platform",
|
||||
"wait_until": null
|
||||
},
|
||||
{
|
||||
"task": "USER-WP-0037-T03",
|
||||
"depends_on": [
|
||||
"USER-WP-0037-T02"
|
||||
],
|
||||
"needs_human": false,
|
||||
"blocked_on": "message-from:state-hub",
|
||||
"wait_until": null
|
||||
}
|
||||
],
|
||||
"qualifier_drift": [
|
||||
{
|
||||
"task": "ACTIVITY-WP-0042-T08",
|
||||
"field": "blocking_reason",
|
||||
"file": "Provider prerequisite verified at lane KV4; scoped consumer bundle/hostPath and env refresh remain unperformed. Exact primary projection also blocked by existing historical identifier compatibility in repo-manager.",
|
||||
"hub": "Provider repair verified: lane KV4 coordinates-only CAS, encrypted upload/readback and independent decryption. Remaining reviewed consumer source/env refresh and acceptance unperformed; exact primary projection blocked by existing repo-manager historical identifier compatibility."
|
||||
},
|
||||
{
|
||||
"task": "ADAPTIVE-WP-0010-T01",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:tenant-engine",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "FI-WP-0005-T04",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:activity-core",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "FI-WP-0005-T08",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:rein-aharness",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "INFO-WP-0029-T05",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:audit-core",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "KG-WP-0005-T03",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:qonto-assistant",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "NK-WP-0027-T06",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:info-tech-canon",
|
||||
"hub": "message-from:ops-warden"
|
||||
},
|
||||
{
|
||||
"task": "NK-WP-0035-T04",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:approval-engine",
|
||||
"hub": "message-from:info-tech-canon"
|
||||
},
|
||||
{
|
||||
"task": "RAPP-QONTO-WP-0002-T01",
|
||||
"field": "wait_until",
|
||||
"file": "2026-10-31",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "RAPP-TELEMETRY-WP-0001-T04",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:railiance-platform",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "REINAH-WP-0003-T04",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:activity-core",
|
||||
"hub": null
|
||||
},
|
||||
{
|
||||
"task": "SAND-WP-0015-T04",
|
||||
"field": "blocked_on",
|
||||
"file": "message-from:railiance-platform",
|
||||
"hub": null
|
||||
}
|
||||
],
|
||||
"parse_warnings": [
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0001-projektgeruest.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0002-fachmodelle.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0003-basis-ui.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0005-lose-anforderungen.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0006-aufgaben-bieterfragen.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0007-dokumente.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0008-preise.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0009-abgabe-nachbetrachtung.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0010-partner-bibliothek.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0011-marktbegleiter.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0012-querschnitt.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0013-feedback-bugs.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0014-aufgaben-lose-scores.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0016-issue-facade-integration.md",
|
||||
"/home/worsch/vergabe-teilnahme/workplans/VT-WP-0017-whynot-design-tokens.md"
|
||||
],
|
||||
"captured_at": "2026-10-03T21:08:51.190098+00:00",
|
||||
"registered_repos": 146,
|
||||
"unavailable_checkouts": [
|
||||
"access-engine",
|
||||
"inter-hub",
|
||||
"markitect-project",
|
||||
"railiance-bootstrap",
|
||||
"railiance-hosts",
|
||||
"repo-manager"
|
||||
],
|
||||
"handoffs": [
|
||||
{
|
||||
"case_id": "case:cross-owner:CUST-WP-0075-T06:state-hub",
|
||||
"scenario_type": "cross_owner_wait",
|
||||
"beneficiary_workplan_id": "CUST-WP-0075",
|
||||
"obligor_repo": "state-hub",
|
||||
"obligor_workplan_id": null,
|
||||
"state": "waiting",
|
||||
"reason": "Owner acceptance or decline not yet recorded",
|
||||
"proposal_message_id": "b4825539-1425-4028-a2e9-9243785a344b",
|
||||
"proposal_read_at": null,
|
||||
"reply_candidates": []
|
||||
},
|
||||
{
|
||||
"case_id": "case:cross-owner:CUST-WP-0075-T06:railiance-platform",
|
||||
"scenario_type": "cross_owner_wait",
|
||||
"beneficiary_workplan_id": "CUST-WP-0075",
|
||||
"obligor_repo": "railiance-platform",
|
||||
"obligor_workplan_id": null,
|
||||
"state": "waiting",
|
||||
"reason": "Owner acceptance or decline not yet recorded",
|
||||
"proposal_message_id": "f84637fa-2355-4971-a2dc-7f1fd01710a8",
|
||||
"proposal_read_at": null,
|
||||
"reply_candidates": [
|
||||
{
|
||||
"id": "84cb5557-cc89-43fa-b8b8-13508c5ea52b",
|
||||
"subject": "Re: D1 readiness: two lanes need a reviewed command before they can be approved",
|
||||
"thread_id": "208c18ec-79fe-4b21-8cc8-928ecccfd4c2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "case:cross-owner:CUST-WP-0075-T06:ops-warden",
|
||||
"scenario_type": "cross_owner_wait",
|
||||
"beneficiary_workplan_id": "CUST-WP-0075",
|
||||
"obligor_repo": "ops-warden",
|
||||
"obligor_workplan_id": null,
|
||||
"state": "waiting",
|
||||
"reason": "Owner acceptance or decline not yet recorded",
|
||||
"proposal_message_id": "79549660-4fc8-44d5-989e-074f6c7b6f07",
|
||||
"proposal_read_at": null,
|
||||
"reply_candidates": [
|
||||
{
|
||||
"id": "94d07ae0-a2a4-47f8-8bf7-9957aaf2a84f",
|
||||
"subject": "Re: BUG: warden secret-fetch policy check is sent to the SSH signing pin and always 403s for non-ops-warden owners",
|
||||
"thread_id": "faf11061-b490-435a-b472-cb315a459760"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,15 +1,31 @@
|
|||
import json,re,os,glob,collections,datetime,sys
|
||||
import yaml
|
||||
import os; S=os.environ.get('TRIAGE_SCRATCH','.')
|
||||
repos=json.load(open(f'{S}/repos.json')); repos=repos if isinstance(repos,list) else repos['items']
|
||||
FM=re.compile(r'^---\n(.*?)\n---',re.S); TB=re.compile(r'```task\n(.*?)```',re.S)
|
||||
parse_warnings=[]
|
||||
def kv(block):
|
||||
d={}
|
||||
for line in block.splitlines():
|
||||
m=re.match(r'^([A-Za-z_]+):\s*(.*?)\s*(#.*)?$',line)
|
||||
if m and not line.startswith(' ') : d[m.group(1)]=m.group(2).strip().strip('"')
|
||||
try:
|
||||
d=yaml.safe_load(block)
|
||||
except yaml.YAMLError:
|
||||
# Historic records mix prose into task fences. Preserve their fields,
|
||||
# but expose the degraded parse rather than silently certify the scan.
|
||||
parse_warnings.append(f)
|
||||
d={}
|
||||
for match in re.finditer(r'^([A-Za-z_]+):\s*([^\n]*)$',block,re.M):
|
||||
key,value=match.groups()
|
||||
if not value.strip():
|
||||
tail=block[match.end():]
|
||||
items=re.match(r'\n((?:[ \t]+-[ \t]+[^\n]*\n?)+)',tail)
|
||||
value='\n'+items.group(1) if items else ''
|
||||
try: d[key]=yaml.safe_load(value)
|
||||
except yaml.YAMLError: d[key]=value.strip().strip('"')
|
||||
if not isinstance(d,dict): raise ValueError('work record must be a YAML mapping')
|
||||
return d
|
||||
def lst(v):
|
||||
if not v: return []
|
||||
if isinstance(v,list): return [str(x) for x in v]
|
||||
v=str(v)
|
||||
v=v.strip()
|
||||
if v.startswith('['): return [x.strip().strip('"\'') for x in v[1:-1].split(',') if x.strip()]
|
||||
return [v]
|
||||
|
|
@ -30,8 +46,8 @@ for r in repos:
|
|||
for tb in TB.findall(txt):
|
||||
d=kv(tb); tid=d.get('id')
|
||||
if not tid: continue
|
||||
T[tid]={'wp':wid,'repo':r['slug'],'status':d.get('status'),'depends_on':lst(d.get('depends_on')),'needs_human':d.get('needs_human','').lower()=='true','blocked_on':d.get('blocked_on') or None,'wait_until':d.get('wait_until') or None,'decision_id':d.get('decision_id') or None,'reason':d.get('blocking_reason') or ''}
|
||||
json.dump({'WP':WP,'T':T},open(f'{S}/files.json','w'))
|
||||
T[tid]={'wp':wid,'repo':r['slug'],'status':d.get('status'),'depends_on':lst(d.get('depends_on')),'needs_human':str(d.get('needs_human','')).lower()=='true','blocked_on':d.get('blocked_on') or None,'wait_until':str(d['wait_until']) if d.get('wait_until') else None,'decision_id':d.get('decision_id') or None,'reason':d.get('blocking_reason') or '', 'hub_id':d.get('state_hub_task_id')}
|
||||
json.dump({'WP':WP,'T':T,'parse_warnings':sorted(set(parse_warnings))},open(f'{S}/files.json','w'))
|
||||
TW={'finished','archived'}; TT={'done','cancel'}
|
||||
def term(x):
|
||||
if x in T: return T[x]['status'] in TT
|
||||
|
|
@ -100,7 +116,7 @@ for n in nodes:
|
|||
if any(not term(b) for b in dep.get(n,[])): continue
|
||||
f=fanin(n)
|
||||
if f: rows.append((len(f),n,sorted(f)))
|
||||
rows.sort(key=lambda r:-r[0])
|
||||
rows.sort(key=lambda r:(-r[0],r[1]))
|
||||
for n,k,f in rows[:45]:
|
||||
t=T.get(k);
|
||||
desc=(f"wait h={t['needs_human']} on={t['blocked_on']} dec={t['decision_id']} | {t['reason'][:70]}" if t and t['status']=='wait' else (f"{t['status']}" if t else f"WP {WP[k]['status']} {WP[k]['repo']}" if k in WP else 'UNKNOWN'))
|
||||
|
|
|
|||
140
scripts/monitor_dependency_triage.py
Normal file
140
scripts/monitor_dependency_triage.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/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()
|
||||
98
tests/test_dependency_triage.py
Normal file
98
tests/test_dependency_triage.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("monitor", ROOT / "scripts/monitor_dependency_triage.py")
|
||||
monitor = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(monitor)
|
||||
|
||||
|
||||
class DependencyTriageTests(unittest.TestCase):
|
||||
def test_yaml_task_lists_and_quoted_hash_are_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
scratch = Path(directory)
|
||||
repo = scratch / "repo"
|
||||
(repo / "workplans").mkdir(parents=True)
|
||||
(scratch / "repos.json").write_text(json.dumps([{"slug": "example", "local_path": str(repo)}]))
|
||||
(repo / "workplans/plan.md").write_text('''---
|
||||
id: EX-WP-0001
|
||||
type: workplan
|
||||
status: blocked
|
||||
flavor: implementation
|
||||
---
|
||||
```task
|
||||
id: EX-WP-0001-T01
|
||||
status: wait
|
||||
depends_on:
|
||||
- EX-WP-0002-T01
|
||||
needs_human: true
|
||||
blocking_reason: "Review packet #1"
|
||||
wait_until: 2026-10-04
|
||||
```
|
||||
''')
|
||||
subprocess.run([sys.executable, str(monitor.SCANNER)], check=True, capture_output=True,
|
||||
env=dict(os.environ, TRIAGE_SCRATCH=directory))
|
||||
task = json.loads((scratch / "files.json").read_text())["T"]["EX-WP-0001-T01"]
|
||||
self.assertEqual(task["depends_on"], ["EX-WP-0002-T01"])
|
||||
self.assertEqual(task["reason"], "Review packet #1")
|
||||
self.assertTrue(task["needs_human"])
|
||||
self.assertEqual(task["wait_until"], "2026-10-04")
|
||||
|
||||
def records(self):
|
||||
task = {"wp": "plan", "status": "wait", "hub_id": "uuid", "needs_human": True,
|
||||
"reason": "Review", "decision_id": "decision", "blocked_on": None,
|
||||
"wait_until": None, "depends_on": []}
|
||||
return {"WP": {"plan": {"status": "blocked", "flavor": "implementation"}}, "T": {"task": task}}
|
||||
|
||||
def test_missing_projection_is_visible(self):
|
||||
result = monitor.audit(self.records(), [])
|
||||
self.assertEqual(result["qualifier_drift"][0]["field"], "projection")
|
||||
|
||||
def test_decision_sync_gap_is_detected(self):
|
||||
result = monitor.audit(self.records(), [{"id": "uuid", "status": "wait",
|
||||
"needs_human": True, "blocking_reason": "Review"}])
|
||||
self.assertEqual(result["qualifier_drift"], [{"task": "task", "field": "decision_id",
|
||||
"file": "decision", "hub": None}])
|
||||
self.assertEqual(result["unrouted_human_gates"], [])
|
||||
|
||||
def test_residuals_are_excluded_from_actionable_findings(self):
|
||||
records = self.records()
|
||||
records["WP"]["plan"]["flavor"] = "residual"
|
||||
result = monitor.audit(records, [])
|
||||
self.assertEqual(result["wait_tasks"], 0)
|
||||
self.assertEqual(result["qualifier_drift"], [])
|
||||
|
||||
def test_terminal_predecessor_keeps_separate_human_gate(self):
|
||||
records = self.records()
|
||||
records["T"]["task"]["depends_on"] = ["predecessor"]
|
||||
records["T"]["predecessor"] = {"wp": "plan", "status": "done"}
|
||||
result = monitor.audit(records, [])
|
||||
self.assertTrue(result["terminal_dependency_waits"][0]["needs_human"])
|
||||
self.assertEqual(result["human_gates"], 1)
|
||||
|
||||
def test_tasks_follow_offset_pagination(self):
|
||||
response = MagicMock()
|
||||
with patch.object(monitor, "urlopen", return_value=response) as fetch, patch.object(
|
||||
monitor.json, "load", side_effect=[[{"id": str(i)} for i in range(200)], [{"id": "last"}]]):
|
||||
rows = monitor.get_rows("http://hub", "tasks")
|
||||
self.assertEqual(len(rows), 201)
|
||||
self.assertIn("offset=200", fetch.call_args_list[1].args[0])
|
||||
|
||||
def test_messages_do_not_use_unsupported_offset(self):
|
||||
with patch.object(monitor, "urlopen", return_value=MagicMock()) as fetch, patch.object(
|
||||
monitor.json, "load", return_value=[{"id": str(i)} for i in range(200)]):
|
||||
rows = monitor.get_rows("http://hub", "messages", from_agent="owner")
|
||||
self.assertEqual(len(rows), 200)
|
||||
self.assertEqual(fetch.call_count, 1)
|
||||
self.assertNotIn("offset", fetch.call_args.args[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -126,6 +126,16 @@ After every slice: commit, `statehub fix-consistency` for each touched
|
|||
repo, one progress event on this workplan, and the headline numbers
|
||||
appended below so progress is visible from the hub.
|
||||
|
||||
2026-10-03 Codex continuation: repeatable read-only monitor implemented in
|
||||
`scripts/monitor_dependency_triage.py`, with seven regression checks covering
|
||||
YAML lists/quoting, pagination, missing projections, decision drift, residual
|
||||
exclusion and independent human gates. Run it with `--output <scratch-dir>`;
|
||||
review `monitor.json` before acting. Current live findings are retained in
|
||||
`docs/evidence/2026-10-03-dependency-graph-triage/monitor-2026-10-03.json`:
|
||||
four unrouted human gates, sixteen unqualified waits and twelve qualifier
|
||||
differences. This task remains in progress until those findings are reconciled;
|
||||
the earlier T02/T03 results are point-in-time, not a current clean scan.
|
||||
|
||||
## Hand the infrastructure proposals to their owners
|
||||
|
||||
```task
|
||||
|
|
@ -147,6 +157,14 @@ qualifier sync gap, already reported to state-hub as message `70d08f35`).
|
|||
the proposal and the evidence; record their workplan ids here. Do not
|
||||
write their workplans for them.
|
||||
|
||||
2026-10-03 Codex continuation: all three original proposal messages remain
|
||||
unread; no acceptance, owner workplan ID or decline found. Three local derived
|
||||
`cross_owner_wait` observations are attached under `handoffs` in the monitor
|
||||
snapshot, with stable case IDs and original message IDs. Obligor workplan IDs
|
||||
remain unknown pending owner identification. Newer platform/warden replies
|
||||
concern D1 readiness and the fetch-policy bug, respectively; neither answers
|
||||
these proposals. No worker was woken. T06 remains `wait`.
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-03 start: 108 open / 100 blocked / 232 wait / 69 human gates /
|
||||
|
|
@ -193,4 +211,11 @@ write their workplans for them.
|
|||
and two unread owner replies: GH-WP-0004-T06 (informed-decision) and
|
||||
NK-WP-0042-T02 (user-engine). Follow-up on the CCR-2026-0031 lane: ops-warden
|
||||
catalog entry plus fetch-pin regeneration (checklist row 6).
|
||||
|
||||
- 2026-10-03 Codex continuation: monitored 146 registered repos; six checkouts
|
||||
unavailable. Visible file view: 107 open plans including residuals; actionable
|
||||
non-residual view: 100 open / 94 blocked / 232 wait / 79 human gates, four
|
||||
unrouted; 56 owner returns. Twelve qualifier differences and sixteen
|
||||
unqualified waits are recorded as T05 follow-up. Four waits have terminal
|
||||
predecessors, but separate human/owner gates and task prose still need review.
|
||||
T06's three proposals remain unanswered. The monitor and seven regression
|
||||
checks are implemented; no fleet cleanup or proposal acceptance is claimed.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue