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>
77 lines
2.7 KiB
TypeScript
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;
|
|
}
|