You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
bug(workspace): failed turns are invisible to the client — fast failures misreported as "lost track", backend error discarded, Retry suppressed where it is safe #2320
What happened (observed on a live instance, evidence in the execution row)
A Workspace message created an execution that failed 4.6 seconds later with HTTP 429 — the agent's Claude subscription hit its weekly limit, and the SUB-003 auto-switcher correctly reported no viable alternative (the whole pool was exhausted). The backend composed a precise, actionable error (limit + reset time + remediation), wrote it to schedule_executions.error, logged it, and ended the turn cleanly.
The Workspace client showed:
Still no reply — we've lost track of this turn. It may still finish; check the conversation shortly.
Every clause of that is wrong for this case: the turn did finish (failed), it will never produce a reply, and the composed error was discarded. Retrying "as-is" would 429 again, but the user has no way to know that.
Mechanism
The client learns a turn's outcome exactly two ways (PortalConversation.vue::awaitPersistedReply):
a new assistant message in persisted portal history, or
the in-flight marker (in_flight_execution_id) still being set.
A failed turn produces neither: no assistant/error message is persisted to enterprise_portal_messages, and start_portal_turn._run's finally clears the marker on every exit path (client_portal/service.py:1843-1846). The failure record lives only in schedule_executions.error (_fail_unstarted_execution, service.py:1702), which no portal read surface exposes — the history response carries in_flight_execution_id / in_flight_wait_budget_seconds but no last-turn outcome.
So after the server reports "nothing running" for REPLY_IDLE_GIVE_UP_MS (6s), the client returns { lost: true, idle: true } (PortalConversation.vue:899) — and deliver() (line 754) renders the #2133 "lost track" message designed for turns that ran and got lost. The idle: true flag that distinguishes "server says nothing is running" from "wait budget exhausted" is returned but never read — even the distinction the client already has goes unrendered.
Net effect, on every fast-failing Workspace turn (subscription/auth 429·503, agent stopped mid-dispatch, resume-lock contention, any pre-start ClientPortalError):
misreported as "lost, may still finish" when the server knows it failed and why
the actionable error the backend composed is discarded
schedule_executions.error must NOT be exposed verbatim to portal clients — it is free-form runtime text on a client-facing surface (the fix(workspace): Reports tab shows raw JSON to clients — reuse the display_hint renderers #2162/G-04 discipline). Persist/expose a typed, sanitized failure: a category (capacity, auth, agent_unavailable, busy, internal) plus a client-safe sentence; the full detail stays on the operator surfaces (Executions) where it already lands. Subscription-limit remediation ("add an API key / register a subscription") is operator guidance, not client guidance.
Retry may only be offered when the failure is provably pre-start/unbilled (the typed record can carry that bit). The lost/idle paths keep no-Retry.
Suggested shape
On the _run failure paths, persist a failure record the client can see — either a typed error message row in portal history, or a last_turn_outcome: {execution_id, status, category, message} field on the history response (the client already polls it).
Client: render "This turn failed: " instead of the lost-track message when an outcome is present; offer Retry iff the record says pre-start/unbilled.
Minimal interim step (frontend-only): read the existing idle flag and word the two give-ups differently — "The agent did not reply" (server says nothing running) vs today's "lost track" (budget exhausted while the marker lived).
Acceptance criteria
A turn that fails before/at start renders its failure (sanitized category + client-safe message) in the thread instead of "lost track"
Raw schedule_executions.error text never reaches a portal client verbatim
What happened (observed on a live instance, evidence in the execution row)
A Workspace message created an execution that failed 4.6 seconds later with HTTP 429 — the agent's Claude subscription hit its weekly limit, and the SUB-003 auto-switcher correctly reported no viable alternative (the whole pool was exhausted). The backend composed a precise, actionable error (limit + reset time + remediation), wrote it to
schedule_executions.error, logged it, and ended the turn cleanly.The Workspace client showed:
Every clause of that is wrong for this case: the turn did finish (failed), it will never produce a reply, and the composed error was discarded. Retrying "as-is" would 429 again, but the user has no way to know that.
Mechanism
The client learns a turn's outcome exactly two ways (
PortalConversation.vue::awaitPersistedReply):in_flight_execution_id) still being set.A failed turn produces neither: no assistant/error message is persisted to
enterprise_portal_messages, andstart_portal_turn._run'sfinallyclears the marker on every exit path (client_portal/service.py:1843-1846). The failure record lives only inschedule_executions.error(_fail_unstarted_execution,service.py:1702), which no portal read surface exposes — the history response carriesin_flight_execution_id/in_flight_wait_budget_secondsbut no last-turn outcome.So after the server reports "nothing running" for
REPLY_IDLE_GIVE_UP_MS(6s), the client returns{ lost: true, idle: true }(PortalConversation.vue:899) — anddeliver()(line 754) renders the #2133 "lost track" message designed for turns that ran and got lost. Theidle: trueflag that distinguishes "server says nothing is running" from "wait budget exhausted" is returned but never read — even the distinction the client already has goes unrendered.Net effect, on every fast-failing Workspace turn (subscription/auth 429·503, agent stopped mid-dispatch, resume-lock contention, any pre-start
ClientPortalError):Constraints on the fix
schedule_executions.errormust NOT be exposed verbatim to portal clients — it is free-form runtime text on a client-facing surface (the fix(workspace): Reports tab shows raw JSON to clients — reuse the display_hint renderers #2162/G-04 discipline). Persist/expose a typed, sanitized failure: a category (capacity,auth,agent_unavailable,busy,internal) plus a client-safe sentence; the full detail stays on the operator surfaces (Executions) where it already lands. Subscription-limit remediation ("add an API key / register a subscription") is operator guidance, not client guidance.Suggested shape
_runfailure paths, persist a failure record the client can see — either a typed error message row in portal history, or alast_turn_outcome: {execution_id, status, category, message}field on the history response (the client already polls it).idleflag and word the two give-ups differently — "The agent did not reply" (server says nothing running) vs today's "lost track" (budget exhausted while the marker lived).Acceptance criteria
schedule_executions.errortext never reaches a portal client verbatimidlevs budget-exhausted give-ups are worded distinctlyRelated: #2120, #2133, #2214, #2319 (the slow twin: post-lost recovery for turns that really ran), abilityai/trinity-enterprise#286, abilityai/trinity-enterprise#358