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>
204 lines
7.3 KiB
TypeScript
204 lines
7.3 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import { getPendingPermission, respondToPermission, cancelPendingPermission } from '../services/permission-waiter.js';
|
|
import { getTaskCommands } from '../services/agent-commands-cache.js';
|
|
|
|
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),
|
|
agent: z.string().max(100).optional(),
|
|
model: z.string().max(200).optional(),
|
|
mode_id: z.string().max(200).optional(),
|
|
thinking_option_id: z.string().max(200).optional(),
|
|
});
|
|
|
|
const PermissionBody = z.object({
|
|
option_id: z.string().max(200).nullable(),
|
|
updated_input: z.record(z.unknown()).optional(),
|
|
});
|
|
|
|
const ListQuery = z.object({
|
|
state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(),
|
|
project_id: z.string().uuid().optional(),
|
|
});
|
|
|
|
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);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
const { project_id, input, agent, model, mode_id, thinking_option_id } = parsed.data;
|
|
|
|
const [task] = await sql<{ id: string; state: string }[]>`
|
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id)
|
|
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null})
|
|
RETURNING id, state
|
|
`;
|
|
|
|
reply.code(201);
|
|
return { id: task!.id, state: task!.state };
|
|
});
|
|
|
|
// GET /api/tasks — list tasks with optional filters
|
|
app.get('/api/tasks', async (req, _reply) => {
|
|
const parsed = ListQuery.safeParse(req.query);
|
|
if (!parsed.success) {
|
|
return { error: 'invalid query', details: parsed.error.flatten() };
|
|
}
|
|
|
|
const { state, project_id } = parsed.data;
|
|
|
|
// Build query with optional filters
|
|
if (state && project_id) {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE state = ${state} AND project_id = ${project_id}
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
} else if (state) {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE state = ${state}
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
} else if (project_id) {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE project_id = ${project_id}
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
} else {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
}
|
|
});
|
|
|
|
// GET /api/tasks/:id — single task detail
|
|
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
|
|
const rows = await sql`
|
|
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, session_id, cost_tokens, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE id = ${req.params.id}
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'task not found' };
|
|
}
|
|
return rows[0];
|
|
});
|
|
|
|
// POST /api/tasks/:id/cancel — cancel a pending or running task
|
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => {
|
|
const taskId = req.params.id;
|
|
|
|
// Get current task state + session info
|
|
const rows = await sql<{ id: string; state: string; session_id: string | null }[]>`
|
|
SELECT id, state, session_id FROM tasks WHERE id = ${taskId}
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'task not found' };
|
|
}
|
|
|
|
const task = rows[0]!;
|
|
if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') {
|
|
reply.code(409);
|
|
return { error: `cannot cancel task in state '${task.state}'` };
|
|
}
|
|
|
|
cancelPendingPermission(taskId);
|
|
|
|
// 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 }[]>`
|
|
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
|
|
`;
|
|
for (const chat of chats) {
|
|
await inference.cancel(task.session_id, chat.id);
|
|
}
|
|
}
|
|
|
|
await sql`
|
|
UPDATE tasks
|
|
SET state = 'cancelled', ended_at = clock_timestamp()
|
|
WHERE id = ${taskId} AND state IN ('pending', 'running', 'blocked')
|
|
`;
|
|
|
|
return { cancelled: true };
|
|
});
|
|
|
|
// GET /api/tasks/:id/permission — pending permission prompt (if any)
|
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
|
const prompt = getPendingPermission(req.params.id);
|
|
if (!prompt) {
|
|
reply.code(404);
|
|
return { error: 'no pending permission' };
|
|
}
|
|
return prompt;
|
|
});
|
|
|
|
// POST /api/tasks/:id/permission — respond to a pending permission prompt
|
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
|
const parsed = PermissionBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
|
|
if (!ok) {
|
|
reply.code(404);
|
|
return { error: 'no pending permission' };
|
|
}
|
|
return { ok: true };
|
|
});
|
|
|
|
// GET /api/tasks/:id/commands — cached ACP slash commands (if any)
|
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/commands', async (req, reply) => {
|
|
const commands = getTaskCommands(req.params.id);
|
|
if (!commands?.length) {
|
|
reply.code(404);
|
|
return { error: 'no commands cached' };
|
|
}
|
|
return { taskId: req.params.id, commands };
|
|
});
|
|
}
|