fix(turn,backend): confirm submit on turn records only, resend lost paste, longer tui wait

This commit is contained in:
hh
2026-07-26 00:49:41 +02:00
parent 89065c2f4e
commit aa7beea1e0
2 changed files with 67 additions and 28 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ class BackendOptions:
history_injection_mode: HistoryInjectionMode = "native_jsonl"
wait_for_turn_duration: bool = False
include_meta_user: bool = False
startup_delay: float = 10.0
startup_delay: float = 60.0
file_wait_timeout: float = 30.0
turn_duration_timeout: float = 5.0
+57 -18
View File
@@ -52,7 +52,7 @@ _TERMINAL_STOP_REASONS: frozenset[str] = frozenset(
_DEFAULT_FILE_WAIT_TIMEOUT = 30.0
_DEFAULT_TURN_DURATION_TIMEOUT = 5.0
_DEFAULT_STARTUP_DELAY = 10.0
_DEFAULT_STARTUP_DELAY = 60.0
# DECSET 2004 — claude's TUI enables bracketed paste mode early in init.
# This is necessary (without it our paste markers parse as raw ANSI and
@@ -169,8 +169,7 @@ class TurnManager:
"""
if self._started:
_log.info(
"start: session_id=%s already started, skip",
self._pty.session_id,
"start: session_id=%s already started, skip", self._pty.session_id
)
return
_log.info(
@@ -197,10 +196,24 @@ class TurnManager:
_TUI_READY_MARKER, timeout=self._startup_delay
)
t_marker = loop.time() - t0
if marker_found:
_log.info(
"start: session_id=%s marker DECSET2004 %s after %.2fs",
"start: session_id=%s marker DECSET2004 FOUND after %.2fs",
self._pty.session_id,
t_marker,
)
else:
# Proceeding anyway is a coin flip: without bracketed
# paste enabled our ESC[200~ framing is parsed as junk
# and the prompt vanishes silently. We still continue
# rather than fail the turn — `send_user_message`
# notices the missing turn record and re-sends — but
# this is the line to grep for when a prompt is lost.
_log.warning(
"start: session_id=%s marker DECSET2004 TIMED_OUT after "
"%.2fs — TUI not ready, the first prompt may be dropped "
"(raise startup_delay if this recurs)",
self._pty.session_id,
"FOUND" if marker_found else "TIMED_OUT",
t_marker,
)
# Phase 2: wait for the TUI render burst to settle —
@@ -226,10 +239,7 @@ class TurnManager:
)
await asyncio.sleep(self._startup_delay)
self._started = True
_log.info(
"start: session_id=%s READY",
self._pty.session_id,
)
_log.info("start: session_id=%s READY", self._pty.session_id)
async def send_user_message(self, text: str) -> AsyncIterator[Event]:
"""Send `text` as a user prompt and stream events until turn-end.
@@ -299,12 +309,12 @@ class TurnManager:
while True:
records = await self._watcher.read_once()
if records and not submit_confirmed:
if records and not submit_confirmed and _has_turn_record(records):
submit_confirmed = True
if submit_attempts > 1:
_log.info(
"send_user_message: session_id=%s SUBMIT confirmed "
"after %d Enter(s), %.1fs",
"after %d attempt(s), %.1fs",
sid,
submit_attempts,
loop.time() - t_loop_start,
@@ -354,15 +364,32 @@ class TurnManager:
f"{now - t_loop_start:.0f}s ({self._watcher.path})"
),
)
# Escalate: a swallowed Enter and a swallowed paste
# look identical from here, but need different
# remedies. Try the cheap, duplicate-proof one
# first (a bare Enter is a no-op on an empty input
# box); only re-send the whole prompt once that has
# failed, since the paste itself must then have
# been eaten — e.g. written before claude's TUI
# enabled bracketed paste, which turns our markers
# into discarded junk.
if submit_attempts == 1:
_log.warning(
"send_user_message: session_id=%s no JSONL record %.1fs "
"after Enter #%d — paste likely stuck in the input box, "
"re-pressing Enter",
"send_user_message: session_id=%s no turn record "
"%.1fs after Enter — re-pressing Enter",
sid,
_SUBMIT_CONFIRM_WINDOW,
submit_attempts,
)
await self._send_submit_key()
else:
_log.warning(
"send_user_message: session_id=%s still no turn "
"record after attempt #%d — the paste itself was "
"lost, re-sending the whole prompt",
sid,
submit_attempts,
)
await self._pty.write(text)
submit_attempts += 1
submit_deadline = now + _SUBMIT_CONFIRM_WINDOW
# Heartbeat log every 10s so a hung wait is visible.
@@ -412,9 +439,7 @@ class TurnManager:
)
except MessageParseError as exc:
_log.warning(
"send_user_message: session_id=%s parse error: %s",
sid,
exc,
"send_user_message: session_id=%s parse error: %s", sid, exc
)
if self._on_parse_error is not None:
with contextlib.suppress(Exception):
@@ -556,6 +581,20 @@ class TurnManager:
await self.aclose()
def _has_turn_record(records: Iterable[JsonlRecord]) -> bool:
"""Does this batch prove claude actually accepted our prompt?
Only ``user`` / ``assistant`` records do. A session writes a burst of
bookkeeping on startup — ``mode``, ``permission-mode``, and, under
``--remote-control``, ``bridge-session`` plus a ``system`` /
``bridge_status`` heartbeat — none of which depend on a prompt having
been submitted. Treating "any record at all" as proof made the submit
check pass vacuously on exactly the cold-start turns it exists to
catch.
"""
return any(rec.get("type") in ("user", "assistant") for rec in records)
def _extract_duration_ms(data: dict[str, Any]) -> int | None:
"""Pull a turn-duration value out of a `system.subtype=turn_duration` record."""
for key in ("durationMs", "duration_ms"):