v1.12.3: stale-stream banner with Retry/Discard

When an assistant message sits status='streaming' with no token activity
for 60+ seconds, the chat shows a banner above the input offering Retry
or Discard. Both clear the stale row via a new backend endpoint
POST /api/chats/:id/discard_stale that updates status='failed' and
publishes chat_status='idle'.

Closes the UX gap that caused the 2026-05-21 debugging spiral —
slow streams and dead streams now look different to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:48:22 +00:00
parent a7104691aa
commit eef4782383
4 changed files with 191 additions and 0 deletions

View File

@@ -18,6 +18,12 @@ const ForkBody = z.object({
name: z.string().min(1).max(200).optional(),
});
const DiscardStaleBody = z.object({
message_id: z.string().uuid(),
});
const STALE_MIN_AGE_SECONDS = 60;
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
@@ -320,6 +326,73 @@ export function registerChatRoutes(
}
);
// v1.12.3: explicit recovery from a stuck-streaming assistant row. The
// frontend gates this behind a 60s no-token-activity timer; the server
// re-checks the age and current status for safety. Non-streaming rows
// return 409 (frontend race; idempotent retry is fine).
app.post<{ Params: { id: string } }>(
'/api/chats/:id/discard_stale',
async (req, reply) => {
const parsed = DiscardStaleBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const rows = await sql<{
id: string;
session_id: string;
chat_id: string;
status: string;
age_seconds: number;
}[]>`
SELECT id, session_id, chat_id, status,
EXTRACT(EPOCH FROM (clock_timestamp() - created_at))::int AS age_seconds
FROM messages
WHERE id = ${parsed.data.message_id} AND chat_id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'message not found in chat' };
}
const msg = rows[0]!;
if (msg.status !== 'streaming') {
reply.code(409);
return { error: 'message is no longer streaming', current_status: msg.status };
}
if (msg.age_seconds < STALE_MIN_AGE_SECONDS) {
reply.code(409);
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
}
const updated = await sql<Message[]>`
UPDATE messages
SET status = 'failed',
content = COALESCE(content, ''),
finished_at = clock_timestamp()
WHERE id = ${msg.id} AND status = 'streaming'
RETURNING id, session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, last_seq, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at, metadata, summary, tail_start_id, compacted_at
`;
if (updated.length === 0) {
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
reply.code(409);
return { error: 'message status changed mid-request' };
}
broker.publishUser('default', {
type: 'chat_status',
chat_id: msg.chat_id,
status: 'idle',
at: new Date().toISOString(),
});
broker.publish(msg.session_id, {
type: 'message_complete',
message_id: msg.id,
chat_id: msg.chat_id,
});
return updated[0];
}
);
app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages',
async (req, reply) => {