feat(assistant): fail soft - validated renders, error parts, timeouts, turns that always end, client error reports #60

Merged
KofTwentyTwo merged 3 commits from feature/fail-soft-renders into develop 2026-10-16 02:10:02 +00:00
KofTwentyTwo commented 2026-10-16 01:38:41 +00:00 (Migrated from github.com)

Summary

Server half of the "fail soft" initiative: the server never emits malformed renders, tool/provider failures become structured error parts, and every turn ends. Adds the client error report endpoint the Next UI will call.

  • Render validation (engine/RenderShapes): every part, tool render (records, record, count, widget, history, pending_action, bulk_load_dry_run) and presentation (table, chart, metrics, filterLinks, recordLinks, actions blocks) is normalized before it is persisted or sent over SSE, and again when stored parts / turn events are read (repair on read). Required lists are always present; a render that cannot be repaired is dropped and replaced by an error notice ("This result could not be displayed."); unknown part and render types pass through. Problems are logged with type + fixed code only.
  • Failures as error parts: tool exceptions (including Errors and toolbox failures) and timeouts become an ERROR (is_error) result for the model, an ERROR tool part and an error notice; internal details go only to the log.
  • Timeouts: new toolCallTimeoutSeconds (default 60) per tool call, measured from when the call gets a permit and capped by the turn deadline; the existing maxTurnSeconds (default 300) still ends a turn with status ERROR and an error event code TIMEOUT.
  • Terminal state always: terminal events (usage, error, message.end) are emitted independently, so a failing emit never skips message.end or the terminal turn row. Conversation detail repairs assistant messages left STREAMING (orphaned runs are reaped; a run that already ended gives its status); the retention process finalizes runs still RUNNING past the hard stream limit (e.g. after a pod restart). The recovery writes are shared in engine/AbandonedTurns.
  • POST /qqq/v1/assistant/client-errors (UI API extension assistant 1.5.0): request AssistantClientErrorReportV1, response 202 AssistantClientErrorResponseV1 {accepted}. Any signed-in user; scope and message required (400 otherwise, and for a non-object body). Fields bounded, message/stacks scrubbed (secrets via AiRedactor, e-mails, digit runs > 6, mixed tokens, query strings), route path only, ids kept only when they are the user's own. Over clientErrorsPerUserPerMinute (default 30): 202 {"accepted": false}, dropped. Rows go to the new owner-locked assistant_client_error table (Assistant app nav for admins, record screens, help, channel wall via the assistant_* pattern, SET NULL FKs in the reference DDL, retention after toolResultRetentionDays). Health check assistant.client-errors is DEGRADED at clientErrorWarnPerHour (default 20) reports/hour, which also shows on the Assistant health card.

Upgrade

Apply the Fail soft block at the end of docs/schema-postgres.sql (creates assistant_client_error; IF NOT EXISTS). See UPGRADE.md / CHANGELOG.md.

Tests

  • RenderShapesTest: normalization per render type, presentation blocks, unknown types, unrepairable renders → notices, events.
  • FailSoftTurnsTest: tool exception → error part, tool timeout, turn timeout (error code TIMEOUT), exception in the loop → message.end ERROR + turn/message finalized + unregistered + no lockout, repair on read of stuck/legacy messages, retention sweep.
  • ClientErrorsTest: bounds and scrubbing, owned ids only, required fields, per-user rate limit, owner security, HTTP 202/400, health check, retention.
  • Contract (AssistantSpecsContractTest, regenerated assistant.yaml), guardrails (tables, help bundle, FK rules), producer nav and health registration updated.

./mvnw verify passes locally (JaCoCo gate met).

🤖 Generated with Claude Code

## Summary Server half of the "fail soft" initiative: the server never emits malformed renders, tool/provider failures become structured error parts, and every turn ends. Adds the client error report endpoint the Next UI will call. - **Render validation** (`engine/RenderShapes`): every part, tool render (records, record, count, widget, history, pending_action, bulk_load_dry_run) and presentation (table, chart, metrics, filterLinks, recordLinks, actions blocks) is normalized before it is persisted or sent over SSE, and again when stored parts / turn events are read (repair on read). Required lists are always present; a render that cannot be repaired is dropped and replaced by an error notice ("This result could not be displayed."); unknown part and render types pass through. Problems are logged with type + fixed code only. - **Failures as error parts**: tool exceptions (including `Error`s and toolbox failures) and timeouts become an ERROR (`is_error`) result for the model, an ERROR tool part and an error notice; internal details go only to the log. - **Timeouts**: new `toolCallTimeoutSeconds` (default 60) per tool call, measured from when the call gets a permit and capped by the turn deadline; the existing `maxTurnSeconds` (default 300) still ends a turn with status ERROR and an `error` event code `TIMEOUT`. - **Terminal state always**: terminal events (`usage`, `error`, `message.end`) are emitted independently, so a failing emit never skips `message.end` or the terminal turn row. Conversation detail repairs assistant messages left `STREAMING` (orphaned runs are reaped; a run that already ended gives its status); the retention process finalizes runs still `RUNNING` past the hard stream limit (e.g. after a pod restart). The recovery writes are shared in `engine/AbandonedTurns`. - **`POST /qqq/v1/assistant/client-errors`** (UI API extension assistant **1.5.0**): request `AssistantClientErrorReportV1`, response `202 AssistantClientErrorResponseV1 {accepted}`. Any signed-in user; `scope` and `message` required (400 otherwise, and for a non-object body). Fields bounded, message/stacks scrubbed (secrets via AiRedactor, e-mails, digit runs > 6, mixed tokens, query strings), route path only, ids kept only when they are the user's own. Over `clientErrorsPerUserPerMinute` (default 30): `202 {"accepted": false}`, dropped. Rows go to the new owner-locked `assistant_client_error` table (Assistant app nav for admins, record screens, help, channel wall via the `assistant_*` pattern, SET NULL FKs in the reference DDL, retention after `toolResultRetentionDays`). Health check `assistant.client-errors` is DEGRADED at `clientErrorWarnPerHour` (default 20) reports/hour, which also shows on the Assistant health card. ## Upgrade Apply the **Fail soft** block at the end of `docs/schema-postgres.sql` (creates `assistant_client_error`; `IF NOT EXISTS`). See UPGRADE.md / CHANGELOG.md. ## Tests - `RenderShapesTest`: normalization per render type, presentation blocks, unknown types, unrepairable renders → notices, events. - `FailSoftTurnsTest`: tool exception → error part, tool timeout, turn timeout (`error` code TIMEOUT), exception in the loop → `message.end` ERROR + turn/message finalized + unregistered + no lockout, repair on read of stuck/legacy messages, retention sweep. - `ClientErrorsTest`: bounds and scrubbing, owned ids only, required fields, per-user rate limit, owner security, HTTP 202/400, health check, retention. - Contract (`AssistantSpecsContractTest`, regenerated `assistant.yaml`), guardrails (tables, help bundle, FK rules), producer nav and health registration updated. `./mvnw verify` passes locally (JaCoCo gate met). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.