parallel question gate: an answer that leaves a sibling dangling defers its wake (deepseek batch incident fix, owner 2026-10-03)

The incident: deepseek batched quiz + ask_user_question in one assistant
message; the quiz executed first (sequential) and terminated the turn; the
answer to the quiz IMMEDIATELY woke a reaction turn (fork-1 design assumed
one dangling question max), the model complained about the unanswered ask,
and the ask became permanently unanswerable — its toolResult could only
append AFTER the intervening assistant message, a shape DeepSeek rejects.

The gate: for answer inputs, if a question call still dangles after the
append, persist write-ahead + emit record-changed + return the settled
terminal — no turn. The dock advances to the sibling; the LAST answer fires
the one reaction turn with every result adjacent to its calling message.
Nothing else generates while a question dangles (job wakes wait, continuation
folds refuse, the composer gates), so adjacency holds by construction.

Also repaired the stranded session data (ask call stripped from the batch;
backup at /tmp/chat_murkbeq2_backup.json).
This commit is contained in:
amosblomqvist
2026-10-03 02:10:16 +03:00
parent 673a430d6e
commit 7a5a55e94a
2 changed files with 97 additions and 3 deletions
+20 -1
View File
@@ -28,7 +28,9 @@ import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope,
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
import { persistTurn, triggerExtraction } from "./turn-service.ts";
import { hasProbeDone } from "../core/native-messages.ts";
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
import { emit } from "../core/events.ts";
import { settledTurnResponse } from "./turn-runs.ts";
import { asAgentMessages } from "../core/native-messages.ts";
import {
ChatInputError,
@@ -228,6 +230,23 @@ export async function handleChatInput(
throw err;
}
// THE PARALLEL-QUESTION GATE (owner, 2026-10-03 — the deepseek parallel
// batch incident): an answer that leaves a SIBLING question dangling
// defers its wake. The dock advances to the next question (the client's
// optimistic result + record-changed carry the view); the LAST answer
// fires the one reaction turn with every result ADJACENT to its calling
// assistant message — the provider shape law (a tool result appended
// after an intervening assistant message is rejected, which stranded
// the sibling question permanently pre-fix). Nothing else can generate
// while a question dangles (job wakes wait, continuation folds refuse,
// the composer gates), so adjacency holds by construction.
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
const merged = [...record.messages, ...appends];
stores.chatStore.saveMessages(chatId, merged);
emit("record-changed", { chatId });
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
}
// One turn per chat, ever. A second request while a turn runs means a
// stale/remounted client raced a live one — letting it through would
// double-run the teacher and clobber the first turn's persist.