fix(turn,backend): confirm submit on turn records only, resend lost paste, longer tui wait
This commit is contained in:
@@ -89,7 +89,7 @@ class BackendOptions:
|
|||||||
history_injection_mode: HistoryInjectionMode = "native_jsonl"
|
history_injection_mode: HistoryInjectionMode = "native_jsonl"
|
||||||
wait_for_turn_duration: bool = False
|
wait_for_turn_duration: bool = False
|
||||||
include_meta_user: bool = False
|
include_meta_user: bool = False
|
||||||
startup_delay: float = 10.0
|
startup_delay: float = 60.0
|
||||||
file_wait_timeout: float = 30.0
|
file_wait_timeout: float = 30.0
|
||||||
turn_duration_timeout: float = 5.0
|
turn_duration_timeout: float = 5.0
|
||||||
|
|
||||||
|
|||||||
+57
-18
@@ -52,7 +52,7 @@ _TERMINAL_STOP_REASONS: frozenset[str] = frozenset(
|
|||||||
|
|
||||||
_DEFAULT_FILE_WAIT_TIMEOUT = 30.0
|
_DEFAULT_FILE_WAIT_TIMEOUT = 30.0
|
||||||
_DEFAULT_TURN_DURATION_TIMEOUT = 5.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.
|
# 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
|
# This is necessary (without it our paste markers parse as raw ANSI and
|
||||||
@@ -169,8 +169,7 @@ class TurnManager:
|
|||||||
"""
|
"""
|
||||||
if self._started:
|
if self._started:
|
||||||
_log.info(
|
_log.info(
|
||||||
"start: session_id=%s already started, skip",
|
"start: session_id=%s already started, skip", self._pty.session_id
|
||||||
self._pty.session_id,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
_log.info(
|
_log.info(
|
||||||
@@ -197,10 +196,24 @@ class TurnManager:
|
|||||||
_TUI_READY_MARKER, timeout=self._startup_delay
|
_TUI_READY_MARKER, timeout=self._startup_delay
|
||||||
)
|
)
|
||||||
t_marker = loop.time() - t0
|
t_marker = loop.time() - t0
|
||||||
|
if marker_found:
|
||||||
_log.info(
|
_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,
|
self._pty.session_id,
|
||||||
"FOUND" if marker_found else "TIMED_OUT",
|
|
||||||
t_marker,
|
t_marker,
|
||||||
)
|
)
|
||||||
# Phase 2: wait for the TUI render burst to settle —
|
# Phase 2: wait for the TUI render burst to settle —
|
||||||
@@ -226,10 +239,7 @@ class TurnManager:
|
|||||||
)
|
)
|
||||||
await asyncio.sleep(self._startup_delay)
|
await asyncio.sleep(self._startup_delay)
|
||||||
self._started = True
|
self._started = True
|
||||||
_log.info(
|
_log.info("start: session_id=%s READY", self._pty.session_id)
|
||||||
"start: session_id=%s READY",
|
|
||||||
self._pty.session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_user_message(self, text: str) -> AsyncIterator[Event]:
|
async def send_user_message(self, text: str) -> AsyncIterator[Event]:
|
||||||
"""Send `text` as a user prompt and stream events until turn-end.
|
"""Send `text` as a user prompt and stream events until turn-end.
|
||||||
@@ -299,12 +309,12 @@ class TurnManager:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
records = await self._watcher.read_once()
|
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
|
submit_confirmed = True
|
||||||
if submit_attempts > 1:
|
if submit_attempts > 1:
|
||||||
_log.info(
|
_log.info(
|
||||||
"send_user_message: session_id=%s SUBMIT confirmed "
|
"send_user_message: session_id=%s SUBMIT confirmed "
|
||||||
"after %d Enter(s), %.1fs",
|
"after %d attempt(s), %.1fs",
|
||||||
sid,
|
sid,
|
||||||
submit_attempts,
|
submit_attempts,
|
||||||
loop.time() - t_loop_start,
|
loop.time() - t_loop_start,
|
||||||
@@ -354,15 +364,32 @@ class TurnManager:
|
|||||||
f"{now - t_loop_start:.0f}s ({self._watcher.path})"
|
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(
|
_log.warning(
|
||||||
"send_user_message: session_id=%s no JSONL record %.1fs "
|
"send_user_message: session_id=%s no turn record "
|
||||||
"after Enter #%d — paste likely stuck in the input box, "
|
"%.1fs after Enter — re-pressing Enter",
|
||||||
"re-pressing Enter",
|
|
||||||
sid,
|
sid,
|
||||||
_SUBMIT_CONFIRM_WINDOW,
|
_SUBMIT_CONFIRM_WINDOW,
|
||||||
submit_attempts,
|
|
||||||
)
|
)
|
||||||
await self._send_submit_key()
|
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_attempts += 1
|
||||||
submit_deadline = now + _SUBMIT_CONFIRM_WINDOW
|
submit_deadline = now + _SUBMIT_CONFIRM_WINDOW
|
||||||
# Heartbeat log every 10s so a hung wait is visible.
|
# Heartbeat log every 10s so a hung wait is visible.
|
||||||
@@ -412,9 +439,7 @@ class TurnManager:
|
|||||||
)
|
)
|
||||||
except MessageParseError as exc:
|
except MessageParseError as exc:
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"send_user_message: session_id=%s parse error: %s",
|
"send_user_message: session_id=%s parse error: %s", sid, exc
|
||||||
sid,
|
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
if self._on_parse_error is not None:
|
if self._on_parse_error is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
@@ -556,6 +581,20 @@ class TurnManager:
|
|||||||
await self.aclose()
|
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:
|
def _extract_duration_ms(data: dict[str, Any]) -> int | None:
|
||||||
"""Pull a turn-duration value out of a `system.subtype=turn_duration` record."""
|
"""Pull a turn-duration value out of a `system.subtype=turn_duration` record."""
|
||||||
for key in ("durationMs", "duration_ms"):
|
for key in ("durationMs", "duration_ms"):
|
||||||
|
|||||||
Reference in New Issue
Block a user