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>
This commit is contained in:
2026-06-03 02:23:11 +00:00
parent 9a139633b8
commit f32fd928b3
48 changed files with 1014 additions and 2254 deletions

View File

@@ -0,0 +1,138 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import postgres from 'postgres';
import { registerTaskRoutes } from '../tasks.js';
/**
* F1 — POST /api/tasks/:id/cancel route wiring.
*
* The route's job: reach the in-flight external run via `cancelExternal(taskId)`
* (the new abort hook), keep cancelling native inference for open chats unchanged,
* and land the task row in 'cancelled'. The streaming assistant message is
* finalized by the dispatcher's run-function, not here — that path is covered by
* finalize-message.test.ts. This suite pins the route's behavior against a real DB.
*/
describe.runIf(!!process.env.DATABASE_URL)('POST /api/tasks/:id/cancel (route, F1)', () => {
let sql: ReturnType<typeof postgres>;
let app: FastifyInstance;
let projectId: string;
let sessionId: string;
let chatId: string;
const externalCancelCalls: string[] = [];
const inferenceCancelCalls: Array<[string, string]> = [];
let externalReturns = true;
beforeAll(async () => {
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql');
const coderSchema = resolve(__dirname, '../../schema.sql');
await sql.unsafe(readFileSync(serverSchema, 'utf8'));
await sql.unsafe(readFileSync(coderSchema, 'utf8'));
const [p] = await sql<{ id: string }[]>`
INSERT INTO projects (name, path, status) VALUES ('f1-cancel-route', '/tmp/f1-cancel-route', 'open') RETURNING id
`;
projectId = p!.id;
const [s] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status) VALUES (${projectId}, 'f1', 'm', 'open') RETURNING id
`;
sessionId = s!.id;
const [c] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id
`;
chatId = c!.id;
app = Fastify();
registerTaskRoutes(
app,
sql,
{
cancel: async (sid: string, cid: string) => {
inferenceCancelCalls.push([sid, cid]);
return false;
},
},
(taskId: string) => {
externalCancelCalls.push(taskId);
return externalReturns;
},
);
await app.ready();
});
afterAll(async () => {
if (app) await app.close();
if (!sql) return;
await sql`DELETE FROM messages WHERE session_id = ${sessionId}`.catch(() => {});
await sql`DELETE FROM tasks WHERE project_id = ${projectId}`.catch(() => {});
await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {});
await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {});
await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {});
await sql.end({ timeout: 5 });
});
async function insertTask(agent: string | null, state: string): Promise<string> {
const [t] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, session_id, state, started_at)
VALUES (${projectId}, 'do a thing', ${agent}, ${sessionId}, ${state}, clock_timestamp())
RETURNING id
`;
return t!.id;
}
it('reaches cancelExternal and lands the task cancelled for a running external task', async () => {
externalReturns = true;
externalCancelCalls.length = 0;
const taskId = await insertTask('opencode', 'running');
const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ cancelled: true });
expect(externalCancelCalls).toContain(taskId);
const [row] = await sql<{ state: string; ended_at: Date | null }[]>`
SELECT state, ended_at FROM tasks WHERE id = ${taskId}
`;
expect(row!.state).toBe('cancelled');
expect(row!.ended_at).not.toBeNull();
});
it('still cancels a native boocode task (cancelExternal returns false → inference.cancel path unchanged)', async () => {
externalReturns = false; // native task: no controller registered
externalCancelCalls.length = 0;
inferenceCancelCalls.length = 0;
const taskId = await insertTask(null, 'running');
const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
expect(res.statusCode).toBe(200);
// The route calls cancelExternal unconditionally (cheap, returns false here)...
expect(externalCancelCalls).toContain(taskId);
// ...and the native inference.cancel path still fires for the open chat.
expect(inferenceCancelCalls).toContainEqual([sessionId, chatId]);
const [row] = await sql<{ state: string }[]>`SELECT state FROM tasks WHERE id = ${taskId}`;
expect(row!.state).toBe('cancelled');
});
it('rejects cancelling an already-terminal task with 409 and never touches the abort hook', async () => {
externalCancelCalls.length = 0;
const taskId = await insertTask('opencode', 'completed');
const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
expect(res.statusCode).toBe(409);
expect(externalCancelCalls).not.toContain(taskId);
});
it('returns 404 for an unknown task', async () => {
const res = await app.inject({
method: 'POST',
url: `/api/tasks/00000000-0000-0000-0000-000000000000/cancel`,
});
expect(res.statusCode).toBe(404);
});
});