Phase 4 of v2.0. BooCoder can now queue tasks and dispatch them through the inference loop autonomously. Dispatcher (services/dispatcher.ts): in-process setInterval(5s) polls tasks WHERE state='pending', picks one at a time, creates an isolated session+chat, enqueues inference with the task's input as the user message, polls for completion, marks state completed/failed with output_summary. Single-task-at-a-time for v2.0.0; parallel dispatch is a Phase 5+ concern. Respects onClose hook for graceful shutdown. Task routes (routes/tasks.ts): POST /api/tasks (create), GET /api/tasks (list with state/project filters), GET /api/tasks/:id (detail), POST /api/tasks/:id/cancel (marks cancelled, aborts if running). Agent probe (services/agent-probe.ts): on startup, probes PATH for opencode/goose/claude/pi via which + --version. UPSERTs into available_agents table. Finds nothing inside the container (expected — Phase 5 addresses host-agent access via ACP/PTY). Schema: ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id (links task to its auto-created inference session for isolation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
|
|
interface InferenceApi {
|
|
cancel: (sessionId: string, chatId: string) => Promise<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(),
|
|
});
|
|
|
|
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): 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 } = parsed.data;
|
|
|
|
const [task] = await sql<{ id: string; state: string }[]>`
|
|
INSERT INTO tasks (project_id, input, agent, model)
|
|
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? 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, worktree_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') {
|
|
reply.code(409);
|
|
return { error: `cannot cancel task in state '${task.state}'` };
|
|
}
|
|
|
|
// If running, try to cancel inference
|
|
if (task.state === 'running' && 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')
|
|
`;
|
|
|
|
return { cancelled: true };
|
|
});
|
|
}
|