Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 54 additions & 69 deletions src/ccbot/bot/_session_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,64 +91,28 @@ async def create_and_activate_session(
user.id,
resume_session_id,
)
# `claude --resume` records a new session_id in the hook, but messages
# still write to the resumed JSONL. The card seeds from that existing
# transcript, so for a resume we must resolve the canonical session_id
# BEFORE painting — wait for the hook (or fall back to the known resume
# id on timeout), then override window_state to track it.
#
# A fresh session has nothing to seed, so this pre-paint wait is
# skipped entirely (see below): the empty card goes up the instant the
# window exists instead of blocking on claude's 2-5s boot + the
# SessionStart hook. The hook is confirmed (and the fresh session_id
# bound) after the paint, before any pending text is forwarded.
# Publish the session immediately, while the agent process boots in the
# pane. Every send is queued until the real TUI input prompt appears.
# This covers fresh starts, normal resumes, and long resume compaction
# with one ordering-preserving gate.
session_manager.mark_window_starting(
created_wid,
backend=session_manager.agent_backend,
resume=resume_session_id is not None,
bot=context.bot,
user_id=user.id,
)

# A resumed transcript id is already authoritative. Bind it before paint
# instead of waiting up to 15 seconds for a lifecycle hook; the hook is
# reconciled in the background below.
if resume_session_id:
# A near-limit transcript auto-compacts on resume (60-110s); flag
# the window so any prompt that arrives while we're still
# compacting buffers into _pending_sends instead of being typed
# mid-compaction. The background watcher drains the buffer after
# the pane settles AND refreshes Telegram TYPING in the meantime
# so the chat doesn't look frozen.
if session_manager.agent_backend == "claude":
session_manager.mark_window_resuming(
created_wid, bot=context.bot, user_id=user.id
)
ws = session_manager.get_window_state(created_wid)
if session_manager.agent_backend == "codex":
# ``codex resume`` preserves the rollout id, so binding it is
# deterministic and must not wait for a SessionStart hook that can
# arrive only after the CLI finishes booting.
ws.session_id = resume_session_id
ws.cwd = str(selected_path)
ws.window_name = created_wname
ws.backend = "codex"
session_manager.save_state()
hook_ok = True
else:
hook_ok = await session_manager.wait_for_session_map_entry(
created_wid, timeout=15.0
)
if not hook_ok:
logger.warning(
"Hook timed out for resume window %s, "
"manually setting session_id=%s cwd=%s",
created_wid,
resume_session_id,
selected_path,
)
ws.session_id = resume_session_id
ws.cwd = str(selected_path)
ws.window_name = created_wname
session_manager.save_state()
elif ws.session_id != resume_session_id:
logger.info(
"Resume override: window %s session_id %s -> %s",
created_wid,
ws.session_id,
resume_session_id,
)
ws.session_id = resume_session_id
session_manager.save_state()
ws.session_id = resume_session_id
ws.cwd = str(selected_path)
ws.window_name = created_wname
ws.backend = session_manager.agent_backend
session_manager.save_state()

