feat(sandbox): recover chat session mapping after OWUI restart
Closes #5. In-process _chat_sessions dict is empty after any restart, which silently started a fresh Claude session on the next turn and lost the prior conversation. On cache miss we now scan the per-chat CLAUDE_CONFIG_DIR on disk, pick the newest session JSONL by mtime, and resume from it — Claude's own filesystem layout is already the source of truth. UUID regex guards against unexpected files in that directory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3cec4dc871
commit
af2e69a4bb
|
|
@ -61,9 +61,21 @@ _MAX_ARTIFACT_BYTES = 50 * 1024 * 1024
|
||||||
# OpenWebUI calls pipe() fresh for each chat turn. Same pattern as the SDK
|
# OpenWebUI calls pipe() fresh for each chat turn. Same pattern as the SDK
|
||||||
# pipe — in-process map of chat_id → claude session_id so `claude --resume`
|
# pipe — in-process map of chat_id → claude session_id so `claude --resume`
|
||||||
# picks up prior turns' state.
|
# picks up prior turns' state.
|
||||||
|
#
|
||||||
|
# This cache is lost on OWUI restart, but the session JSONL Claude writes to
|
||||||
|
# $CLAUDE_CONFIG_DIR/projects/<slug>/<session>.jsonl IS the authoritative
|
||||||
|
# source. _recover_session_id rebuilds the mapping from disk on cache miss.
|
||||||
_chat_sessions: Dict[str, str] = {}
|
_chat_sessions: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Valid claude session UUIDs on disk. Filter against stray files so a
|
||||||
|
# misnamed artifact can't be silently used as a session id. Claude writes
|
||||||
|
# lowercase-hex UUIDs with standard hyphenation.
|
||||||
|
_SESSION_ID_RE = re.compile(
|
||||||
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# open-terminal client
|
# open-terminal client
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -87,6 +99,35 @@ def _safe_chat_id(raw: str) -> str:
|
||||||
return _CHAT_ID_SAFE.sub("", raw) or "default"
|
return _CHAT_ID_SAFE.sub("", raw) or "default"
|
||||||
|
|
||||||
|
|
||||||
|
async def _recover_session_id(
|
||||||
|
client: "OpenTerminalClient", user_id: str, workdir: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Find the newest session JSONL on disk and extract its session_id.
|
||||||
|
|
||||||
|
Claude stores per-project session history at
|
||||||
|
$CLAUDE_CONFIG_DIR/projects/<cwd-slug>/<session-uuid>.jsonl, and the
|
||||||
|
filename stem IS the session id (no JSON parsing required). Because we
|
||||||
|
set CLAUDE_CONFIG_DIR per-chat (`<workdir>/.claude`, see #9), every
|
||||||
|
JSONL under that tree belongs to this chat — we don't need to know
|
||||||
|
Claude's cwd slugification rules.
|
||||||
|
|
||||||
|
Returns None if no session has been written yet (first turn of a chat).
|
||||||
|
`ls -t` handles the rare multi-session case (prior resume failed and
|
||||||
|
Claude started a fresh session in the same workdir) by ordering newest
|
||||||
|
first, per the issue's acceptance criteria.
|
||||||
|
"""
|
||||||
|
# `workdir` is already sanitized (alphanumerics + / . _ - ~) and must
|
||||||
|
# remain unquoted so bash expands `~`. The rest of the glob is static.
|
||||||
|
cmd = f"ls -t {workdir}/.claude/projects/*/*.jsonl 2>/dev/null | head -1"
|
||||||
|
out = (await client.run_capture(user_id, cmd)).strip()
|
||||||
|
if not out.endswith(".jsonl"):
|
||||||
|
return None
|
||||||
|
basename = out.rsplit("/", 1)[-1][: -len(".jsonl")]
|
||||||
|
if not _SESSION_ID_RE.match(basename):
|
||||||
|
return None
|
||||||
|
return basename
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _ProcessHandle:
|
class _ProcessHandle:
|
||||||
process_id: str
|
process_id: str
|
||||||
|
|
@ -228,6 +269,17 @@ class OpenTerminalClient:
|
||||||
async for _ in self.stream_output(handle):
|
async for _ in self.stream_output(handle):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def run_capture(self, user_id: str, command: str) -> str:
|
||||||
|
"""Run a short command and return its merged stdout/stderr as a single
|
||||||
|
string. For one-shot utility commands (ls, cat of tiny files) where
|
||||||
|
we don't need streaming — keeps callers simple."""
|
||||||
|
handle = await self.start(user_id, command)
|
||||||
|
chunks: List[str] = []
|
||||||
|
async for stream, chunk in self.stream_output(handle):
|
||||||
|
if stream == "output":
|
||||||
|
chunks.append(chunk)
|
||||||
|
return "".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# claude CLI invocation + stream-json parsing
|
# claude CLI invocation + stream-json parsing
|
||||||
|
|
@ -708,6 +760,8 @@ class Pipe:
|
||||||
permission_mode=self.valves.PERMISSION_MODE,
|
permission_mode=self.valves.PERMISSION_MODE,
|
||||||
allowed_tools=allowed_tools,
|
allowed_tools=allowed_tools,
|
||||||
max_turns=self.valves.MAX_TURNS,
|
max_turns=self.valves.MAX_TURNS,
|
||||||
|
# Populated below once the client is open — may be recovered
|
||||||
|
# from disk if the in-process cache was cleared by a restart.
|
||||||
resume_session_id=_chat_sessions.get(chat_id),
|
resume_session_id=_chat_sessions.get(chat_id),
|
||||||
anthropic_base_url=self.valves.ANTHROPIC_BASE_URL,
|
anthropic_base_url=self.valves.ANTHROPIC_BASE_URL,
|
||||||
workdir=workdir,
|
workdir=workdir,
|
||||||
|
|
@ -755,6 +809,24 @@ class Pipe:
|
||||||
self.valves.OPEN_TERMINAL_API_KEY,
|
self.valves.OPEN_TERMINAL_API_KEY,
|
||||||
) as client:
|
) as client:
|
||||||
await client.ensure_dir(sandbox_user, workdir)
|
await client.ensure_dir(sandbox_user, workdir)
|
||||||
|
|
||||||
|
# Cache miss = either a brand-new chat OR an OWUI restart
|
||||||
|
# dropped the in-process map (#5). Claude's own on-disk
|
||||||
|
# session JSONL tells us which — look there before giving
|
||||||
|
# up and starting a fresh session.
|
||||||
|
if cfg.resume_session_id is None:
|
||||||
|
recovered = await _recover_session_id(
|
||||||
|
client, sandbox_user, workdir
|
||||||
|
)
|
||||||
|
if recovered:
|
||||||
|
cfg.resume_session_id = recovered
|
||||||
|
_chat_sessions[chat_id] = recovered
|
||||||
|
log.info(
|
||||||
|
"recovered session %s for chat %s from disk",
|
||||||
|
recovered,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
before_snapshot = await _snapshot_workspace(
|
before_snapshot = await _snapshot_workspace(
|
||||||
client, sandbox_user, workdir
|
client, sandbox_user, workdir
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue