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:
138
apps/coder/src/routes/__tests__/tasks-cancel.test.ts
Normal file
138
apps/coder/src/routes/__tests__/tasks-cancel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user