Restore followup/DM reliability fixes wiped by 19:43Z tree-clean

Re-applies three workstreams lost when 793d3d7 committed over uncommitted
edits, reconciled against the parallel track's committed dm.py changes:
- followup-sweeper.py: backfill thread_uuid after successful nudge sends;
  record final_nudge_target=main on final-nudge routing (C1/C2)
- response-harvester.py: resolve followups on main-chat replies when
  final_nudge_target=main (C3); harvest ALL [RESULT] markers per message
- dm.py: pre-send placement gate (fail closed when post-nav URL lacks the
  target thread UUID; skips main) — purely additive over 793d3d7+f268d3d
- sidechat_manager.py: wait_for_chat_list() settle-poll for list population
  race (sidebar button renders before titles load)
- new: bin/tests/test_followup_fixes.py (25 tests), bin/placement-audit.py,
  bin/dm-log-taxonomy.py, bin/session-probe.py,
  docs/SIDECHAT-RELIABILITY.md, docs/UUID-ROTATION.md

Verified: 25/25 tests pass, py_compile clean, sweeper/harvester dry-runs clean.
Known limitation: gate catches wrong-placement, not wrong-mapping (false
autoprovision adopting the parked thread needs a creation check).
This commit is contained in:
operator-main
2026-10-04 20:06:58 +00:00
parent b5c5e2ff4c
commit ad9dbca7eb
10 changed files with 1984 additions and 51 deletions
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""dm-log-taxonomy.py — READ-ONLY failure taxonomy for fleet DM sidechat reliability.
Reads /home/super/Projects/NetVM/dm-log.jsonl, prints:
1. Event-type counts and send outcome rates
2. Sidechat nav failure taxonomy (per-target, per-agent-pair)
3. Failure timeline (hourly buckets, worst 10-min windows, by node)
4. "Ghost" rate: verified:true sidechat sends with no UUID anywhere
5. Hypothesis evidence tables (nav_failed reasons, placement pairs,
alias sources, autoprovision success, retry distribution)
No writes to any state files. Runs in <1s on the current log size.
"""
import json
import re
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone, timedelta
LOG = "/home/super/Projects/NetVM/dm-log.jsonl"
WINDOW_H = 48
def parse_ts(s):
if not s:
return None
try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except Exception:
return None
def main():
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=WINDOW_H)
events = []
parse_err = 0
with open(LOG, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
e = json.loads(line)
except Exception:
parse_err += 1
continue
e["_dt"] = parse_ts(e.get("ts"))
events.append(e)
in_win = [e for e in events if e["_dt"] and e["_dt"] >= cutoff]
print(f"dm-log.jsonl: {len(events)} total lines ({parse_err} parse errors)")
print(f"window: last {WINDOW_H}h -> {len(in_win)} events")
if in_win:
print(f" range: {in_win[0]['ts']} .. {in_win[-1]['ts']}")
print("=" * 78)
# ---- 1. event-type counts ------------------------------------------------
types = Counter(e.get("type", "?") for e in in_win)
print("\n[1] EVENT-TYPE COUNTS")
for t, n in types.most_common():
print(f" {n:6d} {t}")
# ---- per-send assembly ---------------------------------------------------
sends = {}
order = []
def S(e):
sid = e.get("id")
if not sid:
return None
if sid not in sends:
sends[sid] = {"id": sid, "events": [], "first_ts": e["_dt"]}
order.append(sid)
s = sends[sid]
s["events"].append(e)
for k in ("agent", "to", "target"):
if k not in s and e.get(k) is not None:
s[k] = e.get(k)
t = e.get("type")
if t == "verified":
s["verified_seen"] = True
if e.get("thread_uuid"):
s["uuids"] = s.get("uuids", set()) | {e["thread_uuid"]}
if t == "sent":
s["sent_seen"] = True
s["sent_verified"] = bool(e.get("verified"))
if e.get("thread_uuid"):
s["uuids"] = s.get("uuids", set()) | {e["thread_uuid"]}
tags = e.get("tags") or {}
if isinstance(tags, dict) and tags.get("thread"):
s["uuids"] = s.get("uuids", set()) | {tags["thread"]}
if t == "send_done":
s["done"] = True
if t == "sidechat_uuid_capture_failed":
s["capture_failed"] = True
s["capture_out"] = (e.get("out") or "")[:80]
if t == "sidechat_autoprovisioned" and e.get("thread_uuid"):
s["uuids"] = s.get("uuids", set()) | {e["thread_uuid"]}
if t == "alias_resolved" and e.get("thread_uuid"):
s["uuids"] = s.get("uuids", set()) | {e["thread_uuid"]}
if t == "placement_mismatch":
s["placement_mismatch"] = True
if t == "pre_send_assert_failed":
s["gate_failed"] = True
s["gate_reason"] = e.get("reason")
if t == "nav_failed":
s["nav_failed"] = True
s["nav_reason"] = e.get("reason") or "transport"
return s
for e in in_win:
S(e)
started = [sends[i] for i in order
if any(e.get("type") == "send_start" for e in sends[i]["events"])]
print(f"\n[1b] SEND OUTCOMES: {len(started)} sends attempted")
oc = Counter()
for s in started:
if s.get("gate_failed"):
oc["loud-fail: pre_send_assert_failed"] += 1
elif s.get("nav_failed"):
oc["loud-fail: nav_failed"] += 1
elif s.get("sent_seen") and s.get("sent_verified"):
oc["sent verified:true"] += 1
elif s.get("sent_seen"):
oc["sent verified:false"] += 1
elif s.get("done"):
oc["send_done, no sent event"] += 1
else:
oc["abandoned (no terminal event)"] += 1
for k, n in oc.most_common():
print(f" {n:5d} {k}")
print(" (note: main_chat_blocked events are policy blocks, not failures;")
print(" followup_register_failed=signing issues, tangential)")
# ---- 2. sidechat taxonomy ------------------------------------------------
def is_sc(s):
return (s.get("target") or "") not in ("main", "", None)
sc = [s for s in started if is_sc(s)]
print(f"\n[2] SIDECHAT TAXONOMY ({len(sc)} sidechat-targeted sends)")
tax = Counter()
per_target = defaultdict(Counter)
per_pair = defaultdict(Counter)
for s in sc:
if s.get("gate_failed"):
cls = "gate_failed:" + str(s.get("gate_reason"))
elif s.get("nav_failed"):
cls = "nav_failed:" + str(s.get("nav_reason"))
elif s.get("capture_failed"):
cls = "uuid_capture_failed"
elif s.get("placement_mismatch"):
cls = "placement_mismatch"
elif s.get("sent_seen") and s.get("sent_verified"):
cls = ("verified:true, NO uuid anywhere (ghost)"
if not s.get("uuids") else "clean verified")
elif s.get("sent_seen"):
cls = "sent verified:false"
elif s.get("done"):
cls = "done, no sent event"
else:
cls = "abandoned"
tax[cls] += 1
per_target[s.get("target") or "?"][cls] += 1
per_pair[f"{s.get('agent') or '?'}->{s.get('to') or '?'}"][cls] += 1
for k, n in tax.most_common():
print(f" {n:5d} {k}")
print("\n per-target (sends, non-clean, top classes):")
for tgt, c in sorted(per_target.items(), key=lambda x: -sum(x[1].values())):
tot = sum(c.values())
bad = tot - c.get("clean verified", 0)
print(f" {tgt}: {tot} sends, {bad} non-clean {dict(c.most_common(4))}")
print("\n per agent-pair:")
for pair, c in sorted(per_pair.items(), key=lambda x: -sum(x[1].values())):
tot = sum(c.values())
bad = tot - c.get("clean verified", 0)
print(f" {pair}: {tot} sends, {bad} non-clean {dict(c.most_common(4))}")
# ---- 4. ghosts ------------------------------------------------------------
ghosts = [s for s in sc if s.get("sent_seen") and s.get("sent_verified")
and not s.get("uuids")]
print(f"\n[4] GHOST RATE: {len(ghosts)}/{len(sc)} "
f"({100.0 * len(ghosts) / len(sc) if sc else 0:.0f}%) verified:true "
f"sidechat sends with no UUID in any event")
gh = Counter(s["first_ts"].strftime("%m-%d %H") for s in ghosts if s["first_ts"])
print(" ghost hours:", dict(sorted(gh.items())))
print(" proxies: uuid_capture_failed="
f"{sum(1 for s in sc if s.get('capture_failed'))}, "
f"placement_mismatch={sum(1 for s in sc if s.get('placement_mismatch'))}, "
f"pre_send_assert_failed={sum(1 for s in sc if s.get('gate_failed'))}")
# ---- 3. timeline ------------------------------------------------------------
print("\n[3] TIMELINE (hourly, sidechat sends; #=bad, .=ok)")
buckets = defaultdict(Counter)
for s in sc:
if not s.get("first_ts"):
continue
hr = s["first_ts"].strftime("%m-%d %H:00")
buckets[hr]["total"] += 1
bad = not (s.get("sent_seen") and s.get("sent_verified")
and not s.get("capture_failed")
and not s.get("placement_mismatch")
and not s.get("gate_failed") and not s.get("nav_failed"))
if bad:
buckets[hr]["bad"] += 1
for hr in sorted(buckets):
t, b = buckets[hr]["total"], buckets[hr]["bad"]
print(f" {hr} total={t:3d} bad={b:3d} {'#' * b}{'.' * (t - b)}")
wins = defaultdict(Counter)
for s in sc:
if not s.get("first_ts"):
continue
w = s["first_ts"].strftime("%m-%d %H:%M")[:-1] + "0"
wins[w]["total"] += 1
if not (s.get("sent_seen") and s.get("sent_verified")):
wins[w]["bad"] += 1
print(" worst 10-min windows (>=3 sends):")
shown = 0
for w, c in sorted(wins.items(), key=lambda x: -x[1]["bad"]):
if c["total"] >= 3 and shown < 8:
print(f" {w} total={c['total']} bad={c['bad']}")
shown += 1
print(" bad rate by sending node:")
by_node = defaultdict(Counter)
for s in sc:
by_node[s.get("agent") or "?"]["total"] += 1
if not (s.get("sent_seen") and s.get("sent_verified")):
by_node[s.get("agent") or "?"]["bad"] += 1
for node, c in sorted(by_node.items(), key=lambda x: -x[1]["total"]):
r = 100.0 * c["bad"] / c["total"] if c["total"] else 0
print(f" {node}: {c['bad']}/{c['total']} bad ({r:.0f}%)")
# ---- 5. hypothesis evidence ---------------------------------------------------
print("\n[5] HYPOTHESIS EVIDENCE")
nfr = Counter(s.get("nav_reason") for s in sc if s.get("nav_failed"))
print(f" nav_failed reasons: {dict(nfr)}")
land = [s for s in sc if s.get("capture_failed")]
print(f" uuid_capture_failed: {len(land)}, "
f"landing-page outs: {sum(1 for s in land if 'muse.ai/' in (s.get('capture_out') or ''))}")
pairs = Counter()
for e in in_win:
if e.get("type") == "placement_mismatch":
pairs[((e.get("expected_uuid") or "?")[:8],
(e.get("actual_uuid") or "?")[:8], e.get("target"))] += 1
print(" placement_mismatch expected->actual:")
for (a, b, t), n in pairs.most_common(6):
print(f" {n:3d} exp={a}.. act={b}.. target={t}")
ar = Counter(e.get("source") for e in in_win if e.get("type") == "alias_resolved")
print(f" alias_resolved sources: {dict(ar)} (None = field absent, older events)")
ap_try = sum(1 for e in in_win if e.get("type") == "sidechat_autoprovision_start")
ap_ok = sum(1 for e in in_win if e.get("type") == "sidechat_autoprovisioned"
and e.get("thread_uuid"))
print(f" autoprovision: {ap_try} attempts -> {ap_ok} with uuid ({100.0 * ap_ok / ap_try if ap_try else 0:.0f}%)")
rt = Counter()
for s in started:
n = sum(1 for e in s["events"] if e.get("type") == "retry")
if n:
rt[n] += 1
print(f" retry distribution (sends with >=1 retry): {dict(sorted(rt.items()))}")
print("\n DONE.")
if __name__ == "__main__":
sys.exit(main())
Executable → Regular
+71
View File
@@ -62,6 +62,9 @@ TAG_NUDGES_MAX = 10
TAG_NUDGES_DEFAULT = 2
TAG_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
TAG_THREAD_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
# Thread-UUID pattern, shared by nav parsing and the pre-send gate.
UUID_RE = (r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
r"[0-9a-f]{4}-[0-9a-f]{12}")
# Trailing-token form: [reply:expected] [reply:timeout=7200] ...
TAG_TOKEN_RE = re.compile(r"\[([A-Za-z][A-Za-z0-9:]*)"
r"(?:=([^\]]*))?\]\s*$")
@@ -365,6 +368,52 @@ def verify_placement(recipient, msg_id, target, thread_uuid):
detail["error"] = str(e)[:200]
return False, detail
def assert_pre_send_placement(recipient, target, thread_uuid, direct_nav_done=False):
"""Pre-send placement gate (2026-10-04; restored after 19:43Z clobber).
Independently samples the recipient browser's URL and requires the
expected thread UUID in it BEFORE any send. Fails closed: returns
(False, detail) and the caller must abort the send. 'main' skips.
"""
detail = {"target": target}
if target == "main":
return True, detail
if not thread_uuid:
# Fresh autoprovision: SPA assigns UUID only on first send.
# Strongest pre-send signal: browser must be on a /thread/ page
# (the incident was a send parked on the muse.ai landing page).
_u_rc, _u_out, _u_err = run_full(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url")
actual = (_u_out or "").strip()
detail["actual_url"] = actual[:200]
if "/thread/" not in actual:
detail["reason"] = "not_on_thread_page"
detail["expected"] = "a /thread/ URL (fresh sidechat, UUID assigned on first send)"
return False, detail
return True, detail
expected = thread_uuid.lower()
detail["expected_uuid"] = expected
if not direct_nav_done:
# Title-search nav only: re-navigate by direct /thread/<uuid> URL.
_rc, _out, _err = run_full(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat use {expected}")
if _rc != 0 or "NOTFOUND" in _out or expected not in _out.lower():
detail.update({"reason": "direct_nav_failed", "rc": _rc,
"out": _out[:200], "err": _err[:200]})
return False, detail
time.sleep(2)
else:
time.sleep(1)
_u_rc, _u_out, _u_err = run_full(
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url")
actual = (_u_out or "").strip()
detail["actual_url"] = actual[:200]
if expected not in actual.lower():
detail["reason"] = "url_mismatch"
return False, detail
return True, detail
def dm_send(agent, target, message, verify=True, raw=False,
to_agent=None, tags=None, nudge_meta=None,
allow_main_chat=False):
@@ -444,6 +493,7 @@ def dm_send(agent, target, message, verify=True, raw=False,
thread_uuid = None
thread_url = None
is_new_sidechat = False
nav_is_uuid = False
# Resolve well-known aliases or dynamic thread mappings to UUIDs.
nav_target = resolve_sidechat_target(target)
if nav_target != target:
@@ -454,6 +504,7 @@ def dm_send(agent, target, message, verify=True, raw=False,
else:
# Reset-at-Begin: If searching sidebar by title (not a direct UUID), reset to main first to expose the sidebar
is_uuid = bool(re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", nav_target.strip().lower()))
nav_is_uuid = is_uuid
if not is_uuid:
run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main")
time.sleep(1)
@@ -479,6 +530,12 @@ def dm_send(agent, target, message, verify=True, raw=False,
file=sys.stderr)
sys.exit(1)
is_new_sidechat = True
# If create returned a real thread URL (not /thread/new),
# pin it now so the pre-send gate can assert it.
_cm = re.search(r"/thread/(" + UUID_RE + r")", _c_out, re.I)
if _cm:
thread_uuid = _cm.group(1).lower()
thread_url = "https://muse.ai/thread/" + thread_uuid
log_event({"type": "nav_ok", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "status": "sidechat_created_pending_uuid",
"browser_url": thread_url})
@@ -509,6 +566,20 @@ def dm_send(agent, target, message, verify=True, raw=False,
"target": target, "thread_uuid": thread_uuid,
"browser_url": thread_url})
# Pre-send placement assertion: independently confirm the browser is on
# the target thread BEFORE any send. Failure fails loudly (no ghost sends).
_gate_uuid = thread_uuid or (nav_target.lower() if nav_is_uuid else None)
_ps_ok, _ps_detail = assert_pre_send_placement(
recipient, target, _gate_uuid, direct_nav_done=nav_is_uuid)
if not _ps_ok:
log_event({"type": "pre_send_assert_failed", "id": msg_id, "agent": agent,
"to": recipient, **_ps_detail})
print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED "
f"(pre-send placement assertion failed: {_ps_detail.get('reason')}; "
f"expected={_ps_detail.get('expected_uuid') or _ps_detail.get('expected')}; "
f"actual_url={_ps_detail.get('actual_url')})", file=sys.stderr)
sys.exit(1)
time.sleep(2)
# Send (raw mode: no truncation — signatures must survive intact)
Executable → Regular
+52
View File
@@ -14,6 +14,7 @@ Usage:
import argparse
import json
import os
import re
import subprocess
import sys
import time
@@ -26,6 +27,7 @@ BIN_DIR = NETVM_ROOT / "bin"
JOBS_DIR = NETVM_ROOT / "jobs"
FOLLOWUPS_FILE = NETVM_ROOT / "followups.json"
JOB_LOG = NETVM_ROOT / "job-log.jsonl"
DM_LOG = NETVM_ROOT / "dm-log.jsonl"
DM_PY = BIN_DIR / "dm.py"
DISPATCH_PY = BIN_DIR / "job-dispatch.py"
@@ -101,6 +103,46 @@ def send_dm(sender, recipient, target, text):
except Exception as e:
return False, str(e)
_DM_ID_RE = re.compile(r"\bDM ([0-9a-f]{8})\b")
_THREAD_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
def _resolve_nudge_thread_uuid(nudge_output):
"""Parse the DM id from dm.py's stdout, scan the tail (last 5000 lines)
of dm-log.jsonl for that id's sidechat_autoprovisioned event, falling
back to the sent event's tags.thread. Returns the UUID or None."""
m = _DM_ID_RE.search(nudge_output or "")
if not m:
return None
nudge_id = m.group(1)
fallback = None
try:
with open(DM_LOG, "r", encoding="utf-8") as f:
lines = f.readlines()
except FileNotFoundError:
return None
for line in lines[-5000:]:
line = line.strip()
if not line:
continue
try:
e = json.loads(line)
except Exception:
continue
if e.get("id") != nudge_id:
continue
if e.get("type") == "sidechat_autoprovisioned":
uuid = e.get("thread_uuid") or ""
if _THREAD_UUID_RE.fullmatch(uuid):
return uuid
elif e.get("type") == "sent":
cand = ((e.get("tags") or {}).get("thread")) or ""
if _THREAD_UUID_RE.fullmatch(cand):
fallback = cand
return fallback
def sweep_cycle(dry_run=False):
followups = load_followups()
@@ -181,6 +223,15 @@ def sweep_cycle(dry_run=False):
timeout_s = rec.get("timeout_s", 1800)
step_s = max(300, timeout_s // (nudges_allowed + 1))
rec["deadline"] = (now + timedelta(seconds=step_s)).isoformat()
# C1: backfill the thread the nudge actually landed in.
landed_uuid = _resolve_nudge_thread_uuid(out)
if landed_uuid and rec.get("thread_uuid") != landed_uuid:
rec["thread_uuid"] = landed_uuid
print(f"Sweeper: backfilled thread_uuid={landed_uuid} "
f"for followup {dm_id}", file=sys.stderr)
# C2: record final-nudge routing for the harvester.
if is_final:
rec["final_nudge_target"] = "main"
modified = True
append_job_log({
"ts": utcnow_str(),
@@ -189,6 +240,7 @@ def sweep_cycle(dry_run=False):
"nudge_num": nudge_num,
"recipient": recipient,
"target": delivery_target,
"thread_uuid": rec.get("thread_uuid"),
})
else:
print(f"Sweeper WARNING: nudge send failed: {out}", file=sys.stderr)
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""placement-audit.py -- readable audit of sidechat placement / pre-send gate
activity in dm-log.jsonl.
The gate and navigation emit machine-readable events (pre_send_assert_failed,
placement_failed, placement_mismatch, sidechat_uuid_capture_failed, nav_failed,
...). This CLI turns them into human-readable summaries so the logs stay
useful for troubleshooting sidechat reliability.
Usage:
placement-audit.py --since 24h # summary over the last 24 hours
placement-audit.py --since 7d # ... 7 days
placement-audit.py --since 60m # ... 60 minutes
placement-audit.py --watch # live tail of placement-relevant events
Read-only: never writes, no network, no browser.
"""
import argparse
import json
import re
import sys
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
DM_LOG = "/home/super/Projects/NetVM/dm-log.jsonl"
JOB_LOG = "/home/super/Projects/NetVM/job-log.jsonl"
# Event types that count as placement/navigation failures.
FAILURE_TYPES = {
"sidechat_uuid_capture_failed",
"placement_mismatch",
"placement_failed",
"pre_send_assert_failed",
"nav_failed",
"send_failed",
"failed",
}
# Event types worth streaming in --watch (dm-log).
WATCH_TYPES_DM = FAILURE_TYPES | {
"verified",
"sent",
"nav_ok",
"sidechat_autoprovisioned",
}
def parse_since(s):
m = re.fullmatch(r"(\d+)(m|h|d)", (s or "").strip().lower())
if not m:
raise ValueError(f"bad --since value {s!r}; use like 60m, 24h, 7d")
n, unit = int(m.group(1)), m.group(2)
return timedelta(minutes=n) if unit == "m" else timedelta(hours=n) if unit == "h" else timedelta(days=n)
def parse_ts(ts):
if not ts:
return None
try:
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def short_uuid(u):
u = str(u or "")
return u[:8] + "..." if len(u) > 12 else u
def fmt_ts(e):
dt = parse_ts(e.get("ts"))
return dt.strftime("%m-%d %H:%M:%S") if dt else "?"
def expected_actual(e):
"""Return (expected, actual) strings for a failure event."""
t = e.get("type")
if t in ("pre_send_assert_failed", "placement_failed"):
return e.get("expected_uuid"), e.get("actual_url")
if t == "placement_mismatch":
return e.get("expected_uuid"), e.get("actual_uuid")
if t == "sidechat_uuid_capture_failed":
return "(uuid capture)", e.get("out")
if t == "nav_failed":
return e.get("expected") or e.get("reason"), e.get("got") or e.get("out")
if t in ("send_failed", "failed"):
return e.get("reason"), (e.get("send_out") or e.get("err") or "")[:120]
return None, None
def one_line(e):
"""Compact one-line summary of an event."""
t = e.get("type", "?")
who = f"{e.get('agent', '?')}->{e.get('to', '?')}/{e.get('target', '?')}"
base = f"{fmt_ts(e)} {t:28s} {str(e.get('id', ''))[:8]:8s} {who}"
if t in FAILURE_TYPES:
reason = e.get("reason") or ""
exp, act = expected_actual(e)
extra = f" reason={reason}" if reason else ""
if exp or act:
extra += f" expected={short_uuid(exp) if exp and len(str(exp)) > 20 else exp} actual={act}"
return base + extra
if t == "sent":
return base + f" verified={e.get('verified')}"
if t == "verified":
return base + f" placement={e.get('placement')} thread={short_uuid(e.get('thread_uuid'))}"
if t == "nav_ok":
return base + f" status={e.get('status')} url={e.get('browser_url')}"
if t == "sidechat_autoprovisioned":
return base + f" thread={short_uuid(e.get('thread_uuid'))}"
return base
def iter_log(path, cutoff=None):
"""Yield parsed events from a jsonl file, optionally filtered by ts."""
try:
f = open(path, encoding="utf-8")
except OSError as ex:
print(f"warning: cannot open {path}: {ex}", file=sys.stderr)
return
with f:
for line in f:
line = line.strip()
if not line:
continue
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if cutoff is not None:
dt = parse_ts(e.get("ts"))
if dt is None or dt < cutoff:
continue
yield e
def cmd_since(args):
try:
delta = parse_since(args.since)
except ValueError as ex:
print(str(ex), file=sys.stderr)
return 1
cutoff = datetime.now(timezone.utc) - delta
sends = Counter() # (agent, target) -> sent events
sent_ok = Counter() # (agent, target) -> sent with verified=True
taxonomy = Counter() # failure label -> count
failures = [] # failure events, for the recent list
total = 0
for e in iter_log(DM_LOG, cutoff):
total += 1
t = e.get("type")
key = (e.get("agent") or "?", e.get("target") or "?")
if t == "sent":
sends[key] += 1
if e.get("verified") is True:
sent_ok[key] += 1
if t in FAILURE_TYPES:
reason = e.get("reason") or ""
label = f"{t}" + (f":{reason}" if reason else "")
taxonomy[label] += 1
failures.append(e)
# followup nudge failures live in job-log.jsonl
for e in iter_log(JOB_LOG, cutoff):
if e.get("type") == "followup_nudge_failed":
taxonomy["followup_nudge_failed"] += 1
failures.append({"type": "followup_nudge_failed",
"ts": e.get("ts"), "id": e.get("dm_id"),
"agent": "sweeper", "to": e.get("recipient"),
"target": e.get("target"),
"reason": (e.get("error") or "")[:100]})
print(f"== placement audit: last {args.since} (since {cutoff.strftime('%Y-%m-%d %H:%M UTC')}) ==")
print(f"dm-log events scanned: {total}")
print()
print("-- per-target sends --")
print(f"{'agent':10s} {'target':28s} {'sent':>5s} {'verified':>8s} {'unver':>6s}")
for (agent, target), n in sorted(sends.items(), key=lambda kv: -kv[1]):
ok = sent_ok.get((agent, target), 0)
print(f"{agent:10s} {target:28s} {n:5d} {ok:8d} {n - ok:6d}")
if not sends:
print("(no sends in window)")
print()
print("-- failure taxonomy --")
if taxonomy:
for label, n in taxonomy.most_common():
print(f"{n:5d} {label}")
else:
print("(no placement failures in window)")
print()
print("-- 10 most recent failures --")
failures.sort(key=lambda e: parse_ts(e.get("ts")) or datetime.min.replace(tzinfo=timezone.utc),
reverse=True)
for e in failures[:10]:
print(one_line(e))
if not failures:
print("(none)")
return 0
def cmd_watch(_args):
print("watching dm-log.jsonl for placement events (Ctrl-C to stop)...", file=sys.stderr)
try:
f = open(DM_LOG, encoding="utf-8")
except OSError as ex:
print(f"cannot open {DM_LOG}: {ex}", file=sys.stderr)
return 1
with f:
f.seek(0, 2) # start at end: live view only
try:
while True:
line = f.readline()
if not line:
time.sleep(2)
continue
line = line.strip()
if not line:
continue
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if e.get("type") in WATCH_TYPES_DM:
print(one_line(e), flush=True)
except KeyboardInterrupt:
print("\nstopped.", file=sys.stderr)
return 0
def main(argv=None):
ap = argparse.ArgumentParser(description="Audit sidechat placement / gate events in dm-log.jsonl")
ap.add_argument("--since", metavar="60m|24h|7d",
help="summarize events newer than this (e.g. 60m, 24h, 7d)")
ap.add_argument("--watch", action="store_true",
help="live tail of placement-relevant events")
args = ap.parse_args(argv)
if args.watch:
return cmd_watch(args)
if args.since:
return cmd_since(args)
ap.print_help()
return 2
if __name__ == "__main__":
sys.exit(main())
Executable → Regular
+34 -20
View File
@@ -71,6 +71,18 @@ except ImportError:
VALID_AGENTS = ["muse", "pip", "646", "opm"]
DEFAULT_PORTS = {"muse": 9410, "pip": 9420, "646": 9430, "opm": 9440}
# Matches EVERY [RESULT <job_id>] marker in a message (use with finditer, not
# search). The result text is lazy and stops before the next marker (or end of
# text), so a message closing two jobs records each with its own text instead
# of the first marker greedily swallowing the second.
RESULT_RE = re.compile(r"\[RESULT\s+([A-Za-z0-9_-]+)\]\s*(.*?)(?=\[RESULT\s|\Z)", re.S)
def iter_result_markers(text):
"""Yield (job_id, result_text) for every [RESULT <job_id>] marker in text."""
for m in RESULT_RE.finditer(text or ""):
yield m.group(1).strip(), m.group(2).strip()
def utcnow():
return datetime.now(timezone.utc).isoformat()
@@ -353,14 +365,12 @@ def harvest_main_feed(cdp, agent, watermarks, followups, dry_run=False):
append_jsonl(CHAT_HISTORY_LOG, record)
if author == "assistant":
m_res = re.search(r"\[RESULT\s+([A-Za-z0-9_-]+)\]\s*(.*)", text, re.S)
result_job_id = None
if m_res:
job_id = m_res.group(1).strip()
result_job_id = job_id
result_text = m_res.group(2).strip()
markers = list(iter_result_markers(text))
if markers:
for job_id, result_text in markers:
is_fail = is_fail_result(result_text)
job_results += 1
job_record = {
"ts": utcnow(),
"type": "job_result",
@@ -374,9 +384,10 @@ def harvest_main_feed(cdp, agent, watermarks, followups, dry_run=False):
if not dry_run:
append_jsonl(JOB_LOG, job_record)
trigger_chain_next(job_id, result_text, success=not is_fail)
clear_matching_followups(followups, agent, "main", mid, text, dry_run,
job_id=result_job_id)
clear_matching_followups(followups, agent, "main", mid, text,
dry_run, job_id=job_id)
else:
clear_matching_followups(followups, agent, "main", mid, text, dry_run)
return new_messages, new_wm, job_results
@@ -474,14 +485,10 @@ def harvest_agent_thread(cdp, agent, thread_info, watermarks, followups, dry_run
if not dry_run:
append_jsonl(CHAT_HISTORY_LOG, record)
# Check for [RESULT <job_id>] in assistant messages
if author == "assistant":
m_res = re.search(r"\[RESULT\s+([A-Za-z0-9_-]+)\]\s*(.*)", text, re.S)
result_job_id = None
if m_res:
job_id = m_res.group(1).strip()
result_job_id = job_id
result_text = m_res.group(2).strip()
markers = list(iter_result_markers(text))
if markers:
for job_id, result_text in markers:
is_fail = is_fail_result(result_text)
job_results += 1
@@ -499,10 +506,10 @@ def harvest_agent_thread(cdp, agent, thread_info, watermarks, followups, dry_run
append_jsonl(JOB_LOG, job_record)
# Trigger pipeline chaining or next job if configured
trigger_chain_next(job_id, result_text, success=not is_fail)
# Check and clear pending follow-ups
clear_matching_followups(followups, agent, thread_id, mid, text, dry_run,
job_id=result_job_id)
clear_matching_followups(followups, agent, thread_id, mid, text,
dry_run, job_id=job_id)
else:
clear_matching_followups(followups, agent, thread_id, mid, text, dry_run)
return new_messages, new_wm, job_results
@@ -610,6 +617,8 @@ def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=Fal
Matches on thread identity (thread_uuid or target='main') OR on job_id
(from a [RESULT <job_id>] reply). The job_id path works regardless of
thread_uuid or target, fixing ghost followups with null thread_uuid.
Also matches a main-chat reply when the sweeper recorded
final_nudge_target='main' (final nudge routed to main chat).
"""
if not followups:
return
@@ -627,6 +636,11 @@ def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=Fal
match_thread = True
elif f_rec.get("thread_uuid") and f_rec.get("thread_uuid") == thread_id:
match_thread = True
elif f_rec.get("final_nudge_target") == "main" and thread_id == "main":
# C3: the sweeper routed the final nudge to main chat, so a
# main-chat reply resolves even when the followup target is a
# sidechat.
match_thread = True
# Match by job_id (from [RESULT <job_id>]) -- works regardless of
# thread_uuid or target. This is an ADDITIONAL path, not a replacement.
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
session-probe.py — classify a fleet agent's muse.ai login state via CDP.
READ-ONLY: performs a single Runtime.evaluate reading document.title,
location.href, and body markers. Never clicks, navigates, types, or mutates
session state in any way. Uses the shared CDP queue at PRIORITY_LOW so it
never blocks operator sends or the harvester.
Login states (per operator AGENTS.md, 2026-10-03):
LOGGED_IN title contains "Chat —"/"Muse —" (SPA booted authenticated)
LANDING title == "muse.ai" (landing page, not logged in)
LOGGED_OUT body shows a "Log in" affordance
OTP_PROMPT body contains "To log in, enter the code"
ACCOUNT_SELECTION body contains "Your email matches multiple accounts"
UNKNOWN none of the above matched
CDP_UNREACHABLE browser/CDP could not be reached at all
parked_on_landing (bool, separate field): LOGGED_IN but the current URL is
https://muse.ai/ — the watchdog relaunches browsers with the landing page
as start URL, so a fresh browser is parked there until first nav. Session
is fine; nav may proceed (but allow for SPA boot race).
Exit code: 0 when LOGGED_IN, 1 otherwise (suitable for pre-nav gating).
Stdout: one JSON line: {agent, state, title, url, has_input, checked_at}.
INVOCATION (important): CDP is only reachable from inside the node's netns
(Chromium binds DevTools to loopback). Always run via:
netvm-exec.sh <agent> -- python3 /home/super/Projects/NetVM/bin/session-probe.py --agent <agent>
Running it on the bl host directly will report CDP_UNREACHABLE even when the
browser is healthy.
"""
import argparse
import contextlib
import importlib.util
import json
import os
import sys
import urllib.request
from datetime import datetime, timezone
BIN_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BIN_DIR)
try:
from cdp_queue import cdp_slot, PRIORITY_LOW
HAS_CDP_QUEUE = True
except ImportError:
HAS_CDP_QUEUE = False
import websocket # noqa: E402 (after sys.path tweak, mirrors muse-chat-api.py)
def _load_accounts():
path = os.path.join(BIN_DIR, "netvm-registry.py")
spec = importlib.util.spec_from_file_location("netvm_registry", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
accounts = {}
for node, rec in mod.load().items():
accounts[node] = (node, "http://127.0.0.1:%d/json/list" % rec["cdp_port"])
return accounts
def _ev(ws, expr):
"""Runtime.evaluate with event draining (copied pattern from muse-chat-api.py)."""
ws.send(json.dumps({
"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True},
}))
for _ in range(50):
resp = json.loads(ws.recv())
if resp.get("id") == 1:
break
else:
return None
return resp.get("result", {}).get("result", {}).get("value")
_STATE_JS = """(() => {
const body = document.body ? document.body.innerText.slice(0, 4000) : "";
const has_input = !!document.querySelector(
'[contenteditable="true"], textarea[placeholder*="Message"], div[role="textbox"]');
return JSON.stringify({
title: document.title || "",
url: location.href || "",
body: body,
has_input: has_input,
});
})()"""
def classify(title, url, body, has_input):
t = (title or "").strip()
b = (body or "")
if "Your email matches multiple accounts" in b:
return "ACCOUNT_SELECTION"
if "To log in, enter the code" in b:
return "OTP_PROMPT"
if t == "muse.ai":
return "LANDING"
# A "Chat —"/"Muse —" title means the SPA booted with an authenticated
# session (the landing page title is exactly "muse.ai"). The browser may
# still be parked on "/" (fresh relaunch start URL) — reported separately
# via parked_on_landing, not as a session failure.
if ("Chat \u2014" in t) or ("Muse \u2014" in t) or ("Chat -" in t) or ("Muse -" in t):
return "LOGGED_IN"
if "Log in" in b:
return "LOGGED_OUT"
return "UNKNOWN"
def probe(agent, timeout=15):
accounts = _load_accounts()
if agent not in accounts:
return {"agent": agent, "state": "UNKNOWN",
"error": "no such agent in registry",
"checked_at": _now()}
node, cdp_url = accounts[agent]
try:
with urllib.request.urlopen(cdp_url, timeout=5) as r:
targets = json.load(r)
except Exception as e:
return {"agent": agent, "state": "CDP_UNREACHABLE",
"error": "cdp list failed: %s" % str(e)[:120],
"checked_at": _now()}
pages = [t for t in targets if t.get("type") == "page"]
if not pages:
return {"agent": agent, "state": "CDP_UNREACHABLE",
"error": "no page target", "checked_at": _now()}
slot = cdp_slot(node, priority=PRIORITY_LOW) if HAS_CDP_QUEUE \
else contextlib.nullcontext()
try:
with slot:
ws = websocket.create_connection(
pages[0]["webSocketDebuggerUrl"], timeout=timeout)
try:
raw = _ev(ws, _STATE_JS)
finally:
ws.close()
except Exception as e:
return {"agent": agent, "state": "CDP_UNREACHABLE",
"error": "cdp session failed: %s" % str(e)[:120],
"checked_at": _now()}
if not raw:
return {"agent": agent, "state": "UNKNOWN",
"error": "empty evaluate result", "checked_at": _now()}
try:
snap = json.loads(raw)
except Exception:
return {"agent": agent, "state": "UNKNOWN",
"error": "unparseable evaluate result", "checked_at": _now()}
state = classify(snap.get("title"), snap.get("url"),
snap.get("body"), snap.get("has_input"))
url = snap.get("url") or ""
parked = url.rstrip("/") in ("https://muse.ai", "https://muse.ai/")
return {"agent": agent, "state": state, "title": snap.get("title"),
"url": url[:120], "parked_on_landing": parked,
"has_input": snap.get("has_input"),
"checked_at": _now()}
def _now():
return datetime.now(timezone.utc).isoformat()
def main():
accounts = _load_accounts()
p = argparse.ArgumentParser(
description="Classify a fleet agent's muse.ai login state (read-only).")
p.add_argument("--agent", required=True, choices=sorted(accounts.keys()))
p.add_argument("--timeout", type=int, default=15)
args = p.parse_args()
result = probe(args.agent, timeout=args.timeout)
print(json.dumps(result))
sys.stdout.flush()
sys.exit(0 if result.get("state") == "LOGGED_IN" else 1)
if __name__ == "__main__":
main()
+40 -1
View File
@@ -4,6 +4,7 @@ Robust sidechat management for muse-chat-api.py.
Provides:
- ensure_sidebar(ws): Opens sidebar if closed, with retry
- wait_for_chat_list(ws, name=None): Polls until sidebar list content loads
- list_sidechats(ws): Returns list of side chat names, with retry
- All operations logged for audit
@@ -56,13 +57,51 @@ def ensure_sidebar(ws, max_retries=3):
return !!document.querySelector('[data-testid="hatch-chat-compose"]');
})()""")
def wait_for_chat_list(ws, name=None, timeout_s=15):
"""Poll until the sidebar chat list content has loaded (and optionally
contains `name`, case-insensitive substring match).
Readiness requires at least one plausible chat-title line under the
"Side chats" header -- the header itself renders before items populate,
and the compose button that ensure_sidebar() keys on renders earlier
still. Clicking a chat title before the list is ready is a known
nav-miss contributor (the click lands nowhere and the SPA stays on /).
Per-poll CDP errors are treated as not-ready (browser may be mid-render
or mid-flap). Returns True when ready, False on timeout. Note: an
account with genuinely zero sidechats will always time out here.
"""
import re as _re
want = (name or "").strip().lower()
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
text = _ev(ws, "document.body.innerText") or ""
except Exception:
text = ""
idx = text.find("Side chats")
if idx != -1:
section = text[idx:idx + 2000].lower()
lines = [l.strip() for l in section.split("\n")]
titles = [l for l in lines[1:21]
if 5 < len(l) < 80
and not _re.fullmatch(r"\d+[mh]", l)
and l != "unread updates"]
if titles and (not want or want in section):
return True
time.sleep(1)
return False
def list_sidechats(ws):
"""
List side chat names. Returns list of strings.
Ensures sidebar is open first.
Ensures sidebar is open first, then waits for the list content to
populate before scraping (the open signal fires before content loads).
"""
if not ensure_sidebar(ws):
return []
if not wait_for_chat_list(ws):
return []
result = _ev(ws, """(() => {
const text = document.body.innerText;
+694
View File
@@ -0,0 +1,694 @@
#!/usr/bin/env python3
"""
test_followup_fixes.py -- verification harness for the fleet DM machinery fixes.
Workstream 4 of 5 (verification). Covers the contracts of three sibling
workstreams editing code in /home/super/Projects/NetVM/bin/ :
ws-1 followup-sweeper.py
- backfill rec["thread_uuid"] from dm-log sidechat_autoprovisioned
events (so a followup whose thread provisioned late becomes
resolvable instead of a ghost);
- record rec["final_nudge_target"] = "main" when the final nudge is
routed to main chat.
ws-2 response-harvester.py
- resolve followups on main-chat assistant replies when the followup
carries final_nudge_target == "main";
- extract ALL [RESULT <job_id>] markers per message (finditer),
not just the first (re.search).
ws-3 dm.py
- assert the post-nav browser URL contains the target thread UUID
BEFORE sending; fail loudly otherwise. target == "main" skips the
assertion.
Test layout
-----------
test_contract_* Executable specs of the intended behavior, written against
small local reference predicates. These run GREEN now and
pin the exact semantics the siblings must satisfy.
test_wired_* The same behaviors exercised against the REAL modules
(importlib-loaded from bin/; stdlib-only, side-effect-free
imports; tmp fixtures). Where a sibling has not landed the
change yet, these FAIL with an exact deviation report --
that is the intended signal, not a bug in the harness.
Pure unit tests: NO browser, NO network, NO live DMs, NO writes to live state
files (followups.json, job-log.jsonl, dm-log.jsonl). Tmp copies/fixtures only.
Run: python3 test_followup_fixes.py (built-in runner below)
pytest test_followup_fixes.py (also compatible)
Snapshot note (2026-10-04 ~19:45Z): ws-2 has fully landed --
clear_matching_followups() grew final_nudge_target AND job_id matching paths,
and iter_result_markers() extracts all markers via finditer. ws-1 has
partially landed -- DM_LOG constant, _resolve_nudge_thread_uuid() helper, and
final_nudge_target marking are in; the ghost fail-fast for null-thread_uuid
recs is unchanged (no pre-check backfill). ws-3 (dm.py pre-send URL
assertion) had not landed at the time of writing.
"""
import importlib.util
import inspect
import json
import os
import re
import sys
import tempfile
from pathlib import Path
BIN_DIR = Path(__file__).resolve().parent.parent # .../bin/tests -> .../bin
# --------------------------------------------------------------------------
# module loading (side-effect-free: all three modules are stdlib-only at
# import time and do real work only inside functions / __main__)
# --------------------------------------------------------------------------
def _load(mod_name, filename):
path = BIN_DIR / filename
spec = importlib.util.spec_from_file_location(mod_name, str(path))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class _Skip(Exception):
pass
def skip(reason):
raise _Skip(reason)
# --------------------------------------------------------------------------
# CONTRACT REFERENCE IMPLEMENTATIONS (executable specs -- green now)
# --------------------------------------------------------------------------
def contract_backfill_thread_uuid(rec, dm_log_events):
"""ws-1 spec: adopt thread_uuid from a matching sidechat_autoprovisioned
dm-log event. Never overwrite an existing UUID; never write null."""
if rec.get("thread_uuid"):
return False
for ev in dm_log_events or []:
if ev.get("type") != "sidechat_autoprovisioned":
continue
uuid = ev.get("thread_uuid")
if not uuid:
continue
if ev.get("id") == rec.get("dm_id") or ev.get("target") == rec.get("target"):
rec["thread_uuid"] = uuid
return True
return False
def contract_route_nudge(rec):
"""ws-1 spec: final-nudge routing + marking. Returns (delivery_target,
is_final); marks rec['final_nudge_target']='main' on the final nudge."""
nudge_num = rec.get("nudges_sent", 0) + 1
nudges_allowed = rec.get("nudges_allowed", 2)
is_final = (nudge_num == nudges_allowed)
delivery_target = "main" if is_final else rec.get("target", "main")
if is_final:
rec["final_nudge_target"] = "main"
return delivery_target, is_final
def contract_followup_matches(rec, agent, thread_id):
"""ws-2 spec: extended matching predicate for clear_matching_followups."""
if rec.get("status") != "pending":
return False
if rec.get("recipient") != agent:
return False
if rec.get("target") == "main" and thread_id == "main":
return True
if rec.get("thread_uuid") and rec.get("thread_uuid") == thread_id:
return True
# NEW (ws-2 contract): the final nudge went to main, so a main-chat
# assistant reply resolves the followup even though target != "main".
if rec.get("final_nudge_target") == "main" and thread_id == "main":
return True
return False
_RESULT_MARKER_RE = re.compile(r"\[RESULT\s+([A-Za-z0-9_-]+)\]")
def contract_extract_results(text):
"""ws-2 spec: extract EVERY [RESULT <job_id>] marker; each result text
runs from its marker to the next marker (or end of text)."""
ms = list(_RESULT_MARKER_RE.finditer(text or ""))
out = []
for i, m in enumerate(ms):
seg_end = ms[i + 1].start() if i + 1 < len(ms) else len(text)
out.append((m.group(1), text[m.end():seg_end].strip()))
return out
_UUID_RE = re.compile(
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
def contract_assert_post_nav_url(actual_url, expected_uuid, target):
"""ws-3 spec: (ok, reason). target == 'main' skips the assertion."""
if target == "main":
return True, "main_skipped"
if not expected_uuid:
return False, "no_expected_uuid"
if expected_uuid.lower() in (actual_url or "").lower():
return True, "uuid_present"
return False, "url_mismatch"
# --------------------------------------------------------------------------
# fixtures
# --------------------------------------------------------------------------
# Exact text of opm's 2026-10-04 16:54:17Z message (chat-history.jsonl,
# msg assistant-msg-a5afa9c6-...): carries TWO [RESULT] markers; the old
# re.search path recorded only the first, swallowing the 163439 marker.
TWO_MARKER_TEXT = 'All 10 public services are up, bl is up, tunnels are holding except muse\'s long-standing 7682 terminal gap, and the DM queue is drained — fleet operational.\n\n[RESULT pipe-demo-step1-20261004-165058-b08f5fb5] OK: Fleet healthy and operational\n\nOn the nudge: I never received the 16:35:04 request. I traced it in bl\'s dm-log — job `3ef91457` (pipe-demo-step1 from 16:34:39) targeted side chat "pipe-1b4579", but `sidechat_uuid_capture_failed` fired at 16:34:59 (nav landed on the muse.ai landing page, thread_uuid null), and the "verified: true" at 16:35:04 is the known placement-blind verification. The message went somewhere I can\'t see, so the followup tracker correctly saw no reply and nudged. Two things to fix on the pipeline side: assert the post-nav URL holds the target UUID before sending (never trust the verified flag alone), and dedupe nudges — a second identical nudge (`5e078808`) fired at 16:52:01.\n\nClosing the original loop anyway:\n\n[RESULT pipe-demo-step1-20261004-163439-8a408441] OK: Fleet healthy and operational'
UUID_A = "bf7bf3e1-3ee8-4816-9b66-b77b34387986"
UUID_B = "1e75a740-d08f-443d-a0f9-793db196e24f"
def _mk_rec(**kw):
rec = {
"dm_id": "testdm01",
"sender": "opm",
"recipient": "646",
"target": "646 tasks",
"thread_uuid": UUID_B,
"sent_at": "2026-10-04T17:00:00+00:00",
"deadline": "2020-01-01T00:00:00+00:00", # long past -> sweep acts now
"timeout_s": 900,
"nudges_allowed": 2,
"nudges_sent": 0,
"escalate_to": "opm",
"status": "pending",
}
rec.update(kw)
return rec
# --------------------------------------------------------------------------
# 1. sweeper backfill -- contract
# --------------------------------------------------------------------------
def test_contract_backfill_sets_uuid_on_matching_event():
rec = _mk_rec(thread_uuid=None)
evs = [{"type": "sidechat_autoprovisioned", "id": "testdm01",
"target": "646 tasks", "thread_uuid": UUID_A}]
assert contract_backfill_thread_uuid(rec, evs) is True
assert rec["thread_uuid"] == UUID_A, rec
def test_contract_backfill_no_event_stays_null():
rec = _mk_rec(thread_uuid=None)
assert contract_backfill_thread_uuid(rec, []) is False
assert rec["thread_uuid"] is None, rec
evs = [{"type": "nav_ok", "id": "testdm01", "target": "646 tasks"}]
assert contract_backfill_thread_uuid(rec, evs) is False
assert rec["thread_uuid"] is None, rec
def test_contract_backfill_never_overwrites_existing_uuid():
rec = _mk_rec(thread_uuid=UUID_B)
evs = [{"type": "sidechat_autoprovisioned", "id": "testdm01",
"target": "646 tasks", "thread_uuid": UUID_A}]
assert contract_backfill_thread_uuid(rec, evs) is False
assert rec["thread_uuid"] == UUID_B, "existing UUID must never be overwritten"
# ... and a null-UUID event must never blank it either
evs2 = [{"type": "sidechat_autoprovisioned", "id": "testdm01",
"target": "646 tasks", "thread_uuid": None}]
assert contract_backfill_thread_uuid(rec, evs2) is False
assert rec["thread_uuid"] == UUID_B, rec
def test_contract_backfill_ignores_unrelated_events():
rec = _mk_rec(thread_uuid=None)
evs = [{"type": "sidechat_autoprovisioned", "id": "otherdm99",
"target": "other-target", "thread_uuid": UUID_A}]
assert contract_backfill_thread_uuid(rec, evs) is False
assert rec["thread_uuid"] is None, rec
# --------------------------------------------------------------------------
# 2. sweeper final-nudge marking -- contract
# --------------------------------------------------------------------------
def test_contract_final_nudge_marks_and_routes_main():
rec = _mk_rec(nudges_sent=1, nudges_allowed=2) # about to send 2/2
target, is_final = contract_route_nudge(rec)
assert is_final is True
assert target == "main", target
assert rec.get("final_nudge_target") == "main", rec
def test_contract_nonfinal_nudge_keeps_target_unmarked():
rec = _mk_rec(nudges_sent=0, nudges_allowed=2) # about to send 1/2
target, is_final = contract_route_nudge(rec)
assert is_final is False
assert target == "646 tasks", target
assert "final_nudge_target" not in rec, rec
# --------------------------------------------------------------------------
# 3. harvester matching -- contract
# --------------------------------------------------------------------------
def test_contract_harvester_main_reply_resolves_via_final_nudge_target():
rec = _mk_rec(target="646 tasks", thread_uuid=UUID_B,
final_nudge_target="main")
assert contract_followup_matches(rec, "646", "main") is True
def test_contract_harvester_unrelated_thread_does_not_resolve():
rec = _mk_rec(target="646 tasks", thread_uuid=UUID_B,
final_nudge_target="main")
assert contract_followup_matches(rec, "646", "deadbeef-thread") is False
assert contract_followup_matches(rec, "pip", "main") is False
def test_contract_harvester_existing_rules_still_work():
# exact thread_uuid match
assert contract_followup_matches(_mk_rec(), "646", UUID_B) is True
# target == "main" + main thread
assert contract_followup_matches(_mk_rec(target="main"), "646", "main") is True
# non-pending never resolves
r = _mk_rec(status="resolved", final_nudge_target="main")
assert contract_followup_matches(r, "646", "main") is False
# --------------------------------------------------------------------------
# 4. multi-RESULT extraction -- contract
# --------------------------------------------------------------------------
def test_contract_multi_result_extracts_both():
got = contract_extract_results(TWO_MARKER_TEXT)
ids = [j for j, _ in got]
assert ids == ["pipe-demo-step1-20261004-165058-b08f5fb5",
"pipe-demo-step1-20261004-163439-8a408441"], ids
texts = dict(got)
assert texts["pipe-demo-step1-20261004-163439-8a408441"] == \
"OK: Fleet healthy and operational", texts
first = texts["pipe-demo-step1-20261004-165058-b08f5fb5"]
assert first.startswith("OK: Fleet healthy and operational"), first
assert "[RESULT" not in first, "first result text must stop at the 2nd marker"
def test_contract_single_result():
got = contract_extract_results("[RESULT abc-123] OK: done")
assert got == [("abc-123", "OK: done")], got
def test_contract_no_result():
assert contract_extract_results("just a normal message") == []
assert contract_extract_results("") == []
# --------------------------------------------------------------------------
# 5. dm.py URL assertion -- contract
# --------------------------------------------------------------------------
def test_contract_dm_url_contains_uuid_passes():
ok, reason = contract_assert_post_nav_url(
"https://muse.ai/thread/" + UUID_A, UUID_A, "pipe-1b4579")
assert ok is True and reason == "uuid_present", (ok, reason)
def test_contract_dm_url_landing_page_fails():
ok, reason = contract_assert_post_nav_url(
"https://muse.ai/", UUID_A, "pipe-1b4579")
assert ok is False and reason == "url_mismatch", (ok, reason)
def test_contract_dm_url_wrong_thread_fails():
ok, reason = contract_assert_post_nav_url(
"https://muse.ai/thread/" + UUID_B, UUID_A, "pipe-1b4579")
assert ok is False and reason == "url_mismatch", (ok, reason)
def test_contract_dm_url_main_skips_assertion():
ok, reason = contract_assert_post_nav_url("https://muse.ai/", None, "main")
assert ok is True and reason == "main_skipped", (ok, reason)
def test_contract_dm_url_empty_actual_fails():
ok, reason = contract_assert_post_nav_url("", UUID_A, "pipe-1b4579")
assert ok is False, (ok, reason)
# --------------------------------------------------------------------------
# wiring: real modules
# --------------------------------------------------------------------------
def _tmp_sweeper_env(sweeper):
"""Point the sweeper's file IO at a tmp dir; return (tmpdir, saved).
NOTE: the module uses pathlib.Path objects (FOLLOWUPS_FILE.exists()),
so the monkeypatched values must be Paths, not strs."""
tmp = tempfile.mkdtemp(prefix="w4sweep")
saved = {}
for attr, fname in (("FOLLOWUPS_FILE", "followups.json"),
("JOB_LOG", "job-log.jsonl")):
saved[attr] = getattr(sweeper, attr)
setattr(sweeper, attr, Path(tmp) / fname)
return tmp, saved
def _restore(mod, saved):
for attr, val in saved.items():
setattr(mod, attr, val)
def test_wired_sweeper_final_nudge_routes_main_and_marks():
"""Real sweep_cycle on a followup due its final nudge: delivery must go
to main (existing behavior) AND rec must gain final_nudge_target='main'
(ws-1 contract)."""
sweeper = _load("sweeper_under_test", "followup-sweeper.py")
tmp, saved = _tmp_sweeper_env(sweeper)
saved["send_dm"] = sweeper.send_dm
seen = {}
def fake_send_dm(sender, recipient, target, text):
seen.update(sender=sender, recipient=recipient, target=target)
return True, "SENT id=fake01"
sweeper.send_dm = fake_send_dm
try:
rec = _mk_rec(nudges_sent=1, nudges_allowed=2) # final nudge due
with open(os.path.join(tmp, "followups.json"), "w") as f:
json.dump({"w2nudge": rec}, f)
stats = sweeper.sweep_cycle(dry_run=False)
assert stats["nudges_sent"] == 1, stats
assert seen.get("target") == "main", \
f"final nudge must route to main, went to {seen.get('target')!r}"
with open(os.path.join(tmp, "followups.json")) as f:
rec2 = json.load(f)["w2nudge"]
assert rec2.get("final_nudge_target") == "main", (
"DEVIATION: ws-1 has not landed final_nudge_target marking -- "
f"sweep_cycle routed the final nudge to main but did not record "
f"final_nudge_target on the followup rec (rec keys: "
f"{sorted(rec2.keys())})")
finally:
_restore(sweeper, saved)
def test_wired_sweeper_backfills_thread_uuid():
"""Real _resolve_nudge_thread_uuid (ws-1): parses the nudge DM id from
send output, adopts the thread UUID from that id's
sidechat_autoprovisioned dm-log event (falling back to the sent event's
tags.thread); returns None when nothing usable exists (never fabricates
a UUID). Fixture dm-log via the module's DM_LOG hook -- no live state."""
sweeper = _load("sweeper_under_test", "followup-sweeper.py")
resolver = getattr(sweeper, "_resolve_nudge_thread_uuid", None)
assert resolver is not None, (
"DEVIATION: ws-1 backfill not landed -- followup-sweeper.py has no "
"_resolve_nudge_thread_uuid helper")
tmp = tempfile.mkdtemp(prefix="w4blog")
dmpath = Path(tmp) / "dm-log.jsonl"
saved = sweeper.DM_LOG
sweeper.DM_LOG = dmpath
nid = "f00dbabe"
out = (f"DM {nid} from opm to 646/pipe-x: SENT and VERIFIED "
f"thread={UUID_A}")
try:
# 1. autoprovisioned event -> UUID adopted
with open(dmpath, "w") as f:
f.write(json.dumps({"type": "sidechat_autoprovisioned", "id": nid,
"target": "pipe-x", "thread_uuid": UUID_A}) + "\n")
f.write(json.dumps({"type": "sent", "id": nid,
"tags": {"thread": UUID_A}}) + "\n")
assert resolver(out) == UUID_A, "autoprovisioned UUID not adopted"
# 2. no autoprovisioned event -> falls back to sent.tags.thread
with open(dmpath, "w") as f:
f.write(json.dumps({"type": "sent", "id": nid,
"tags": {"thread": UUID_A}}) + "\n")
assert resolver(out) == UUID_A, "sent.tags.thread fallback broken"
# 3. no usable events -> None (never fabricates / never null-writes)
with open(dmpath, "w") as f:
f.write(json.dumps({"type": "sent", "id": "other12",
"tags": {}}) + "\n")
assert resolver(out) is None, "resolver fabricated a UUID"
# 4. malformed UUID in event -> skipped
with open(dmpath, "w") as f:
f.write(json.dumps({"type": "sidechat_autoprovisioned", "id": nid,
"thread_uuid": "not-a-uuid"}) + "\n")
assert resolver(out) is None, "malformed UUID accepted"
# 5. unparseable nudge output -> None
assert resolver("some garbage without an id") is None
finally:
sweeper.DM_LOG = saved
def test_wired_sweeper_never_overwrites_uuid_with_null():
"""Real sweep_cycle: after a successful nudge whose thread cannot be
determined, an existing rec thread_uuid is left untouched (ws-1
contract: only a real UUID is ever written)."""
sweeper = _load("sweeper_under_test", "followup-sweeper.py")
tmp, saved = _tmp_sweeper_env(sweeper)
saved["send_dm"] = sweeper.send_dm
saved_dm_log = sweeper.DM_LOG
sweeper.DM_LOG = Path(tmp) / "dm-log.jsonl" # empty: no events
Path(tmp, "dm-log.jsonl").write_text("")
nid = "b00bf00d"
sweeper.send_dm = lambda *a: (
True, f"DM {nid} from opm to 646/pipe-x: SENT and VERIFIED")
try:
rec = _mk_rec(thread_uuid=UUID_B, nudges_sent=0, nudges_allowed=2)
with open(os.path.join(tmp, "followups.json"), "w") as f:
json.dump({"w2null": rec}, f)
sweeper.sweep_cycle(dry_run=False)
with open(os.path.join(tmp, "followups.json")) as f:
rec2 = json.load(f)["w2null"]
assert rec2["thread_uuid"] == UUID_B, (
f"DEVIATION: existing thread_uuid was overwritten "
f"(now {rec2['thread_uuid']!r}) despite no resolvable nudge thread")
assert rec2["nudges_sent"] == 1
finally:
sweeper.DM_LOG = saved_dm_log
_restore(sweeper, saved)
def test_wired_sweeper_backfill_reprovision_updates_uuid():
"""Real sweep_cycle: when a nudge lands in a newly provisioned thread,
the rec's stale UUID is replaced by the real new one (ws-1 reprovision
case)."""
sweeper = _load("sweeper_under_test", "followup-sweeper.py")
tmp, saved = _tmp_sweeper_env(sweeper)
saved["send_dm"] = sweeper.send_dm
saved_dm_log = sweeper.DM_LOG
sweeper.DM_LOG = Path(tmp) / "dm-log.jsonl"
nid = "c0ffee42"
with open(os.path.join(tmp, "dm-log.jsonl"), "w") as f:
f.write(json.dumps({"type": "sidechat_autoprovisioned", "id": nid,
"target": "pipe-x", "thread_uuid": UUID_A}) + "\n")
sweeper.send_dm = lambda *a: (
True, f"DM {nid} from opm to 646/pipe-x: SENT and VERIFIED "
f"thread={UUID_A}")
try:
rec = _mk_rec(thread_uuid=UUID_B, nudges_sent=0, nudges_allowed=2)
with open(os.path.join(tmp, "followups.json"), "w") as f:
json.dump({"w2re": rec}, f)
sweeper.sweep_cycle(dry_run=False)
with open(os.path.join(tmp, "followups.json")) as f:
rec2 = json.load(f)["w2re"]
assert rec2["thread_uuid"] == UUID_A, (
f"DEVIATION: reprovisioned thread UUID not adopted "
f"(still {rec2['thread_uuid']!r})")
finally:
sweeper.DM_LOG = saved_dm_log
_restore(sweeper, saved)
def test_wired_harvester_final_nudge_target_main_resolves():
"""Real clear_matching_followups: followup target='646 tasks',
final_nudge_target='main' must resolve on an assistant message in
thread 'main' (ws-2 contract)."""
harv = _load("harvester_under_test", "response-harvester.py")
rec = _mk_rec(target="646 tasks", thread_uuid=UUID_B,
final_nudge_target="main")
fups = {"hx1": rec}
harv.clear_matching_followups(fups, "646", "main", "mid-1",
"some assistant reply", dry_run=True)
assert rec.get("status") == "resolved", (
"DEVIATION: ws-2 final_nudge_target path not landed -- "
"clear_matching_followups() does not resolve a followup with "
"final_nudge_target='main' on a main-thread assistant reply "
f"(status={rec.get('status')!r}; fn signature: "
f"{inspect.signature(harv.clear_matching_followups)})")
# ... and must NOT resolve on an unrelated thread
rec2 = _mk_rec(target="646 tasks", thread_uuid=UUID_B,
final_nudge_target="main")
fups2 = {"hx2": rec2}
harv.clear_matching_followups(fups2, "646", "unrelated-thread", "mid-2",
"some assistant reply", dry_run=True)
assert rec2.get("status") == "pending", \
f"unrelated thread must not resolve (status={rec2.get('status')!r})"
def test_wired_harvester_existing_rules_still_hold():
"""Real clear_matching_followups: pre-existing rules keep working."""
harv = _load("harvester_under_test", "response-harvester.py")
r1 = _mk_rec() # thread_uuid == UUID_B
fups = {"e1": r1}
harv.clear_matching_followups(fups, "646", UUID_B, "m", "t", dry_run=True)
assert r1["status"] == "resolved", "exact thread_uuid match broke"
r2 = _mk_rec(target="main", thread_uuid=None)
fups = {"e2": r2}
harv.clear_matching_followups(fups, "646", "main", "m", "t", dry_run=True)
assert r2["status"] == "resolved", "target=='main' rule broke"
r3 = _mk_rec()
fups = {"e3": r3}
harv.clear_matching_followups(fups, "646", "nope", "m", "t", dry_run=True)
assert r3["status"] == "pending", "unrelated thread wrongly resolved"
def test_wired_harvester_extracts_all_result_markers():
"""The real extraction path must yield BOTH [RESULT] markers (ws-2:
finditer instead of first-only re.search)."""
harv = _load("harvester_under_test", "response-harvester.py")
extractor = next(
(getattr(harv, n) for n in dir(harv)
if "result" in n.lower()
and any(k in n.lower() for k in ("extract", "iter", "marker"))
and callable(getattr(harv, n))),
None)
src = inspect.getsource(harv)
if extractor is not None:
got = extractor(TWO_MARKER_TEXT)
ids = [j for j, _ in got]
assert ids == ["pipe-demo-step1-20261004-165058-b08f5fb5",
"pipe-demo-step1-20261004-163439-8a408441"], \
f"extractor {extractor.__name__} missed markers: {ids}"
return
if "finditer" in src and "RESULT" in src:
return # inline finditer implementation detected; contract assumed met
# Deviation evidence: the current inline path uses re.search (first only).
cur = re.search(r"\[RESULT\s+([A-Za-z0-9_-]+)\]\s*(.*)",
TWO_MARKER_TEXT, re.S)
raise AssertionError(
"DEVIATION: ws-2 finditer change not landed -- response-harvester.py "
"exposes no result extractor and its inline path is re.search "
f"(first marker only): it captures {cur.group(1)!r} and swallows the "
"second marker [RESULT pipe-demo-step1-20261004-163439-8a408441] "
"inside group(2)")
def test_wired_dm_send_asserts_post_nav_url_before_send():
"""dm_send must independently assert the post-nav browser URL contains
the target thread UUID BEFORE sending (ws-3 contract); 'main' skips."""
path = BIN_DIR / "dm.py"
dm = None
via = "file-text fallback"
try:
dm = _load("dm_under_test", "dm.py")
src = inspect.getsource(dm.dm_send)
via = "inspect(dm.dm_send)"
except Exception:
src = path.read_text()
m = re.search(r"def dm_send\(.*?(?=\ndef |\Z)", src, re.S)
src = m.group(0) if m else src
# Exposed predicate? test it directly.
pred = None
if dm is not None:
pred = next(
(getattr(dm, n) for n in dir(dm)
if callable(getattr(dm, n))
and "nav" in n.lower() and "url" in n.lower()),
None)
if pred is not None:
assert pred("https://muse.ai/thread/" + UUID_A, UUID_A, "pipe-x")[0] is True
assert pred("https://muse.ai/", UUID_A, "pipe-x")[0] is False
return
# ws-3 landed shape: module-level assert_pre_send_placement() called from
# dm_send before the send. Verify the call site and exercise the helper.
gate = getattr(dm, "assert_pre_send_placement", None) if dm is not None else None
if callable(gate):
assert "assert_pre_send_placement" in src, \
"gate exists but dm_send never calls it"
_orig_run_full = dm.run_full
_calls = []
def _stub(cmd, timeout=60):
_calls.append(cmd)
if "sidechat use" in cmd:
return 0, "navigated https://muse.ai/thread/" + UUID_A, ""
return 0, _stub.url, ""
try:
dm.run_full = _stub
# 1. UUID-known thread, correct placement -> pass
_stub.url = "https://muse.ai/thread/" + UUID_A
ok, detail = gate("opm", "pipe-x", UUID_A, direct_nav_done=True)
assert ok is True, f"expected pass on matching URL: {detail}"
# 2. landing page -> loud fail (the 2026-10-04 incident mode)
_stub.url = "https://muse.ai/"
ok, detail = gate("opm", "pipe-x", UUID_A, direct_nav_done=True)
assert ok is False and detail.get("reason") == "url_mismatch", detail
# 3. wrong thread -> loud fail
_stub.url = "https://muse.ai/thread/" + UUID_B
ok, detail = gate("opm", "pipe-x", UUID_A, direct_nav_done=True)
assert ok is False and detail.get("reason") == "url_mismatch", detail
# 4. main target skips assertion with zero subprocess calls
_calls.clear()
ok, _ = gate("opm", "main", None, direct_nav_done=False)
assert ok is True and not _calls, "main must skip without subprocess"
# 5. re-nav path (direct_nav_done=False) -> pass
_stub.url = "https://muse.ai/thread/" + UUID_A
ok, detail = gate("opm", "pipe-x", UUID_A, direct_nav_done=False)
assert ok is True, f"re-nav path should pass: {detail}"
finally:
dm.run_full = _orig_run_full
return
nav_i = src.find("sidechat use")
send_i = src.find("Send with verification retries")
segment = src[nav_i:send_i] if 0 <= nav_i < send_i else ""
has_url_fetch = re.search(r"""['"]\s*url['"]|account\s+\S+\s+url\b""", segment)
has_abort = ("sys.exit" in segment) or ("raise " in segment)
assert has_url_fetch and has_abort, (
f"DEVIATION: ws-3 pre-send URL assertion not landed ({via}) -- "
"dm_send's nav->send path performs no independent post-nav URL "
"fetch+containment check that aborts the send; it trusts the "
"`sidechat use` command output (no_thread_url/uuid_mismatch on the "
"nav output only). Note: verify_placement() asserts the URL "
"post-send, which is a different (later) check.")
# --------------------------------------------------------------------------
# runner (also pytest-compatible: plain test_* functions, no args)
# --------------------------------------------------------------------------
def main():
fns = [(n, f) for n, f in sorted(globals().items())
if n.startswith("test_") and callable(f)]
results = []
for name, fn in fns:
try:
fn()
results.append((name, "PASS", ""))
except _Skip as e:
results.append((name, "SKIP", str(e)))
except AssertionError as e:
results.append((name, "FAIL", str(e) or "assertion failed"))
except Exception as e: # noqa: BLE001 - harness must not crash
results.append((name, "ERROR",
f"{type(e).__name__}: {e}"))
npass = sum(1 for _, s, _ in results if s == "PASS")
nfail = sum(1 for _, s, _ in results if s in ("FAIL", "ERROR"))
nskip = sum(1 for _, s, _ in results if s == "SKIP")
print(f"\n{'test':58} result")
print("-" * 80)
for name, status, detail in results:
print(f"{name:58} {status}")
if detail and status in ("FAIL", "ERROR"):
for line in detail.splitlines():
print(f" {line}")
print("-" * 80)
print(f"{len(results)} tests: {npass} pass, {nfail} fail/error, {nskip} skip")
return 1 if nfail else 0
if __name__ == "__main__":
sys.exit(main())
+228
View File
@@ -0,0 +1,228 @@
# Sidechat Reliability Runbook
Fleet sidechat DMs are the fleet's nervous system. On 2026-10-04 they were
measured at ~1/3 navigation success. This runbook is the single place that
explains how the machinery works, how each known failure looks in the logs,
and what to do when a DM doesn't arrive.
All paths below are on **bl** (`super@100.123.153.75`, via the VM).
Repo: `/home/super/Projects/NetVM` (git, local-only, no remotes).
Companion tools (all read-only unless noted):
- `bin/dm-log-taxonomy.py` — failure taxonomy over dm-log.jsonl (counts, nav-failure
breakdown per target/agent-pair, hourly bursts, ghost rate). Run: `python3 bin/dm-log-taxonomy.py`
- `bin/placement-audit.py` — human-readable placement/gate activity.
`placement-audit.py --since 24h`, `--since 60m`, `--watch`
- `bin/tests/test_followup_fixes.py` — 25 unit tests pinning the sweeper/harvester/gate contracts
---
## 1. Architecture (10 lines)
1. Operator (or timer) runs `bin/dm.py send --agent <node> --to <agent> --target <sidechat> "<msg>"`.
2. `dm.py` resolves the target name → thread UUID: `SIDCHAT_ALIASES` (intentionally
empty — hardcoded UUIDs rot), then `job-sidechats.json` dynamic mapping, then
passthrough name for sidebar search.
3. Unknown names autoprovision: `sidechat create` in the recipient's browser, and the
fresh UUID is registered in `job-sidechats.json` (self-healing).
4. Browser control goes `netvm-exec.sh <node> -- python3 muse-chat-api.py --account <node> <cmd>`,
which drives headless Chromium over CDP inside the node's Warp netns.
5. `dm.py` navigates to `/thread/<uuid>` (or sidebar title search), then the
**pre-send gate** (2026-10-04) independently samples the browser URL and requires
the expected UUID in it — fail-closed, never sends blind.
6. Send happens via CDP; `verify_placement()` re-navigates and reads the thread back
(post-send check, complementary to the pre-send gate).
7. Every step appends machine-readable events to `dm-log.jsonl` (`send_start`,
`nav_ok`, `sent`, `verified`, `placement_failed`, `pre_send_assert_failed`, …).
8. If the message carries `reply:expected` tags, `_register_followup()` arms a record in
`followups.json` (deadline, nudges, escalate target).
9. `followup-sweeper.py` (driven by gravity loop) sends nudges on expiry — in-thread
first, main chat on the final nudge — then escalates. It backfills `thread_uuid`
when a nudge provisions the thread.
10. `response-harvester.py` (every ~60s via `response-harvester.service`) reads agent
threads, records `[RESULT job_id]` markers to `job-log.jsonl`, and resolves
followups when an assistant reply lands in the matching thread.
---
## 2. Failure-mode catalog
How to read the signatures: `grep '"type": "<event>"' dm-log.jsonl`, or use
`placement-audit.py --since 24h` / `dm-log-taxonomy.py`.
### 2a. Landing-page nav (caught by the pre-send gate)
- **Signature:** `{"type":"pre_send_assert_failed","id":…,"agent":…,"to":…,
"target":…,"expected_uuid":…,"actual_url":"https://muse.ai/",
"reason":"url_mismatch"|"not_on_thread_page"|"direct_nav_failed"}`
- **Cause:** the browser never left the muse.ai landing page (or parked on the wrong
thread) after `sidechat use`; the old code would have sent anyway.
- **Effect:** send aborted, exit 1, nothing delivered, nothing marked verified.
- **Fix:** re-run the send; if it repeats, the nav layer is flapping — see §2g and the
nav workstream. The gate doing its job is *good news*, not a bug.
- **Owner:** dm.py placement-gate workstream (landed 2026-10-04).
### 2b. `sidechat_uuid_capture_failed` (autoprovision went nowhere)
- **Signature:** `{"type":"sidechat_uuid_capture_failed","id":…,"out":"https://muse.ai/"}`
- **Cause:** `sidechat create` ran but UUID capture saw the landing page — the thread
may or may not exist; dm.py cannot know.
- **Effect (pre-gate era):** the send proceeded with `thread_uuid: null` and reported
`verified:true` — a ghost send (see §2d). With the gate live, the send now fails
closed instead.
- **Owner:** dm.py autoprovision path; gate workstream.
### 2c. Stale UUID rotation (the UUID you have is dead)
- **Signature:** `{"type":"placement_failed",…,"expected_uuid":"<uuid>",
"actual_url":"https://muse.ai/thread/<different-uuid>","reason":"url_mismatch"}`
or `{"type":"placement_mismatch",…,"expected_uuid":…,"actual_uuid":…}`.
- **Cause:** thread UUIDs rotate — observed same-day (`heartbeat-opm`:
`5bd5b350` → `0077e918` → dead). Any hardcoded UUID is a time bomb; the SPA may
also redirect a dead-UUID nav to whatever thread was last open (misdelivery, not
just failure).
- **Fix:** never hardcode UUIDs — `SIDCHAT_ALIASES` in dm.py is intentionally empty
(sibling workstream removed the last hardcoded entries 2026-10-04, incl. the
twice-dead heartbeat UUID). Use name-based autoprovision + `job-sidechats.json`
dynamic mappings, which self-heal on re-provision.
- **Owner:** UUID-rotation workstream. TODO: `docs/UUID-ROTATION.md` not yet written;
this section is the interim reference.
### 2d. Placement-blind `verified:true` (the ghost send)
- **Signature:** `{"type":"verified","id":…,"agent":…,"target":…}` with **no**
`thread_uuid` field (or `"thread_uuid": null`), paired with a `sent` event —
and no `placement_failed`/`pre_send_assert_failed` anywhere near it.
- **Cause:** the old verify step checked the *current* browser chat, not the target
(see operator AGENTS.md). A send parked on the landing page still "verified".
- **Effect:** the message is lost but every downstream system believes it landed —
followups nudge a ghost, pipelines stall as `dispatched`.
- **Fix:** the pre-send gate (§2a) makes new ghost sends impossible (fail-closed).
For historical ghosts: backfill `thread_uuid` in `followups.json` once the thread
provisions, register the alias in `job-sidechats.json`, resolve manually with a note.
- **Owner:** gate workstream (prevention); operators (historical repair).
### 2e. Logged-out / dead session
- **Signature:** no single event type. Look for `nav_failed` with `"rc": 1` and CDP
tracebacks, or muse-chat-api.py login-state detection: page `Title == "muse.ai"`
(LANDING), body containing `"To log in, enter the code"` (OTP_PROMPT), or
`"Your email matches multiple accounts"` (ACCOUNT_SELECTION).
- **Cause:** Meta session expired, OTP pending, or (2026-10-04 ~19:43 UTC) all four
chromebox browsers dying silently every 10–15 min with no crash log.
- **Fix:** `agent-health.sh` watchdog relaunches dead Chromium; OTP/account-selection
states need the human (approval flow). Track: board task `9e8b15d69ca9` for the
2026-10-04 browser-flapping root cause.
- **Owner:** session-state workstream. TODO: dedicated session-probe tooling pending.
### 2f. Ghost followup (`thread_uuid: null` — can never auto-resolve)
- **Signature:** in `followups.json`: `"thread_uuid": null`, `"status": "pending"`;
in dm-log: the original send's `sidechat_uuid_capture_failed`, then sweeper
`followup_nudged` events that never resolve.
- **Cause:** the harvester's `clear_matching_followups` only matches exact
`thread_uuid` (or `target=="main"` in main). A null-UUID followup matches nothing,
forever — the sweeper nudges then escalates on a ghost.
- **Fix:** sweeper now backfills `thread_uuid` when a nudge successfully provisions
the thread (workstream landed 2026-10-04). For existing ghosts: backfill manually,
register the alias, resolve with a note. Longer-term TODO: sweeper should also
backfill on sweep ticks by scanning dm-log for the *original* dm_id's
`sidechat_autoprovisioned` event (currently only backfills on nudge success).
- **Owner:** sweeper-policy workstream.
### 2g. Flaky nav (~1/3 success) and timing races
- **Signature:** bursts of `nav_failed` / `pre_send_assert_failed` /
`placement_mismatch` for the same target in `placement-audit.py --since 60m`;
`dm-log-taxonomy.py` shows the per-target failure rate.
- **Cause:** under investigation 2026-10-04 — sidebar DOM races, CDP timing,
possibly the browser-flapping issue (§2e). The gate converts these from silent
misdeliveries into loud, retried failures (strictly better).
- **Owner:** nav-shootout, timing-races, and retry-module workstreams.
TODO: their findings land here when complete.
### 2h. `main_chat_blocked`
- **Signature:** `{"type":"main_chat_blocked","id":…,"agent":…,"to":…,"target":"main"}`
- **Cause:** a send targeted `main` without `--allow-main-chat`. Deliberate guardrail:
automation stays out of main chat unless explicitly allowed.
- **Fix:** pass `--allow-main-chat` if main delivery is really intended.
---
## 3. Diagnosis playbook: "a DM didn't arrive"
Run in order. All commands on bl unless noted.
1. **Find the send.** `grep '"id": "<8-hex-id>"' /home/super/Projects/NetVM/dm-log.jsonl`
(or grep by target). Walk its event sequence: `send_start` → `nav_ok`? →
`sent`? → `verified`? Any `*_failed` event is your answer.
2. **Placement first.** `python3 bin/placement-audit.py --since 24h` — if you see
`pre_send_assert_failed` / `placement_failed` / `placement_mismatch` for the id,
the message never left bl correctly. Check `actual_url`: landing page (§2a),
wrong thread (§2c).
3. **Check the taxonomy.** `python3 bin/dm-log-taxonomy.py` — is this an isolated
incident or is the target/agent-pair failing at a high rate right now (§2g)?
4. **Check the session.** If `nav_failed` with rc=1: is the browser alive?
`ss -tln` for the CDP port in the netns; muse-chat-api.py login-state detection
(LANDING vs LOGGED_IN per operator AGENTS.md). If all four browsers are flapping,
see board task `9e8b15d69ca9` (§2e).
5. **Check UUID freshness.** `python3 -c "import json;print(json.load(open(
'/home/super/Projects/NetVM/job-sidechats.json')).get('<target>'))"` — does the
mapped UUID still open the right thread? If the send used a UUID from anywhere
else, suspect rotation (§2c).
6. **Check the followup.** `grep -A12 '<dm_id>' /home/super/Projects/NetVM/followups.json`
— `thread_uuid: null` + `pending` = ghost (§2f). If nudges are firing, the reply
(if any) landed in a thread the harvester doesn't match: backfill + resolve manually.
7. **Check the pipeline.** If it was a pipeline step: `grep '<job_id>'
/home/super/Projects/NetVM/pipelines.json` — `dispatched` with no result after a
ghost send needs `pipeline_engine.record_step_result()` + a decision on chaining.
8. **Repair, don't re-fire blindly.** Fix the cause (nav, session, UUID), then resend
once. Repeated blind retries of a ghost just burn the nudge budget.
---
## 4. Hard lessons (non-negotiable)
1. **Never trust `verified:true` alone.** It historically meant "found the msg_id in
whatever chat the browser happened to be parked in." Always corroborate with a
placement assertion (pre-send gate) or a thread read-back.
2. **Assert the post-nav URL before sending.** The gate is now the enforcement; any
new send path must go through it. Fail closed, fail loudly (non-zero exit +
`pre_send_assert_failed` event), never degrade to blind send.
3. **Thread UUIDs rotate — never hardcode them.** Observed same-day rotation.
`SIDCHAT_ALIASES` stays empty; name-based autoprovision + `job-sidechats.json`
self-heals. If you find a hardcoded UUID anywhere, delete it.
4. **Backfill `thread_uuid`.** A followup with null UUID can never auto-resolve.
Backfill on provision (sweeper does this now), and repair historical ghosts by
hand with a note.
5. **Atomic writes on live files.** siphon/response-harvester fire every ~60s; the
sweeper loop is tight. Edit to `/tmp`, `py_compile`, atomic `mv`. Never half-write
a live file. Back up (`*.bak-<date>-<who>`) before touching shared code.
6. **One owner per file per change.** Parallel fixes on shared surfaces caused the
2026-10-03 502s: reconcile first, disjoint ownership, review the combined diff
before it goes live. dm.py is the single chokepoint — treat it accordingly.
7. **The dm-log is the source of truth for delivery; the harvester is the source of
truth for replies.** `verified` ≠ delivered; `sent` ≠ read. When they disagree,
believe the thread read-back.
---
## 5. Sibling workstream map (2026-10-04 troubleshooting sprint)
| # | Workstream | Output | Status |
|---|-----------|--------|--------|
| 1 | Gate restore | pre-send gate in `dm.py` (`assert_pre_send_placement`) | **Landed** |
| 2 | Log taxonomy | `bin/dm-log-taxonomy.py` | **Landed** |
| 3 | UUID rotation | hardcoded UUID removal (`SIDCHAT_ALIASES` emptied) | **Landed** |
| 4 | Nav shootout | — | TODO: findings pending |
| 5 | Session-state | — | TODO: probe tooling pending |
| 6 | Timing races | — | TODO: findings pending |
| 7 | Retry module | shared retry helper | TODO: pending |
| 8 | Sweeper policy | thread_uuid backfill + `final_nudge_target` | **Landed** |
| 9 | Audit CLI | `bin/placement-audit.py` | **Landed** |
| 10 | This runbook | `docs/SIDECHAT-RELIABILITY.md` | **This file** |
Related docs: `docs/DM-SPEC.md` (DM system spec), `docs/SIDECHAT_SPEC.md`,
`docs/SIDECHAT-POLICY-TRIAGE.md`, `docs/CHROMEBOX-RUNBOOK.md`.
TODO: `docs/UUID-ROTATION.md` (rotation mechanics deep-dive) — §2c is the interim reference.
+132
View File
@@ -0,0 +1,132 @@
# Thread-UUID rotation: measurement & mapping hygiene proposal
Date: 2026-10-04. Source: `dm-log.jsonl` (window 2026-10-03 21:36Z → 2026-10-04 19:46Z,
~22h, 3318 lines — the log does not cover a full 7 days; rates below are
extrapolated from this window and should be re-measured on a longer one).
Read-only analysis; no code changed.
## 1. Measured rotation per alias
Two distinct phenomena share the symptom "alias → different UUID":
- **(A) Real thread death/rotation.** The SPA retires threads. Observed earlier
for heartbeat-opm (5bd5b350 → 0077e918 → dead, twice in one day, per operator
notes; predates this log window).
- **(B) Fuzzy-search nondeterminism.** `sidechat use "<name>"` scans every DOM
element for `textContent.includes(name)`, picks the shortest match, and clicks
it (`muse-chat-api.py: cmd_sidechat_use`). The candidate set changes between
React renders, so the same alias resolves to different live threads run to run
— with no thread dying at all.
| alias | distinct UUIDs (window) | autoprovisioned (real rotations) | send-target flaps (fuzzy) | changes/day* |
|---|---|---|---|---|
| heartbeat | 4 (5f18476d, 1e75a740, 0077e918, …) | 0 | 3 | ~3.3 flap |
| 646 tasks | 4 (1e75a740, a7d0b08a, 1dfb3199) | 1 (18:30 → 1dfb3199) | 2 | ~1.1 real, ~2.2 flap |
| 646-pip | 2 (82779e55 → 75feb3a2) | 1 (18:01) | 0 | ~1.1 real |
| ops-audit | 2 (75feb3a2 → 34a8a87c) | 1 (19:28) | 0 | ~1.1 real |
| main-loop brain | 1 (5f18476d) | 0 | — | collision, see §3 |
| pipe-* (6 aliases) | 1 each | 1 each (creation) | 0 | 0 post-creation |
| 646-pip-coord | 1 (4466d0c1) | 0 | 0 | 0 |
| 646-opm-coord | 1 (4139dd4e) | 0 | 0 | 0 |
\* changes/day extrapolated from ~22h window; demo-day traffic inflates flaps.
Smoking gun for (B): at 16:25:53 a `super`→646 send for "646 tasks" landed on
a7d0b08a, and 52 seconds later an `opm`→646 send for the same alias in the same
recipient browser landed on 1e75a740. No thread died in between — the fuzzy
search is nondeterministic across runs.
## 2. Who writes / reads job-sidechats.json today
Writers:
- `dm.py` autoprovision path only (atomic tmp+mv write of
`{thread_uuid, agent, created_at}` on `sidechat_autoprovisioned`).
- Manual edits (e.g. today's removal of stale heartbeat-opm / pipe-9735f2).
NOT writers:
- The new sweeper thread_uuid backfill (workstream 1) writes **followups.json
only** — it does not refresh job-sidechats.json.
- `thread_lifecycle.py` exists but is marked "prototype, not deployed".
Readers: `dm.py resolve_sidechat_target` (exact key match → UUID, else
passthrough to fuzzy search), harvester/siphon (monitor list), box-ctl,
job-dispatch, super-cli.
Critical gap in the refresh story: a **dead mapped UUID does not self-heal**.
`sidechat use <dead-uuid>` → SPA redirects to `/` → output is
"Navigated to: https://muse.ai/" (not NOTFOUND) → `nav_failed no_thread_url` →
hard fail. Autoprovision only fires on NOTFOUND from *name* search. So a dead
mapping = permanent send failures for that alias until a human removes the
entry. (This is fail-closed, not misdelivery — but it is not self-healing.)
Second gap: an **unmapped alias whose fuzzy search keeps "succeeding" never
autoprovision** and therefore never earns a canonical mapping — it flaps
forever. This is heartbeat's exact situation right now (see §3).
## 3. Flags
- **MISSING mapping, highest churn: `heartbeat`.** 4 UUIDs in 3h, zero
autoprovision events, no entry in job-sidechats.json (the old `heartbeat-opm`
entry was removed during today's hardcoded-alias cleanup). Every send does a
fresh fuzzy search. At 16:45 a heartbeat send landed on 1e75a740 — 646 tasks'
thread. **Misdelivery already happened here.**
- **MISSING mapping: `ops-audit`.** Re-provisioned 19:28 (34a8a87c) but the
alias was never registered; next fuzzy search may flap.
- **COLLISION, unverified: `main-loop brain` → 5f18476d.** The brain's init
send (19:40) fuzzy-matched into 5f18476d — the same thread heartbeat used at
16:35 and 19:41. No `sidechat_autoprovisioned` event for the brain alias, so
no new thread was created. Either the fuzzy search false-positived into
heartbeat's thread, or the brain was deliberately placed there. **Confirm
intent — if unintended, digests and heartbeat traffic are cross-talking.**
- **No current mapping points to a UUID superseded by a newer autoprovision
for the same alias** — all 11 entries match their alias's latest
autoprovision event. Nothing stale in that narrow sense.
- **Aging, low-risk:** `test-auto-prov` (last confirmed 16:20), `646-opm-coord`
(last confirmed 12:52), and six single-use `pipe-*` demo entries accumulate
with no TTL. Harmless today, cruft tomorrow.
- **Confounder:** as of ~19:43Z all four chromebox browsers are flapping
(dying every 10–15 min, no crash signature). Nav flakiness measured today is
polluted by this; re-measure rotation after the browsers stabilize.
## 4. Proposal: mapping hygiene
1. **Canonical entry for every recurring alias.** `heartbeat`, `ops-audit`,
`main-loop brain` (once its thread is confirmed), `646 tasks`, `646-pip`,
`646-pip-coord`, `646-opm-coord` must each have exactly one entry, created
by explicit provisioning — never by fuzzy luck. Unmapped + fuzzy-matched is
the misdelivery configuration; it should be treated as a bug, not a
fallback.
2. **Dead-UUID reprovisioning.** On `no_thread_url` / `uuid_mismatch` where
the nav target came from a *mapping* (source == `dynamic_mapping`), fall
through to autoprovision and overwrite the mapping — the same path name
search uses today. This closes the "dead mapping = permanent failure" gap
and makes rotation self-healing. (Keep failing closed when even
autoprovision can't produce a thread page.)
3. **Exact-match-first fuzzy search.** Before the DOM substring scan,
`sidechat use` should try an exact title match (normalized case/space).
Only fall back to substring on no exact hit. This kills most cross-alias
false positives (the 16:45 heartbeat→646-tasks landing) without changing
the autoprovision contract.
4. **TTL + audit job.** Add `last_confirmed_at` to each mapping entry, refreshed
on every successful send through it. A periodic audit (daily is plenty;
weekly at minimum) flags: entries unconfirmed > TTL (7d job, 24h heartbeat),
aliases seen in dm-log with no entry, and UUIDs serving >1 alias
(collision detector — would have caught 1e75a740 and 5f18476d). Report-only
at first; auto-prune single-use `pipe-*`/test entries after TTL.
5. **Sweeper backfill → also refresh the mapping.** The workstream-1 backfill
already learns the live UUID per followup; writing it through to
job-sidechats.json when the followup's target is a mapped alias is nearly
free and covers the "nudge provisioned a thread dm.py didn't" case.
6. **Do not reintroduce hardcoded UUIDs** (the empty `SIDCHAT_ALIASES` was the
right call). The mapping file is the single source of truth; the audit job
in (4) is what keeps it honest.
## 5. Suggested next steps (no code in this workstream)
- Confirm `main-loop brain` thread intent (collision with heartbeat's 5f18476d).
- Provision canonical threads for `heartbeat` and `ops-audit`; register them.
- Implement proposal items 2 and 3 (dm.py + muse-chat-api.py — small, surgical).
- Stand up the audit job (4) as report-only; review one week of flags before
enabling any auto-prune.
- Re-run this measurement on a 7-day dm-log window once rotation stabilizes
post-browser-flap.