# Register Session record and make it active. Honor /new <name> if any.
pending_name = (
Expand All @@ -164,9 +128,6 @@ async def create_and_activate_session(
session_manager.set_session_claude_id(sess.id, ws.session_id)
session_manager.set_active_session(user.id, sess.id)

if session_manager.get_user_settings(user.id).get("local_terminal") == "auto":
await open_terminal_for_window(created_wid, user_id=user.id)

# Transition the carrier from dir-browser to the new session's
# empty live card in place. No separate "Created. Send messages
# here." notice — that was a dead-end stub; the live card itself
Expand All @@ -183,16 +144,40 @@ async def create_and_activate_session(
# the stale dir-browser body when paint fails.
await safe_edit(query, f"✅ {message}")

# Fresh session: claude is still booting, so the hook hasn't written
# the session_id yet. Confirm it now (card already on screen) and bind
# it onto the Session record so the monitor + history follow the right
# transcript and notifications reverse-map to this user — all before
# any pending text is forwarded below.
if not resume_session_id:
await session_manager.wait_for_session_map_entry(created_wid, timeout=5.0)
ws = session_manager.get_window_state(created_wid)
if ws.session_id and not sess.claude_session_id:
session_manager.set_session_claude_id(sess.id, ws.session_id)
async def _bind_lifecycle_in_background() -> None:
"""Attach the hook-written session id without delaying Telegram UI."""
try:
await session_manager.wait_for_session_map_entry(created_wid, timeout=15.0)
live_ws = session_manager.get_window_state(created_wid)
if resume_session_id:
# Claude may expose a transient new id for ``--resume``;
# messages still belong to the requested transcript.
if live_ws.session_id != resume_session_id:
live_ws.session_id = resume_session_id
live_ws.cwd = str(selected_path)
live_ws.window_name = created_wname
live_ws.backend = session_manager.agent_backend
session_manager.save_state()
elif live_ws.session_id and not sess.claude_session_id:
session_manager.set_session_claude_id(sess.id, live_ws.session_id)
except Exception as e:
logger.warning(
"Background lifecycle bind failed for window %s: %s",
created_wid,
e,
)

asyncio.create_task(
_bind_lifecycle_in_background(), name=f"session-bind:{created_wid}"
)

# Desktop Terminal is a convenience side-effect, never part of the
# session-start critical path.
if session_manager.get_user_settings(user.id).get("local_terminal") == "auto":
asyncio.create_task(
open_terminal_for_window(created_wid, user_id=user.id),
name=f"local-terminal:{created_wid}",
)

# Forward any pending text held while the picker was up. ``take_pending_text``
# drops a stale stash (older than PENDING_TEXT_TTL_S) so a message typed
Expand Down
2 changes: 2 additions & 0 deletions src/ccbot/bot/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1280,9 +1280,11 @@ async def _dispatch_text_to_active(
metrics.inc("tg_send_failures")
await safe_reply(update.message, f"❌ {message}")
return False
queued_for_startup = message.startswith("Queued for ")
if (
sess is not None
and sess.backend == "codex"
and not queued_for_startup
and not await tmux_manager.ensure_codex_prompt_submitted(wid, text)
):
metrics.inc("tg_send_failures")
Expand Down
62 changes: 47 additions & 15 deletions src/ccbot/handlers/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from __future__ import annotations

import asyncio
import json
import logging
import re
Expand Down Expand Up @@ -459,8 +460,6 @@ async def restore_session(bot: Bot, user_id: int, sess: Session) -> tuple[bool,
resume_session_id = sess.claude_session_id or None
initial_prompt: str | None = None
if cross_backend:
import asyncio

from ..session_import import build_import_context, import_prompt

try:
Expand All @@ -482,29 +481,30 @@ async def restore_session(bot: Bot, user_id: int, sess: Session) -> tuple[bool,
if not success:
return False, message

# A near-limit transcript auto-compacts on resume (60-110s) before it
# accepts input. Flag the window so any prompts that arrive while
# we're still compacting buffer into _pending_sends instead of being
# typed mid-compaction. The background watcher drains the buffer
# once the pane settles AND keeps Telegram TYPING refreshed so the
# chat doesn't look frozen during the wait.
if resume_session_id and target_backend == "claude":
session_manager.mark_window_resuming(created_wid, bot=bot, user_id=user_id)
# Publish the restored window immediately. Prompts sent from Telegram now
# queue until the real TUI input box appears (including long compaction).
session_manager.mark_window_starting(
created_wid,
backend=target_backend,
resume=resume_session_id is not None or initial_prompt is not None,
bot=bot,
user_id=user_id,
)

# Codex ``resume <id>`` keeps the same authoritative rollout id. We
# already know everything needed to bind the window, while its SessionStart
# hook may not run until the CLI has finished booting. Waiting 15 seconds
# here made a normal archive restore look frozen for exactly that long.
# Bind Codex immediately; the hook will later add transcript_path and
# self-heal the persisted map. Claude resume remains on the old wait path
# because Claude can report a transient new session id before we override
# it back to the resumed transcript id.
# self-heal the persisted map. Claude's original id is also known, so it
# can be published immediately and reconciled after the hook in background.
codex_restore_published = False
if resume_session_id and target_backend == "codex":
from ..codex_session_io import build_session_file_path

transcript_path = build_session_file_path(resume_session_id, workdir)
if transcript_path is None or not transcript_path.is_file():
session_manager.cancel_window_startup(created_wid)
await tmux_manager.kill_window(created_wid)
return False, "Codex rollout not found; restore was cancelled"
try:
Expand All @@ -516,11 +516,12 @@ async def restore_session(bot: Bot, user_id: int, sess: Session) -> tuple[bool,
transcript_path=transcript_path,
)
except (OSError, RuntimeError) as e:
session_manager.cancel_window_startup(created_wid)
await tmux_manager.kill_window(created_wid)
logger.warning("Codex restore binding failed for %s: %s", created_wid, e)
return False, "Could not publish Codex restore binding"
codex_restore_published = True
else:
elif cross_backend:
await session_manager.wait_for_session_map_entry(created_wid, timeout=15.0)

# If we did a --resume, override window_state to original sid (Claude allocates a new sid for the resume).
Expand All @@ -530,10 +531,12 @@ async def restore_session(bot: Bot, user_id: int, sess: Session) -> tuple[bool,
ws.session_id = resume_session_id
ws.cwd = workdir
ws.window_name = created_wname
ws.backend = target_backend
session_manager.save_state()
elif cross_backend:
ws = session_manager.get_window_state(created_wid)
if not ws.session_id:
session_manager.cancel_window_startup(created_wid)
await tmux_manager.kill_window(created_wid)
return (
False,
Expand All @@ -549,10 +552,39 @@ async def restore_session(bot: Bot, user_id: int, sess: Session) -> tuple[bool,
if not codex_restore_published:
session_manager.set_session_window(sess.id, created_wid)
session_manager.set_active_session(user_id, sess.id)

if resume_session_id and not codex_restore_published:

async def _reconcile_resume_binding() -> None:
try:
await session_manager.wait_for_session_map_entry(
created_wid, timeout=15.0
)
live_ws = session_manager.get_window_state(created_wid)
if live_ws.session_id != resume_session_id:
live_ws.session_id = resume_session_id
live_ws.cwd = workdir
live_ws.window_name = created_wname
live_ws.backend = target_backend
session_manager.save_state()
except Exception as e:
logger.warning(
"Background archive binding failed for %s: %s",
created_wid,
e,
)

asyncio.create_task(
_reconcile_resume_binding(), name=f"archive-bind:{created_wid}"
)

if session_manager.get_user_settings(user_id).get("local_terminal") == "auto":
from ..local_terminal import open_terminal_for_window

await open_terminal_for_window(created_wid, user_id=user_id)
asyncio.create_task(
open_terminal_for_window(created_wid, user_id=user_id),
name=f"local-terminal:{created_wid}",
)
note = ""
if resume_session_id:
note = " — if it was a large session it may compact for a minute; your first message is held until it's ready."
Expand Down
Loading
Loading