Files
boocode/apps/coder/src/services/finalize-message.ts
indifferentketchup f32fd928b3 feat: post-review backlog hardening (cancel/parser/stall/history/9502)
Five independent items from the post-review backlog. F1: Stop on an external
agent task now aborts the running child via a per-task AbortController registry
reachable from the cancel route, and finalizes the assistant message as
cancelled (fixing two latent bugs — catch blocks left the message streaming,
and warm success-paths wrote complete on an aborted turn); warm pools/worktrees
are preserved and the native path is unchanged. F2/F3: prune the tool-call
parser to its two load-bearing exports (unexport eight zero-caller symbols, add
a gate test for the <invoke>-as-text fallback) and route placeholder-rejection
logging through pino. F6: a 90s per-chunk stall-timeout wraps native inference's
fullStream via AbortSignal.any so a hung stream finalizes the message instead of
hanging — no retry (a pure classifyStreamError helper is added). F7: a read-only
view_session_history MCP tool (newest-N, chronological). F9: retire the unused
apps/coder/web :9502 fallback SPA, keeping every API/WS/health/MCP route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:23:11 +00:00

77 lines
2.7 KiB
TypeScript

import type { Sql } from '../db.js';
import type { WsFrame } from '@boocode/contracts/ws-frames';
export type TerminalMessageStatus = 'cancelled' | 'failed';
/**
* F1 (D-7) — decide the terminal status a Stop'd / errored external turn lands in.
*
* A user Stop (the per-task AbortController fired) or a thrown `AbortError` is a
* deliberate, non-error outcome → `'cancelled'`. A genuine thrown error → `'failed'`.
* Keeping the two distinct keeps the human-inbox / failure surfaces honest.
*
* Pure (no DB / IO) so the mapping is unit-testable in isolation.
*/
export function classifyTerminalStatus(opts: { aborted: boolean; error?: unknown }): TerminalMessageStatus {
if (opts.aborted) return 'cancelled';
if (opts.error instanceof Error && opts.error.name === 'AbortError') return 'cancelled';
return 'failed';
}
/**
* F1 (OCE-001 / OCE-002) — finalize a streaming assistant message into a terminal
* state and publish the matching `message_complete` frame.
*
* Idempotent via `WHERE status = 'streaming'`: a second call (a double-Stop, or an
* abort short-circuit followed by the catch block) updates zero rows and does NOT
* re-publish, so the frontend reducer settles the message exactly once. It also
* never clobbers a row that already finished cleanly (`complete`) — the abort that
* raced a clean finish is a no-op.
*
* Returns `true` iff this call performed the finalization (the row was still
* streaming); `false` if it was already terminal or the id is absent (the throw
* preceded the row's creation).
*/
export async function finalizeStreamingMessage(
sql: Sql,
publishFrame: (sessionId: string, frame: WsFrame) => void,
opts: {
sessionId: string;
chatId: string;
assistantId: string;
status: TerminalMessageStatus;
model: string | null;
/** Partial accumulated text to persist; omit to leave the row's content untouched. */
content?: string;
},
): Promise<boolean> {
const { sessionId, chatId, assistantId, status, model, content } = opts;
if (!assistantId) return false;
const rows =
content !== undefined
? await sql<{ id: string }[]>`
UPDATE messages
SET content = ${content}, status = ${status}, finished_at = clock_timestamp()
WHERE id = ${assistantId} AND status = 'streaming'
RETURNING id
`
: await sql<{ id: string }[]>`
UPDATE messages
SET status = ${status}, finished_at = clock_timestamp()
WHERE id = ${assistantId} AND status = 'streaming'
RETURNING id
`;
if (rows.length === 0) return false;
publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
model,
status,
} as WsFrame);
return true;
}