fix: recover session submissions and failed turn settlement

This commit is contained in:
amosblomqvist
2026-10-25 19:45:17 +02:00
parent b277ee3e0f
commit 7d65fa8754
21 changed files with 944 additions and 94 deletions
+15
View File
@@ -511,6 +511,21 @@ export interface DockSelection {
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
/** In-memory submission snapshot: survives the question form's optimistic
* unmount, never written to storage. Indices use the call's stable shuffle. */
export interface QuestionFormDraft {
selection: DockSelection;
thinking: string;
other: string;
text: string;
confidenceIndex: number;
}
export interface QuestionDraft {
form?: QuestionFormDraft;
readingThinking?: string;
}
/**
* Toggling a listed option. Single-select: the option becomes THE answer and
* displaces "Other". Multi-select: the option toggles alongside its siblings
+35 -7
View File
@@ -42,8 +42,8 @@ import {
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 { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts";
import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts";
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
import { composeInstructions } from "../agent/teacher-agent.ts";
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
@@ -79,6 +79,10 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts";
* for write-ahead persistence, turn liveness, and the turn services.
*/
function settlementErrorText(error: unknown): string {
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
}
const jobRunner = createJobRunner({
chatStore: stores.chatStore,
jobStore: stores.jobStore,
@@ -289,7 +293,11 @@ export async function handleChatInput(
// 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);
try { stores.chatStore.saveMessages(chatId, merged); }
catch (err) {
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
return new Response("Your input could not be saved. Please try again.", { status: 500 });
}
emit("record-changed", { chatId });
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
}
@@ -313,14 +321,19 @@ export async function handleChatInput(
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
try {
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
// in the record — flip their jobs now (the wake's steers ride this
// write-ahead; the delivering turn may run for minutes).
} catch (err) {
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
endTurn(chatId);
return new Response("Your input could not be saved. Please try again.", { status: 500 });
}
// Visibility bookkeeping is not the canonical save. Its failure must
// neither misreport a saved input as lost nor prevent the turn running.
try {
for (const appended of stagedAutomatic ? [] : appends) {
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
}
} catch (err) {
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err);
}
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
@@ -496,6 +509,21 @@ export async function handleChatInput(
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
onSettleError: settlementErrorText,
onSettleFailure: (error, reportedErrorText) => {
// POST only acknowledges acceptance; the submitter may attach
// after this turn has already failed and left the registry.
rememberTurnFailure(chatId, reportedErrorText ?? settlementErrorText(error));
endTurn(chatId);
unregisterTurn(chatId);
unregisterLiveAgent(chatId);
// The canonical save may have succeeded before later bookkeeping
// threw. Reconcile against actual durability, never replay input
// merely because usage/extraction/other settlement work failed.
const persisted = stores.chatStore.getChat(chatId)?.messages ?? [];
reconcileUserSteers(chatId, persisted);
settleSteers(chatId, persisted);
},
onFinish: (turnMessages, isAborted) => {
// Wave B: the record write is the agent's own final state —
// the new messages are the slice past the merged base.
+2 -1
View File
@@ -5,6 +5,7 @@ import { titleFromPrompt } from "../core/contracts.ts";
import { graphRevision } from "./graph.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
import { clearTurnFailure } from "./turn-runs.ts";
/**
* Server-side chat entries for the transport layer (app/api/chats*,
@@ -108,7 +109,7 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
// ledgers → jobs → the chat record.
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
stores.chatStore.deleteChat(chatId);
if (stores.chatStore.deleteChat(chatId)) clearTurnFailure(chatId);
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
}
+28 -7
View File
@@ -79,6 +79,10 @@ export interface PiTurnSpec {
* event stream itself is verbatim — provider errors ride their
* message's stopReason/errorMessage through untouched). */
onError?: (error: unknown) => string;
/** Report settlement failure while the views are still open. */
onSettleError?: (error: unknown) => string;
/** Release liveness even if the normal finish seam throws. */
onSettleFailure?: (error: unknown, reportedErrorText?: string) => void;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
* Receives the agent's FULL final state (native messages) — the caller
* persists the slice past its own base count. */
@@ -119,11 +123,12 @@ export interface PiTurnHandle {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
const { agent, onError, onFinish, onSettleError, onSettleFailure, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
let settled = false;
let sawAbort = false;
let reportedErrorText: string | undefined;
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
@@ -221,11 +226,22 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
// messages of its own (the prompt is composed per turn), so dropping
// the role wholesale is exact; revisit if prompt-updates ever land.
const settleWith = () => {
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
try {
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
} catch (err) {
console.error("[turn] settlement failed:", err);
try { onSettleFailure?.(err, reportedErrorText); }
catch (cleanupError) { console.error("[turn] settlement cleanup failed:", cleanupError); }
if (reportedErrorText === undefined) {
reportedErrorText = onSettleError?.(err) ?? "An error occurred.";
send({ type: "error", errorText: reportedErrorText });
}
}
};
const handle = (event: AgentEvent) => {
if (settled) return;
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
// every consumer downstream shares. No uniquifier → verbatim.
const uniqued = uniquifier ? uniquifier.event(event) : event;
@@ -246,8 +262,9 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage);
}
if (event.type === "agent_end") {
closeAll();
// Persist/report before the terminal closes every view to new frames.
settleWith();
closeAll();
}
};
requestCycle?.bindRelease(handle);
@@ -273,11 +290,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
// stream as stopReason "error"): surface, close, persist. If the
// agent already settled (agent_end reached before the throw),
// the seam has run — never settle twice.
if (settled) return;
if (settled) {
console.error("[turn] error after settlement:", err);
return;
}
console.error("[turn] agent loop failed:", err);
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
closeAll();
reportedErrorText = onError?.(err) ?? "An error occurred.";
send({ type: "error", errorText: reportedErrorText });
settleWith();
closeAll();
}
})();
+6 -3
View File
@@ -21,6 +21,7 @@
*/
import { emit } from "../core/events.ts";
import { clearTurnFailure } from "./turn-runs.ts";
interface LhGlobal {
__lhTurnRegistry?: Map<string, number>;
@@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000;
*/
export function beginTurn(chatId: string): boolean {
if (isTurnRunning(chatId)) return false;
clearTurnFailure(chatId);
inFlight.set(chatId, Date.now());
emit("turn-started", { chatId });
return true;
}
/**
* Mark the chat's turn as finished (runs in onFinish, aborted or not —
* AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads
* durable truth). Emits only when an entry was actually present: a
* Mark the chat's turn as finished after normal settlement, or after the
* failure notice has been retained by settlement cleanup. A subsequent
* fetch reads durable truth, never a promise that an unsaved reply landed.
* Emits only when an entry was actually present: a
* defensive no-op endTurn must not fire a spurious settle.
*/
export function endTurn(chatId: string): void {
+30 -5
View File
@@ -13,7 +13,9 @@
* Registration lifecycle: chat-handler registers the handle the moment
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
* — after persist, usage, extraction, and endTurn — so anything that sees
* "no running turn" also sees an already-persisted record. The per-chat
* "no running turn" sees durable record truth. Failed settlement also
* retains its small notice before unregistering, so late attachments do
* not mistake an unsaved reply for success. The per-chat
* 409 guard means a chatId can hold at most one handle, and beginTurn's
* 30-minute TTL bounds the pathological never-settling case the same way
* it does for the liveness map: if onFinish never fires, the stale entry
@@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts";
interface LhTurnRunsGlobal {
__lhTurnRuns?: Map<string, PiTurnHandle>;
__lhTurnFailures?: Map<string, string>;
}
function runs(): Map<string, PiTurnHandle> {
@@ -37,6 +40,25 @@ function runs(): Map<string, PiTurnHandle> {
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
}
/** One small, transient notice per failed session. Never stores messages.
* Cleared on the next accepted begin, session deletion, or process exit. */
function failures(): Map<string, string> {
const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
return (g.__lhTurnFailures ??= new Map<string, string>());
}
export function rememberTurnFailure(chatId: string, errorText: string): void {
failures().set(chatId, errorText);
}
export function turnFailure(chatId: string): string | undefined {
return failures().get(chatId);
}
export function clearTurnFailure(chatId: string): void {
failures().delete(chatId);
}
/** Register the chat's running turn (chat-handler, right after the turn
* starts). One per chat by construction — the 409 guard. */
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
@@ -67,11 +89,14 @@ export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
* turn (raced settle, or the client checked turnInFlight a beat too
* late): a same-dialect SSE response whose single chunk tells the client
* "nothing to attach to — the record is current, refetch it". Chosen as
* an EVENT (not a status code) so the reattach path consumes ONE wire
* "nothing to attach to — refetch durable truth". A retained settlement
* failure is delivered first, including when no view attached in time.
* Chosen as an EVENT (not a status code), consuming ONE wire
* protocol end to end. */
export function settledTurnResponse(): Response {
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
export function settledTurnResponse(chatId?: string): Response {
const errorText = chatId ? turnFailure(chatId) : undefined;
const notice = errorText === undefined ? "" : `data: ${JSON.stringify({ type: "error", errorText })}\n\n`;
return new Response(`${notice}data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n`, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
+13 -4
View File
@@ -1,5 +1,5 @@
/**
* The chat-turn services — one named, error-contained seam per turn
* The chat-turn services — one named seam per turn
* responsibility, extracted from the chat-handler so it composes instead of
* implementing. Each takes its dependencies explicitly (storage ports, the
* job runner) — no module here imports an adapter.
@@ -26,6 +26,14 @@ import type { ChatStore, GraphStore } from "../core/ports.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
/** Distinguish a failed canonical save from later bookkeeping failures. */
export class TurnPersistenceError extends Error {
constructor(cause: unknown) {
super("The reply could not be saved.", { cause });
this.name = "TurnPersistenceError";
}
}
/**
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
* turn's own messages — the agent loop's native history, provider/model/
@@ -45,7 +53,8 @@ import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/setti
* - Plan blocks: one frozen snapshot per successful plan_init call,
* idempotent (persist replays are safe).
*
* Returns the stamped new messages (usage provenance reads them).
* Returns the stamped new messages (usage provenance reads them). Throws
* TurnPersistenceError on failure so the runner can report it before DONE.
*/
export function persistTurn(
chatStore: ChatStore,
@@ -56,7 +65,7 @@ export function persistTurn(
): LhMessage[] {
try {
const record = chatStore.getChat(chatId);
if (!record) return [];
if (!record) throw new Error(`Chat not found: ${chatId}`);
const stamped: LhMessage[] = [];
for (const m of turnMessages.slice(baseCount)) {
const message = m as LhMessage;
@@ -79,7 +88,7 @@ export function persistTurn(
return stamped;
} catch (err) {
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
return [];
throw new TurnPersistenceError(err);
}
}