Ask before switching while sessions are live, and recover the ones already broken
Acts on docs/incident-mode-switch-corrupts-live-sessions.md, which is added here as the record of why. The report identifies a consequence that was not modelled. A failed call is recoverable; a *successful* one may not be. If a running session takes even one completion from the provider being switched to - which happens when that mode matches the base URL it already had cached - that provider's message-id format lands in its transcript. OpenRouter issues `gen-<epoch>-<rand>` where Anthropic issues `msg_...`, and native Anthropic then refuses to resume the session at all, with a 400 naming previous_message_id. The only way back is to truncate the transcript, losing every turn after the cut. That happened here, and was fixed by hand. Two changes follow. Sessions are now settled before the write, not reported after it. A switch with anything running stops, names the sessions, explains what is about to happen to them, and offers restart (the only answer that ends with everything on the mode the bar now claims), close, proceed anyway, or abort - defaulting to abort. Non-interactively it refuses outright unless given --yes. The old after-the-fact reporter is deleted rather than left as a second, contradictory account. `claude-mode repair-session` replaces the hand surgery: it finds a project's transcripts, reports which are resumable, and on --apply backs the file up and truncates to the last Anthropic-issued message. Verified against the real corrupted transcript from the incident - it reproduces the manual cut exactly, 1921 lines to 1813, dropping the two `gen-` completions and the error placeholders after them, leaving a transcript that ends on a genuine msg_ id. It refuses a transcript written to in the last 90 seconds, since that one belongs to a session still running. The panel passes --yes, having already asked in its own card, and that card now names the transcript risk rather than only the inconvenient one. Requirement 4 of the report - documenting the mechanism - landed in 9c301e1; the README now carries the unrecoverable half as well.
This commit is contained in:
@@ -603,10 +603,107 @@ def cmd_set_all(argv):
|
||||
save(path, p)
|
||||
print(model)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session transcript repair
|
||||
#
|
||||
# Native Anthropic requires previous_message_id to be an id it issued itself -
|
||||
# one starting `msg_`. A session that took even one completion from a gateway
|
||||
# while the mode was switched under it has that provider's id format in its
|
||||
# transcript instead (OpenRouter issues `gen-<epoch>-<rand>`), and every attempt
|
||||
# to resume it afterwards fails with a 400 naming previous_message_id. Client-
|
||||
# side error placeholders, written as model `<synthetic>` with a UUID for an id,
|
||||
# do the same thing when one is last.
|
||||
#
|
||||
# The transcript is newline-delimited JSON, one independent object per line, so
|
||||
# rolling back to the last message Anthropic actually issued is a truncation.
|
||||
# Everything after it is lost - which is the cost, and why nothing here runs
|
||||
# without being asked twice.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _msg_id(obj):
|
||||
m = obj.get("message")
|
||||
if isinstance(m, dict) and m.get("id"):
|
||||
return str(m["id"])
|
||||
return None
|
||||
|
||||
|
||||
def cmd_repair_session(argv):
|
||||
"""repair-session <transcript.jsonl> [--apply]
|
||||
|
||||
Prints a JSON verdict. With --apply, backs the file up and truncates it to
|
||||
the last Anthropic-issued message.
|
||||
"""
|
||||
path = argv[0]
|
||||
apply_it = "--apply" in argv[1:]
|
||||
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
|
||||
last_good = -1 # index of the last line carrying a msg_ id
|
||||
foreign, synthetic = [], []
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(obj)
|
||||
if mid is None:
|
||||
continue
|
||||
if mid.startswith("msg_"):
|
||||
last_good = i
|
||||
elif mid.startswith("gen-"):
|
||||
foreign.append({"line": i + 1, "id": mid,
|
||||
"model": str((obj.get("message") or {}).get("model", ""))})
|
||||
else:
|
||||
synthetic.append({"line": i + 1, "id": mid,
|
||||
"apiError": bool(obj.get("isApiErrorMessage"))})
|
||||
|
||||
# Only the *last* id matters for resuming: an error placeholder in the
|
||||
# middle of a long-finished turn is history, not a blocker.
|
||||
tail_ids = [i for i in range(len(lines) - 1, last_good, -1)
|
||||
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
|
||||
healthy = (last_good >= 0 and not tail_ids)
|
||||
|
||||
out = {
|
||||
"path": path,
|
||||
"lines": len(lines),
|
||||
"lastGoodLine": last_good + 1 if last_good >= 0 else 0,
|
||||
"dropLines": 0 if healthy or last_good < 0 else len(lines) - (last_good + 1),
|
||||
"foreignIds": foreign,
|
||||
"syntheticIds": synthetic,
|
||||
"healthy": healthy,
|
||||
"repairable": (not healthy) and last_good >= 0,
|
||||
"applied": False,
|
||||
"backup": "",
|
||||
}
|
||||
|
||||
if apply_it and out["repairable"]:
|
||||
stamp = __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup = "%s.pre-repair-backup-%s" % (path, stamp)
|
||||
with open(backup, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines[:last_good + 1]) + "\n")
|
||||
out["applied"] = True
|
||||
out["backup"] = backup
|
||||
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
def _safe(line):
|
||||
try:
|
||||
return json.loads(line)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
COMMANDS = {
|
||||
"health": cmd_health,
|
||||
"preflight-json": cmd_preflight_json,
|
||||
"sessions-json": cmd_sessions_json,
|
||||
"repair-session": cmd_repair_session,
|
||||
"stale-models": cmd_stale_models,
|
||||
"strip-tags": cmd_strip_tags,
|
||||
"or-models": cmd_or_models,
|
||||
|
||||
Reference in New Issue
Block a user