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
+18 -8
View File
@@ -358,24 +358,34 @@ export function Chat({
abortInFlightTurn();
return;
}
// From a pending question there is no turn to kill — abort IS
// dismissal (A2, 2026-10-03): the skipped toolResult lands via the
// SILENT route — no turn, no wake, nothing generates. The session
// pauses at the user's choice; the next plain send is the wake.
// From a pending question the dismiss is silent (A2, 2026-10-03):
// the skipped toolResult lands via the SILENT route, no turn, no
// wake — the next plain send is the wake. MID-TURN (owner,
// 2026-10-04 — the GLM loop): the question can only dangle because a
// turn is STILL RUNNING, so the discard ALSO kills that turn; the
// route parks the dismissal (202) and the settle applies it.
const p = pendingRef.current;
if (p) {
const question = (p.input as { question?: string } | null)?.question ?? "";
const reading = readingThinkingRef.current.trim() || undefined;
clearReadingThinking();
const answer = dismissedAnswer(toolQuestionKind(p.tool), question);
void dismissQuestion(p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer);
void dismissQuestion(
p.toolCallId,
reading ? { ...answer, readingThinking: reading } : answer,
);
if (busyRef.current) abortInFlightTurn();
return;
}
// The practice discard (owner, 2026-10-04): ask-style — the
// silent marker route; the conclude synthesis rides the user's
// next input. The composer returns immediately.
// next input. The composer returns immediately. Mid-turn the
// discard also kills the running turn (same park + settle law).
const problem = activeProblemRef.current;
if (problem) void dismissProblem(problem.toolCallId);
if (problem) {
void dismissProblem(problem.toolCallId);
if (busyRef.current) abortInFlightTurn();
}
},
[abortInFlightTurn, dismissQuestion, dismissProblem],
);
@@ -1444,7 +1454,7 @@ export function Chat({
<MermaidOverlay chart={mermaidFocus} onClose={() => setMermaidFocus(null)} />
)}
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} />
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} busy={busy} />
</div>
</div>
</div>