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);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,12 @@ interface InferenceApi {
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// F1: the dispatcher's reach into an in-flight external-agent run. Narrow by
|
||||
// design (not the whole dispatcher) — the route only needs to fire the abort.
|
||||
// Returns true when a controller was registered for the task (an external run was
|
||||
// in flight), false otherwise (native boocode task, or already finished).
|
||||
export type ExternalCancelFn = (taskId: string) => boolean;
|
||||
|
||||
const CreateBody = z.object({
|
||||
project_id: z.string().uuid(),
|
||||
input: z.string().min(1).max(64_000),
|
||||
@@ -27,7 +33,12 @@ const ListQuery = z.object({
|
||||
project_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
|
||||
export function registerTaskRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
inference: InferenceApi,
|
||||
cancelExternal: ExternalCancelFn,
|
||||
): void {
|
||||
// POST /api/tasks — create a new task
|
||||
app.post('/api/tasks', async (req, reply) => {
|
||||
const parsed = CreateBody.safeParse(req.body);
|
||||
@@ -127,7 +138,14 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
||||
|
||||
cancelPendingPermission(taskId);
|
||||
|
||||
// If running, try to cancel inference
|
||||
// F1: abort the in-flight external-agent run (opencode / goose / qwen / claude).
|
||||
// Idempotent — a double-Stop re-aborts harmlessly; a native boocode task is not
|
||||
// registered, so this returns false and the inference.cancel path below handles
|
||||
// it unchanged. The dispatcher's run-function finalizes the streaming assistant
|
||||
// message as 'cancelled' once the backend honors the signal.
|
||||
cancelExternal(taskId);
|
||||
|
||||
// If running, try to cancel inference (native boocode path — unchanged).
|
||||
if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
|
||||
// Find active chat in the task's session
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
|
||||
Reference in New Issue
Block a user