fix: route internal-turn approval cards to visible channels; feed provider errors back to the model
Approval cards raised by company-mode internal scheduling turns (review/report work items, session ids like `<root>:review::<wid>::vN`) were posted to the turn's own session channel, which the UI deliberately hides. The card silently timed out after 300s and the work item parked on AWAITING_HUMAN, so users saw only the gate card and never the approval prompt. ws_handler now detects these internal turns and routes their escalation cards to origin_task_id, the root session's primary task channel, or the activity channel — never the hidden one. Also unblocks the previously dead origin/session fallbacks in the resolver. Unclassified LLM stream failures (e.g. provider content-filter rejections like "input may contain sensitive information") used to hit a blind truncate-retry loop that replayed the identical payload for a dozen-plus consecutive failures. runtime_v2 now feeds the provider's verbatim error text back into the conversation as a "[runtime notice]" system message so the model can adapt (rephrase, drop quotes, change tack), bounded at 2 feedback retries (counter resets on any successful stream) plus one context-reset attempt, then fails honestly with the real error. The blind truncate path remains only for classified tool-protocol errors. Verified: new end-to-end tests for recover-after-notice and bounded-failure; runtime_v2 + ws_handler + escalation/approval + company-mode suites all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1960,6 +1960,9 @@ class WSHandler:
|
||||
logger.warning(f"Failed to resolve escalation task mapping for {source_task_id}: {e}")
|
||||
task = None
|
||||
if task is not None:
|
||||
internal_turn_target = self._company_internal_turn_escalation_target(task)
|
||||
if internal_turn_target is not None:
|
||||
return internal_turn_target or None
|
||||
ui_task_id = self._ui_task_id_for_task(task)
|
||||
if ui_task_id:
|
||||
return ui_task_id
|
||||
@@ -1975,6 +1978,45 @@ class WSHandler:
|
||||
|
||||
return source_task_id
|
||||
|
||||
def _company_internal_turn_escalation_target(self, task: Any | None) -> str | None:
|
||||
"""Visible routing target for escalations raised by internal
|
||||
company-mode scheduling turns.
|
||||
|
||||
Review/report turn work items get composite ids (``review::<wid>::vN``),
|
||||
so their runtime tasks carry session ids shaped like
|
||||
``<root_session>:review::<wid>::vN``. The UI deliberately hides those
|
||||
session channels, so an approval card posted to the turn's own channel
|
||||
can never be seen or answered — it silently times out and the work item
|
||||
parks on AWAITING_HUMAN.
|
||||
|
||||
Returns None when ``task`` is not such an internal turn (caller keeps
|
||||
its normal resolution), the primary task id of the run's root session
|
||||
when resolvable, or "" when the turn is internal but no visible session
|
||||
is known (caller should fall back to the activity channel rather than
|
||||
the hidden channel).
|
||||
"""
|
||||
if task is None:
|
||||
return None
|
||||
session_id = str(getattr(task, "session_id", "") or "").strip()
|
||||
root_session_id, sep, suffix = session_id.partition(":")
|
||||
if not sep or "::" not in suffix:
|
||||
return None
|
||||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
origin_task_id = str(metadata.get("origin_task_id") or "").strip()
|
||||
task_id = str(getattr(task, "id", "") or "").strip()
|
||||
if origin_task_id and origin_task_id != task_id:
|
||||
return origin_task_id
|
||||
for candidate_session_id in (
|
||||
root_session_id,
|
||||
str(getattr(task, "parent_session_id", "") or "").strip(),
|
||||
):
|
||||
if not candidate_session_id:
|
||||
continue
|
||||
mapped_task_id = str(self._session_to_task.get(candidate_session_id) or "").strip()
|
||||
if mapped_task_id and mapped_task_id != task_id:
|
||||
return mapped_task_id
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _pending_escalation_matches_task(record: dict[str, Any], task_id: str | None) -> bool:
|
||||
task_key = str(task_id or "").strip()
|
||||
|
||||
Reference in New Issue
Block a user