feat: completion-enforcement loop (fallback, proof, emit-model, auditor)

Close the loop so dispatched work actually completes on bl:

- on_no_result fallback in followup-sweeper (op + job forms via
  exec-constrained registry / job-dispatch), seeded on the three
  autonomy-pulse jobs; fallback_due() dedupes the gravity path
- gravity.py: add __main__ entry (loop-remediator.timer was a no-op),
  300s re-arm budget, fallback firing + stamp/skip logic
- harvester: proof-of-result followups (result_has_evidence),
  acted-variant NACK, emit-model tool-hint wording
- envelope: RESPONSE RULE states the emit model (agents EMIT
  directives verbatim; runtime executes; works from bare containers)
- completion-audit.py + systemd 15-min timer: per-family funnel,
  swarm drain, followup backlog; digest DM when degraded, 6h heartbeat
- tests/test_completion.py (29 tests), JOB-SPEC.md docs

Tests: 67/67 focused green (completion + tool_calls).
This commit is contained in:
Muse Sidechat
2026-10-06 08:20:14 +00:00
parent b7e45010c3
commit a9f014f9fa
12 changed files with 1173 additions and 16 deletions
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Completion auditor: prove work gets done, or say exactly where it stalls.
Runs on a 15-minute systemd timer (systemd/completion-audit.*). Reads
job-log.jsonl, swarms.json, and followups.json; computes the completion
funnel per job family plus swarm drain and followup backlog; writes a JSON
report under logs/ and posts a compact digest to the ops heartbeat sidechat
when degraded (or a heartbeat summary every 6h when green).
Read-only except the digest DM and its own log/state files. Exit 0 always
on a completed audit; tracebacks (real errors) fail the timer visibly.
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
JOB_LOG = REPO_ROOT / "job-log.jsonl"
SWARMS_FILE = REPO_ROOT / "swarms.json"
FOLLOWUPS_FILE = REPO_ROOT / "followups.json"
LOGS_DIR = REPO_ROOT / "logs"
STATE_FILE = LOGS_DIR / "completion-audit-state.json"
_JOB_ID_RE = re.compile(r"^(.+)-(\d{8})-(\d{6})-([0-9a-f]{8})$")
HEARTBEAT_INTERVAL_H = 6
STALE_RUNNING_MIN = 90
SILENT_MIN_SENT = 3
def utcnow():
return datetime.now(timezone.utc)
def family_of(job_id):
"""Strip the dispatch suffix (<name>-YYYYMMDD-HHMMSS-<hex8>) to the family."""
m = _JOB_ID_RE.match(job_id or "")
return m.group(1) if m else (job_id or "?")
def parse_ts(ts):
try:
t = datetime.fromisoformat(str(ts))
except Exception:
return None
if t.tzinfo is None:
t = t.replace(tzinfo=timezone.utc)
return t
def compute_funnel(events, cutoff):
"""Aggregate job-log events since cutoff.
Returns (families, tools) where families maps family -> counters and
tools holds global tool_exec stats. Pure over the event list.
"""
families = defaultdict(lambda: Counter())
tools = Counter()
tool_errs = Counter()
for e in events:
t = parse_ts(e.get("ts"))
if t is None or t < cutoff:
continue
ty = e.get("type")
if ty == "job_sent":
families[family_of(e.get("job_id"))]["sent"] += 1
elif ty == "job_dispatched":
families[family_of(e.get("job_id"))]["dispatched"] += 1
elif ty == "tool_exec":
tools["total"] += 1
if e.get("success"):
tools["ok"] += 1
else:
tools["fail"] += 1
tool_errs[e.get("op", "?")] += 1
elif ty == "job_result":
fam = family_of(e.get("job_id"))
families[fam]["results"] += 1
families[fam]["ok" if e.get("success") else "fail"] += 1
elif ty == "job_failed":
families[family_of(e.get("job_id"))]["failed"] += 1
elif ty == "fallback_executed":
families[family_of(e.get("job_id"))]["fallback_ok"] += 1
elif ty == "fallback_failed":
families[family_of(e.get("job_id"))]["fallback_fail"] += 1
elif ty == "proof_requested":
families[family_of(e.get("job_id"))]["proofs"] += 1
return families, {"tools": tools, "tool_errs": tool_errs}
def swarm_drain(now):
"""Status counts + stale-running slots from swarms.json."""
try:
data = json.load(open(SWARMS_FILE))
except Exception:
return {"error": "swarms.json unreadable"}, []
values = data.values() if isinstance(data, dict) else data
status = Counter()
stale = []
for s in values:
if not isinstance(s, dict):
continue
for sl in s.get("slots", []) or []:
status[sl.get("status", "?")] += 1
if sl.get("status") == "running":
upd = parse_ts(sl.get("updated_ts"))
if upd and (now - upd) > timedelta(minutes=STALE_RUNNING_MIN):
stale.append({
"swarm": s.get("swarm_id"),
"slot": sl.get("slot"),
"agent": sl.get("agent_id"),
"idle_min": int((now - upd).total_seconds() // 60),
})
return {"slots": dict(status)}, stale
def followup_backlog(now):
"""Pending/overdue/escalated counts from followups.json."""
try:
data = json.load(open(FOLLOWUPS_FILE))
except Exception:
return {"error": "followups.json unreadable"}
values = data.values() if isinstance(data, dict) else data
out = Counter()
for r in values:
if not isinstance(r, dict):
continue
st = r.get("status", "?")
out[st] += 1
if st == "pending":
dl = parse_ts(r.get("deadline"))
if dl and dl < now:
out["overdue"] += 1
return dict(out)
def build_report(hours):
now = utcnow()
cutoff = now - timedelta(hours=hours)
events = []
try:
with open(JOB_LOG) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except Exception:
continue
except FileNotFoundError:
pass
families, tools = compute_funnel(events, cutoff)
fam = {k: dict(v) for k, v in sorted(families.items())}
swarm, stale = swarm_drain(now)
backlog = followup_backlog(now)
totals = Counter()
for v in fam.values():
for k, n in v.items():
totals[k] += n
silent = sorted(
k for k, v in fam.items()
if v.get("sent", 0) >= SILENT_MIN_SENT and v.get("results", 0) == 0)
degraded_reasons = []
if silent:
degraded_reasons.append(f"{len(silent)} silent families: {', '.join(silent[:5])}")
if totals.get("failed"):
degraded_reasons.append(f"{totals['failed']} job_failed")
if tools["tools"].get("fail"):
top = tools["tool_errs"].most_common(3)
degraded_reasons.append(
"tool errors: " + ", ".join(f"{op}x{n}" for op, n in top))
if totals.get("fallback_fail"):
degraded_reasons.append(f"{totals['fallback_fail']} fallback_failed")
if stale:
degraded_reasons.append(f"{len(stale)} running slots idle >{STALE_RUNNING_MIN}m")
if backlog.get("overdue"):
degraded_reasons.append(f"{backlog['overdue']} overdue followups")
if backlog.get("escalated"):
degraded_reasons.append(f"{backlog['escalated']} escalated followups")
return {
"ts": now.isoformat(),
"window_h": hours,
"totals": dict(totals),
"tools": {k: dict(v) if isinstance(v, Counter) else v
for k, v in tools.items()},
"families": fam,
"silent_families": silent,
"swarms": swarm,
"stale_running": stale[:10],
"followups": backlog,
"degraded": bool(degraded_reasons),
"reasons": degraded_reasons,
}
def render_digest(rep):
t = rep["totals"]
tools = rep["tools"].get("tools", {})
lines = [
f"Completion audit ({rep['window_h']}h, {rep['ts'][:16]}Z)",
f"funnel: {t.get('sent', 0)} sent / {t.get('dispatched', 0)} dispatched / "
f"{tools.get('total', 0)} tool_exec / {t.get('results', 0)} results "
f"({t.get('ok', 0)} ok)",
]
if rep["silent_families"]:
lines.append("silent: " + ", ".join(rep["silent_families"][:6]))
bits = []
if t.get("failed"):
bits.append(f"{t['failed']} job_failed")
if tools.get("fail"):
bits.append(f"{tools['fail']} tool errors")
if t.get("fallback_ok") or t.get("fallback_fail"):
bits.append(f"fallback {t.get('fallback_ok', 0)} ok / {t.get('fallback_fail', 0)} fail")
if t.get("proofs"):
bits.append(f"{t['proofs']} proof reqs")
if bits:
lines.append("flags: " + ", ".join(bits))
sw = rep["swarms"].get("slots", {})
if sw:
lines.append("swarms now: " + " / ".join(f"{v} {k}" for k, v in sorted(sw.items())))
if rep["stale_running"]:
lines.append(f"stale running: {len(rep['stale_running'])} slots (see report)")
fb = rep["followups"]
if fb and "error" not in fb:
lines.append(
f"followups now: {fb.get('pending', 0)} pending / {fb.get('overdue', 0)} "
f"overdue / {fb.get('escalated', 0)} escalated")
if rep["degraded"]:
lines.append("verdict: DEGRADED — " + "; ".join(rep["reasons"][:3]))
else:
lines.append("verdict: HEALTHY — work flowing, results landing")
return "\n".join(lines)
def should_post(report):
"""Post on degraded, else heartbeat at most every HEARTBEAT_INTERVAL_H."""
if report["degraded"]:
return True, "degraded"
try:
state = json.load(open(STATE_FILE))
last = parse_ts(state.get("last_heartbeat"))
except Exception:
last = None
if last is None or (utcnow() - last) > timedelta(hours=HEARTBEAT_INTERVAL_H):
return True, "heartbeat"
return False, "green-quiet"
def post_digest(digest):
argv = [sys.executable, str(REPO_ROOT / "bin" / "dm.py"), "send",
"--agent", "super", "--to", "opm", "--target", "heartbeat", digest]
p = subprocess.run(argv, capture_output=True, text=True, timeout=120)
return p.returncode == 0, (p.stdout or p.stderr or "").strip()[:300]
def save_report(report):
LOGS_DIR.mkdir(parents=True, exist_ok=True)
stamp = report["ts"].replace("+00:00", "Z").replace(":", "")
dated = LOGS_DIR / f"completion-audit-{stamp[:15]}.json"
body = json.dumps(report, indent=2)
dated.write_text(body, encoding="utf-8")
latest = LOGS_DIR / "completion-audit-latest.json"
tmp = LOGS_DIR / f".completion-audit-latest.tmp.{os.getpid()}"
tmp.write_text(body, encoding="utf-8")
os.replace(tmp, latest)
return dated
def main():
ap = argparse.ArgumentParser(description="Completion funnel auditor")
ap.add_argument("--hours", type=int, default=24)
ap.add_argument("--post", dest="post", action="store_true", default=True)
ap.add_argument("--no-post", dest="post", action="store_false")
ap.add_argument("--json", action="store_true", help="Print raw report JSON")
args = ap.parse_args()
report = build_report(args.hours)
path = save_report(report)
if args.json:
print(json.dumps(report, indent=2))
else:
print(render_digest(report))
print(f"\nreport: {path}")
if not args.post:
print("post: skipped (--no-post)")
return 0
do_post, why = should_post(report)
if not do_post:
print(f"post: skipped ({why})")
return 0
ok, detail = post_digest(render_digest(report))
print(f"post: {'delivered' if ok else 'FAILED'} ({why}) {detail[:120]}")
if ok and why == "heartbeat":
try:
state = {}
if STATE_FILE.exists():
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
state["last_heartbeat"] = report["ts"]
STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
except Exception as e:
print(f"warning: state save failed: {e}")
return 0
if __name__ == "__main__":
sys.exit(main())