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:
@@ -35,6 +35,7 @@ import {
|
||||
} from "../core/settings-resolution.ts";
|
||||
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||
import { applyParkedDismissal } from "./dismissal-park.ts";
|
||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||
@@ -426,6 +427,13 @@ export async function handleChatInput(
|
||||
// against it and the pipeline's batches share it.
|
||||
triggerExtraction(stores, extraction, chatId, effective.graph.extractionChunkTokens);
|
||||
}
|
||||
// PARKED DISMISSAL (owner, 2026-10-04): a dismissal that arrived
|
||||
// mid-turn was parked by the dismiss route (202); now that
|
||||
// persistTurn has landed the turn's messages, apply it against
|
||||
// real truth — BEFORE endTurn, so "turn-settled" guarantees a
|
||||
// subsequent fetch reads the dismissal already in the record
|
||||
// (the same ordering law as persistTurn itself).
|
||||
applyParkedDismissal(stores.chatStore, chatId);
|
||||
// LAST: a poller that sees not-in-flight must read an already-persisted
|
||||
// record — endTurn only after persistTurn has done its job.
|
||||
endTurn(chatId);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* PARKED DISMISSALS (owner, 2026-10-04): ESC-discard while the turn is
|
||||
* still working. Mid-turn the STORED record holds no pending question or
|
||||
* problem (the turn hasn't persisted — its messages land at settle), so a
|
||||
* dismissal arriving now CANNOT validate; the old behavior was a hard 409,
|
||||
* which left the learner trapped with a phantom question until the turn
|
||||
* died (the GLM practice-loop incident: the only escape was killing the
|
||||
* dev server).
|
||||
*
|
||||
* The park (owner ruling: "server-side park"): the dismiss route, finding
|
||||
* a live turn, parks the intent and answers 202; the turn's settle path
|
||||
* (chat-handler onFinish, AFTER persistTurn, BEFORE endTurn — the same
|
||||
* ordering law that makes turn-settled imply durable truth) applies it
|
||||
* against the freshly persisted record. Validation runs at APPLY time
|
||||
* against real state: if the dismissed question/problem really is pending
|
||||
* there, the dismissal lands exactly as the route would have written it;
|
||||
* if it never materialized (the call errored, the model re-posed and
|
||||
* answered), the park is dropped silently — the thing the user dismissed
|
||||
* no longer exists.
|
||||
*
|
||||
* Lives on globalThis, NOT a module-level Map — the same Next.js module
|
||||
* instance discipline as turn-liveness: the dismiss route and the
|
||||
* chat-handler entry must see the SAME registry or the park is written in
|
||||
* one world and read in another. Ephemeral by design; one parked
|
||||
* dismissal per chat (last write wins — there is one dock, one discard).
|
||||
*/
|
||||
|
||||
import { ChatInputError, dismissAppends, emit } from "../core";
|
||||
import type { ChatStore } from "../core/ports.ts";
|
||||
|
||||
/** The parked dismissal body — exactly the parsed input the dismiss route
|
||||
* accepted ({type:"dismiss", toolCallId, answer} or
|
||||
* {type:"dismiss-problem", problemCallId}). */
|
||||
export type ParkedDismissal = unknown;
|
||||
|
||||
interface LhGlobal {
|
||||
__lhParkedDismissals?: Map<string, ParkedDismissal>;
|
||||
}
|
||||
|
||||
function registry(): Map<string, ParkedDismissal> {
|
||||
const g = globalThis as typeof globalThis & LhGlobal;
|
||||
return (g.__lhParkedDismissals ??= new Map<string, ParkedDismissal>());
|
||||
}
|
||||
|
||||
/** Park (or overwrite) the chat's dismissal intent. Called by the dismiss
|
||||
* route when a turn owns the chat. */
|
||||
export function parkDismissal(chatId: string, input: ParkedDismissal): void {
|
||||
registry().set(chatId, input);
|
||||
}
|
||||
|
||||
/** Take the chat's parked dismissal (removes it). Returns null when
|
||||
* nothing is parked. */
|
||||
export function takeParkedDismissal(chatId: string): ParkedDismissal | null {
|
||||
const parked = registry().get(chatId);
|
||||
if (parked === undefined) return null;
|
||||
registry().delete(chatId);
|
||||
return parked;
|
||||
}
|
||||
|
||||
/** Apply the chat's parked dismissal, if any, against the current record.
|
||||
* Runs in the turn's onFinish — between persistTurn and endTurn, so a
|
||||
* client reacting to "turn-settled" reads the dismissal already in the
|
||||
* record. The append path is the route's own (dismissAppends); a
|
||||
* ChatInputError means the dismissed thing never materialized — the park
|
||||
* is dropped, silence is the correct outcome. Emits "record-changed"
|
||||
* exactly when something was written. */
|
||||
export function applyParkedDismissal(chatStore: ChatStore, chatId: string): void {
|
||||
const input = takeParkedDismissal(chatId);
|
||||
if (input === null) return;
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) return;
|
||||
try {
|
||||
const appends = dismissAppends(record, input as Parameters<typeof dismissAppends>[1]);
|
||||
chatStore.saveMessages(chatId, [...record.messages, ...appends]);
|
||||
emit("record-changed", { chatId });
|
||||
} catch (err) {
|
||||
if (err instanceof ChatInputError) return; // never materialized — drop
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export { authStore } from "../store/auth.ts";
|
||||
export * from "./settings-body.ts";
|
||||
export * from "./turn-liveness.ts";
|
||||
export * from "./turn-runs.ts";
|
||||
export * from "./dismissal-park.ts";
|
||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||
export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
|
||||
Reference in New Issue
Block a user