feat(state): add same-pane session deduplication

Handle edge case where Claude's --resume creates an orphan session file
before resuming the original session, leaving two session files pointing
to the same Zellij pane.

The deduplication algorithm (_dedupe_same_pane_sessions) resolves
conflicts by preferring:
1. Sessions with context_usage (indicates actual conversation occurred)
2. Higher conversation_mtime_ns (more recent file activity)

When an orphan is identified, its session file is deleted from disk to
prevent re-discovery on subsequent state collection cycles.

Test coverage includes:
- Keeping session with context_usage over one without
- Keeping higher mtime when both have context_usage
- Keeping higher mtime when neither has context_usage
- Preserving sessions on different panes (no false positives)
- Single session per pane unchanged
- Sessions without pane info unchanged
- Handling non-numeric mtime values defensively

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-28 00:47:15 -05:00
parent 1fb4a82b39
commit 19a31a4620
2 changed files with 288 additions and 4 deletions

View File

@@ -2,18 +2,18 @@ import hashlib
import json
import subprocess
import time
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from amc_server.context import (
from amc_server.config import (
EVENTS_DIR,
SESSIONS_DIR,
STALE_EVENT_AGE,
STALE_STARTING_AGE,
ZELLIJ_BIN,
_state_lock,
_zellij_cache,
)
from amc_server.zellij import ZELLIJ_BIN, _zellij_cache
from amc_server.logging_utils import LOGGER
@@ -158,6 +158,9 @@ class StateMixin:
# Sort by session_id for stable, deterministic ordering (no visual jumping)
sessions.sort(key=lambda s: s.get("session_id", ""))
# Dedupe same-pane sessions (handles --resume creating orphan + real session)
sessions = self._dedupe_same_pane_sessions(sessions)
# Clean orphan event logs (sessions persist until manually dismissed or SessionEnd)
self._cleanup_stale(sessions)
@@ -236,7 +239,7 @@ class StateMixin:
Returns:
set: Absolute paths of transcript files with active processes.
"""
from amc_server.context import CODEX_SESSIONS_DIR
from amc_server.agents import CODEX_SESSIONS_DIR
if not CODEX_SESSIONS_DIR.exists():
return set()
@@ -381,3 +384,56 @@ class StateMixin:
f.unlink()
except (json.JSONDecodeError, OSError):
pass
def _dedupe_same_pane_sessions(self, sessions):
"""Remove orphan sessions when multiple sessions share the same Zellij pane.
This handles the --resume edge case where Claude creates a new session file
before resuming the old one, leaving an orphan with no context_usage.
When multiple sessions share (zellij_session, zellij_pane), keep the one with:
1. context_usage (has actual conversation data)
2. Higher conversation_mtime_ns (more recent activity)
"""
def session_score(s):
"""Score a session for dedup ranking: (has_context, mtime)."""
has_context = 1 if s.get("context_usage") else 0
mtime = s.get("conversation_mtime_ns") or 0
# Defensive: ensure mtime is numeric
if not isinstance(mtime, (int, float)):
mtime = 0
return (has_context, mtime)
# Group sessions by pane
pane_groups = defaultdict(list)
for s in sessions:
zs = s.get("zellij_session", "")
zp = s.get("zellij_pane", "")
if zs and zp:
pane_groups[(zs, zp)].append(s)
# Find orphans to remove
orphan_ids = set()
for group in pane_groups.values():
if len(group) <= 1:
continue
# Pick the best session: prefer context_usage, then highest mtime
group_sorted = sorted(group, key=session_score, reverse=True)
# Mark all but the best as orphans
for s in group_sorted[1:]:
session_id = s.get("session_id")
if not session_id:
continue # Skip sessions without valid IDs
orphan_ids.add(session_id)
# Also delete the orphan session file
try:
orphan_file = SESSIONS_DIR / f"{session_id}.json"
orphan_file.unlink(missing_ok=True)
except OSError:
pass
# Return filtered list
return [s for s in sessions if s.get("session_id") not in orphan_ids]