feat(plan-01): Phase 1 — relocate harness code to packages/casan-harness (symlink facade)
Physically move the pure-code subtrees out of .specify into the package, leaving compat symlinks at the old .specify/<dir> paths so every existing reference (internal CASAN_HARNESS_ROOT + external CI/docker/mjs) keeps resolving. Runtime state stays put. Moved (git mv): scripts/ tests/ security/ templates/ config/ governance/ memory/ .specify/<dir> -> packages/casan-harness/<dir> (+ .specify/<dir> symlink) Stays in .specify (state/governance/domain, handled later): logs/ agentops/ level5/ init-options.json traceability-map.json Python `.resolve()` self-location followed the compat symlink into packages and lost the app root; generate-casan-demo-context.py, generate-agentops-dashboard.py and dashboard-server.py now walk UP for the `.specify` state marker instead of a fixed parent depth (fixes "missing trace files" in run-casan4). Full gate: PASS=64 FAIL=0 SKIP=3 (CASAN_CI_STEP_TIMEOUT_SEC=1200 — track-a ~450s runs close to the 600s default and can tip over under load; this is timing variance, not a regression — it passed cleanly with headroom). Runtime log/audit artifacts kept unstaged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2c765c9a45
commit
664bd1f00c
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN No-progress / Oscillation Detector (Plan-17 Track 2, harness-owned).
|
||||
|
||||
Watches a run's per-step observations and decides whether the loop is CONVERGING,
|
||||
STALLED (no forward progress), or OSCILLATING (repeating/thrashing actions). A
|
||||
stalled or oscillating loop is halted or escalated (on_stall policy) instead of
|
||||
being allowed to burn budget going nowhere.
|
||||
|
||||
Detection rules (thresholds from loop-policy.yaml, else strictest built-in):
|
||||
* oscillation = the last N observations share one action_hash (stuck repeating),
|
||||
* thrash = the last `thrash_window` steps alternate between exactly 2
|
||||
action_hashes (A,B,A,B...),
|
||||
* no-progress = the last W observations made zero forward progress
|
||||
(progress never exceeded the running best).
|
||||
|
||||
Deny-by-default & fail-closed: absent/unknown policy => strictest thresholds
|
||||
(detect sooner); a corrupt policy => ESCALATE/HALT, never silent CONVERGING.
|
||||
Insufficient data (< window) => CONVERGING (the Budget Governor is the hard stop).
|
||||
|
||||
Usage:
|
||||
loop-convergence.py observe --run-id R --step N --action-hash H --progress P
|
||||
loop-convergence.py verdict --run-id R [--profile prod|dev]
|
||||
|
||||
Exit codes:
|
||||
0 CONVERGING (or observe recorded ok)
|
||||
3 HALT (STALLED/OSCILLATING with on_stall=halt, OR fail-closed error)
|
||||
4 ESCALATE (STALLED/OSCILLATING with on_stall=escalate)
|
||||
2 usage error
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import loop_common as lc
|
||||
|
||||
|
||||
def _emit(payload, stream=sys.stdout):
|
||||
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def _obs_path(run_id):
|
||||
return os.path.join(lc.run_dir(run_id), "convergence.jsonl")
|
||||
|
||||
|
||||
def _load_obs(run_id):
|
||||
path = _obs_path(run_id)
|
||||
rows = []
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except ValueError:
|
||||
# A corrupt observation stream cannot be trusted.
|
||||
raise lc.PolicyError("observation_stream_corrupt")
|
||||
rows.sort(key=lambda r: r.get("step", 0))
|
||||
return rows
|
||||
|
||||
|
||||
def cmd_observe(args):
|
||||
path = _obs_path(args.run_id)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
rec = {
|
||||
"step": args.step,
|
||||
"action_hash": args.action_hash,
|
||||
"progress": args.progress,
|
||||
"ts": lc.now_iso(),
|
||||
}
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(rec, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
_emit({"decision": "OBSERVED", "run_id": args.run_id, "step": args.step})
|
||||
return 0
|
||||
|
||||
|
||||
def _detect_oscillation(hashes, cfg):
|
||||
n = cfg["oscillation_repeat"]
|
||||
if len(hashes) >= n and len(set(hashes[-n:])) == 1:
|
||||
return {"pattern": "repeat", "action_hash": hashes[-1], "count": n}
|
||||
w = cfg["thrash_window"]
|
||||
if len(hashes) >= w:
|
||||
window = hashes[-w:]
|
||||
distinct = set(window)
|
||||
if len(distinct) == 2:
|
||||
# A,B,A,B... : every element differs from its immediate neighbour.
|
||||
if all(window[i] != window[i + 1] for i in range(len(window) - 1)):
|
||||
return {"pattern": "thrash", "actions": sorted(distinct), "window": w}
|
||||
return None
|
||||
|
||||
|
||||
def _detect_no_progress(progresses, cfg):
|
||||
w = cfg["no_progress_window"]
|
||||
if len(progresses) < w:
|
||||
return None
|
||||
# Mark each step that improved on the running best; STALLED if the last W
|
||||
# steps contain no improvement at all.
|
||||
best = None
|
||||
improved = []
|
||||
for p in progresses:
|
||||
if p is None:
|
||||
improved.append(False)
|
||||
continue
|
||||
if best is None or p > best:
|
||||
improved.append(True)
|
||||
best = p
|
||||
else:
|
||||
improved.append(False)
|
||||
if not any(improved[-w:]):
|
||||
return {"window": w, "last_progress": progresses[-1]}
|
||||
return None
|
||||
|
||||
|
||||
def cmd_verdict(args):
|
||||
prof = args.profile or lc.profile()
|
||||
try:
|
||||
policy = lc.load_policy()
|
||||
cfg, source = lc.resolve_convergence(policy, prof)
|
||||
rows = _load_obs(args.run_id)
|
||||
except lc.PolicyError as exc:
|
||||
payload = {
|
||||
"decision": "HALT",
|
||||
"reason": "fail_closed",
|
||||
"detail": str(exc),
|
||||
"run_id": args.run_id,
|
||||
"provenance": lc.provenance("loop-convergence", None, verified=False),
|
||||
}
|
||||
lc.append_audit({"kind": "convergence_halt", **payload})
|
||||
_emit(payload)
|
||||
return 3
|
||||
|
||||
hashes = [r.get("action_hash") for r in rows]
|
||||
progresses = [r.get("progress") for r in rows]
|
||||
|
||||
osc = _detect_oscillation(hashes, cfg)
|
||||
stall = None if osc else _detect_no_progress(progresses, cfg)
|
||||
|
||||
if osc or stall:
|
||||
verdict = "OSCILLATING" if osc else "STALLED"
|
||||
on_stall = cfg.get("on_stall", "escalate")
|
||||
decision = "HALT" if on_stall == "halt" else "ESCALATE"
|
||||
payload = {
|
||||
"decision": decision,
|
||||
"verdict": verdict,
|
||||
"run_id": args.run_id,
|
||||
"profile": prof,
|
||||
"policy_source": source,
|
||||
"thresholds": cfg,
|
||||
"observations": len(rows),
|
||||
"evidence": osc or stall,
|
||||
"on_stall": on_stall,
|
||||
"provenance": lc.provenance("loop-convergence", None, verified=policy is not None),
|
||||
}
|
||||
lc.append_audit({"kind": "convergence_" + verdict.lower(), **payload})
|
||||
_emit(payload)
|
||||
return 3 if decision == "HALT" else 4
|
||||
|
||||
payload = {
|
||||
"decision": "CONVERGING",
|
||||
"verdict": "CONVERGING",
|
||||
"run_id": args.run_id,
|
||||
"profile": prof,
|
||||
"policy_source": source,
|
||||
"thresholds": cfg,
|
||||
"observations": len(rows),
|
||||
"provenance": lc.provenance("loop-convergence", None, verified=policy is not None),
|
||||
}
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(description="CASAN Convergence Detector (Plan-17 T2)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
o = sub.add_parser("observe", help="Record a per-step observation")
|
||||
o.add_argument("--run-id", required=True)
|
||||
o.add_argument("--step", type=int, required=True)
|
||||
o.add_argument("--action-hash", required=True)
|
||||
o.add_argument("--progress", type=float, required=True)
|
||||
o.set_defaults(func=cmd_observe)
|
||||
|
||||
v = sub.add_parser("verdict", help="Classify the run: CONVERGING|STALLED|OSCILLATING")
|
||||
v.add_argument("--run-id", required=True)
|
||||
v.add_argument("--profile", default=None)
|
||||
v.set_defaults(func=cmd_verdict)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "HALT", "reason": "fail_closed", "detail": str(exc)})
|
||||
return 3
|
||||
except Exception as exc: # never fail open
|
||||
_emit({"decision": "HALT", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user