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:
smoido
2026-08-31 02:28:24 +03:00
parent 43cef6bf9e
commit 4c36f4d00b
5 changed files with 393 additions and 32 deletions
+28 -26
View File
@@ -2122,7 +2122,7 @@ cm_project_dir() {
}
cmd_repair_session() {
local target='' apply=0 reinject=1 scan_all=0 dir='' a file verdict age
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
for a in "$@"; do
case "$a" in
--apply) apply=1 ;;
@@ -2130,6 +2130,7 @@ cmd_repair_session() {
--no-reinject) reinject=0 ;;
--list) target='--list' ;;
--all) scan_all=1 ;;
--json) as_json=1; scan_all=1 ;;
-*) err "unknown option '$a'"; return 1 ;;
*) target="$a" ;;
esac
@@ -2139,32 +2140,33 @@ cmd_repair_session() {
# position from which to remember which project it belonged to. --all drops
# the working-directory scoping and reports only what is actually broken.
if [ "$scan_all" -eq 1 ]; then
head_ 'scanning every session transcript'
local f v bad=0 total=0
for f in "$CM_SETTINGS_DIR"/projects/*/*.jsonl; do
[ -f "$f" ] || continue
total=$((total + 1))
v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue
printf '%s' "$v" | "$PY" -c "
import json,sys,os
d=json.load(sys.stdin)
# Only 'repairable' is damage this tool can or should act on.
if d['kind'] != 'repairable': raise SystemExit(0)
print(' %-38s %s' % (os.path.basename(d['path'])[:-6], os.path.basename(os.path.dirname(d['path']))))
print(' would drop %d line(s); %d foreign id(s)' % (d['dropLines'], len(d['foreignIds'])))
raise SystemExit(9)
" && continue
bad=$((bad + 1))
done
printf '\n'
if [ "$bad" -eq 0 ]; then
ok "nothing to repair across $total transcript(s)"
else
warn "$bad of $total transcript(s) were cut short by a mode switch"
printf ' %sclaude-mode repair-session <id> --apply%s\n' "$C_DIM" "$C_RESET"
local scan
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" 2>/dev/null)" || {
err 'could not scan transcripts'; return 1; }
if [ "$as_json" -eq 1 ]; then
printf '%s\n' "$scan"
return 0
fi
printf ' %ssessions that ran entirely on a gateway are not listed: they carry that%s\n' "$C_DIM" "$C_RESET"
printf ' %sprovider'"'"'s ids by design and resume fine under it%s\n' "$C_DIM" "$C_RESET"
head_ 'scanning every session transcript'
printf '%s' "$scan" | "$PY" -c "
import json,sys
d=json.load(sys.stdin)
G,Y,D,X='\033[32m','\033[33m','\033[90m','\033[0m'
for b in d['broken']:
print(' %-38s %s' % (b['sessionId'], b['project']))
prov = ', '.join(b['providers']) or 'another provider'
print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov))
print()
if not d['broken']:
print(' %sok %s nothing to repair across %d transcript(s)' % (G,X,d['scanned']))
else:
print(' %swarn%s %d of %d transcript(s) were cut short by a mode switch' % (Y,X,d['count'],d['scanned']))
print(' %sclaude-mode repair-session <id> --apply%s' % (D,X))
print(' %ssessions that ran entirely on a gateway are not listed: they carry that%s' % (D,X))
print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
"
return 0
fi
+106
View File
@@ -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,