practice hardening: weak-model argument heal + parked dismissals mid-turn

The GLM incident (2026-10-04): glm-5.3 sent mode as a JSON-string-encoded
enum ('"recall"') on 19 consecutive problem calls; TypeBox's error never
named the expected values, so the model re-emitted the identical payload
until the dev server was killed. And mid-turn the learner was trapped:
Esc's question/problem rungs only dismissed — the dismiss route 409'd
against the turn-owned record and nothing could stop the running turn.

- healEncodedArguments (practice.ts, the prepareArguments seam): a STRING
  where the schema expects a literal union / boolean / null / number /
  array is JSON.parse-d and kept when it satisfies the schema. Valid args
  pass through untouched (same reference); unhealable values stay for
  validation to reject honestly. Wired on BOTH problem and conclude.
- dismissal-park.ts: the dismiss route parks a mid-turn dismissal (202);
  the turn's settle path (onFinish, after persistTurn, before endTurn —
  the same ordering law as the record write itself) applies it against
  the persisted record, only if the dismissed thing really is pending.
- confirmAbort: question/problem discard while busy now ALSO aborts the
  in-flight turn; the dialog line says so (busy prop).

No error-result cap (owner ruling). 16 new pins (heal incl. pi-ai
validateToolArguments end-to-end; park registry, apply paths, the 202
route contract).
This commit is contained in:
amosblomqvist
2026-10-04 23:57:00 +03:00
parent 4640117ba7
commit 85022ae143
9 changed files with 544 additions and 17 deletions
+14 -7
View File
@@ -13,11 +13,14 @@
*
* Guards mirror the turn route's honesty: 404 unknown chat, 400 for an
* input that targets no dangling question call (or isn't a real
* dismissal), 409 when a turn is somehow running — the dismissing client
* heals via the record-changed sync, same as any raced settle.
* dismissal). A RUNNING turn no longer refuses (owner, 2026-10-04 — the
* GLM practice-loop incident): the dismissal is PARKED server-side and
* the turn's settle path applies it against the persisted record, so
* ESC-discard now doubles as the turn abort's bookkeeper (the client
* fires both).
*/
import { ChatInputError, dismissAppends, emit, parseDismissInput } from "@/src/core";
import { getChat, isTurnRunning, stores } from "@/src/server";
import { getChat, isTurnRunning, parkDismissal, stores } from "@/src/server";
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await ctx.params;
@@ -35,11 +38,15 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
throw err;
}
// A dismissal can only target a settled question (question tools end
// their turn) — a running turn means something raced; refuse, never
// append around a live turn's own persist.
// A running turn owns the chat's record (its messages persist at
// settle) — the dismissal cannot validate against stale truth, so it
// is PARKED (owner, 2026-10-04): onFinish applies it to the settled
// record if the dismissed question/problem really is pending there.
// The dismissing client has also aborted the turn — this 202 is the
// "noted, will land" contract.
if (isTurnRunning(id)) {
return Response.json({ error: "A turn is running for this session" }, { status: 409 });
parkDismissal(id, input);
return Response.json({ ok: true, parked: true }, { status: 202 });
}
let appends;