Notice broken sessions in the bar, and offer to repair them there
Nothing tells you a session is unresumable until you try to resume it, and by then you have usually forgotten which one it was. The widget now scans every project on a timer and whenever the panel opens, marks its icon when something needs fixing, and lists the affected sessions with a repair button that says what it will drop and what it will keep before doing anything. The scan had to get roughly eighty times cheaper first. Classifying a transcript needs two facts - what its last message id is, and whether any Anthropic id exists at all - and the first settles the common case alone. Reading the tail of each file and only opening the whole thing when the tail already looks wrong takes the sweep from ~5s to ~60ms across 59 transcripts, which is the difference between something that can sit on a timer and something that cannot. `--all` uses the same path, and `--json` exposes it. The badge is a dot beside the mark rather than a recolouring of it: this widget's job is to report which provider is active, and tinting it red to mean something else entirely would be a lie about that. Verified by planting a genuinely corrupted transcript, watching the scan find it and the dot appear, then removing it and watching both clear.
This commit is contained in:
@@ -16,6 +16,7 @@ Subcommands:
|
||||
presets <dir> "name<TAB>provider<TAB>desc"
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -837,11 +838,116 @@ def _safe(line):
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _last_msg_id_from_tail(path, window=262144):
|
||||
"""The id of the last message-bearing line, read from the end of the file.
|
||||
|
||||
Classifying a transcript needs two facts: what the last message id is, and
|
||||
whether any Anthropic id exists at all. The first decides the common case on
|
||||
its own - if the last one is `msg_`, the transcript is healthy and the rest
|
||||
of the file never has to be touched. Since that is 40-odd of the 59 here,
|
||||
and some are 10MB, reading the tail first is the difference between a scan
|
||||
that can run on a timer and one that cannot.
|
||||
|
||||
Returns (id_or_None, saw_whole_file).
|
||||
"""
|
||||
size = os.path.getsize(path)
|
||||
with open(path, "rb") as fh:
|
||||
start = max(0, size - window)
|
||||
fh.seek(start)
|
||||
chunk = fh.read()
|
||||
whole = start == 0
|
||||
if not whole:
|
||||
nl = chunk.find(b"\n") # drop the partial first line
|
||||
chunk = chunk[nl + 1:] if nl >= 0 else b""
|
||||
for raw in reversed(chunk.splitlines()):
|
||||
if not raw.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(raw.decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(d)
|
||||
if mid:
|
||||
return mid, whole
|
||||
return None, whole
|
||||
|
||||
|
||||
def cmd_scan_sessions(argv):
|
||||
"""scan-sessions <projects-dir> - JSON list of transcripts worth repairing.
|
||||
|
||||
Only `repairable` is reported: a transcript carrying a genuine Anthropic
|
||||
message with a different provider's output after it. A session that ran
|
||||
entirely on a gateway, or never got a reply, is not damage.
|
||||
"""
|
||||
root = argv[0]
|
||||
broken, scanned, skipped = [], 0, 0
|
||||
|
||||
for proj in sorted(glob.glob(os.path.join(root, "*"))):
|
||||
if not os.path.isdir(proj):
|
||||
continue
|
||||
for path in sorted(glob.glob(os.path.join(proj, "*.jsonl"))):
|
||||
scanned += 1
|
||||
try:
|
||||
last, whole = _last_msg_id_from_tail(path)
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
# Healthy is decided by the tail alone, and is the common case.
|
||||
if last is not None and last.startswith("msg_"):
|
||||
continue
|
||||
if last is None and whole:
|
||||
continue # no replies at all; nothing to fix
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
last_good, foreign = -1, []
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(d)
|
||||
if not mid:
|
||||
continue
|
||||
if mid.startswith("msg_"):
|
||||
last_good = i
|
||||
elif mid.startswith("gen-"):
|
||||
foreign.append(str((d.get("message") or {}).get("model", "")))
|
||||
|
||||
if last_good < 0:
|
||||
continue # gateway-native or reply-less
|
||||
tail = [i for i in range(len(lines) - 1, last_good, -1)
|
||||
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
|
||||
if not tail:
|
||||
continue
|
||||
|
||||
broken.append({
|
||||
"sessionId": os.path.basename(path)[:-6],
|
||||
"project": os.path.basename(proj),
|
||||
"path": path,
|
||||
"lines": len(lines),
|
||||
"dropLines": len(lines) - (last_good + 1),
|
||||
"providers": sorted(set(f for f in foreign if f)),
|
||||
"mtime": int(os.path.getmtime(path)),
|
||||
})
|
||||
|
||||
print(json.dumps({"scanned": scanned, "skipped": skipped,
|
||||
"broken": broken, "count": len(broken)}))
|
||||
|
||||
COMMANDS = {
|
||||
"health": cmd_health,
|
||||
"preflight-json": cmd_preflight_json,
|
||||
"sessions-json": cmd_sessions_json,
|
||||
"repair-session": cmd_repair_session,
|
||||
"scan-sessions": cmd_scan_sessions,
|
||||
"stale-models": cmd_stale_models,
|
||||
"strip-tags": cmd_strip_tags,
|
||||
"or-models": cmd_or_models,
|
||||
|
||||
Reference in New Issue
Block a user