feat: normalized external-agent status (#10 scoped) (v2.7.6)
Scoped half of boocode_code_review_v2 §1 #10 — publish the agent status BooCoder already observes (the config-injection notify-hook is the documented follow-on, clean-room from superset ELv2). - agent_status_updated WS frame (working|blocked|idle|error), server+web parity. - Published from the dispatcher's turn boundaries (warm-acp/opencode/sdk/pty: working at start, idle/error at end) + the permission flow (blocked/working). Best-effort, never breaks a turn. - Clean-room normalizeAgentEvent helper (superset's vendor-event -> Start/blocked /Stop collapse, event names as facts) + 25 tests — reused by the follow-on. - AgentComposerBar status dot (distinct from the WS-liveness dot), tracked per (chat,agent) by a useAgentStatus map in CoderPane. Built by 2 parallel agents vs a pinned frame contract. Server 545 + coder 294 tests passing (25 new); web tsc + builds clean; ws-frames parity green. Clears the actionable review backlog (#1/#3/#4/#6-#12). Builds on v2.7.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,8 @@ import { ClaudeSdkBackend } from './backends/claude-sdk.js';
|
||||
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
|
||||
import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
|
||||
import type { AgentBackend, AgentEvent } from './agent-backend.js';
|
||||
import { publishAgentStatus } from './agent-status-publish.js';
|
||||
import type { AgentStatus } from './normalize-agent-status.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
@@ -66,6 +68,21 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
return task.session_id ?? `task:${task.id}`;
|
||||
}
|
||||
|
||||
// agent-status-normalize (#10): publish a normalized per-(chat,agent) status on
|
||||
// the session channel. Every external-agent path (warm-acp / opencode / claude-sdk /
|
||||
// pty one-shot) reports `working` at turn start, `idle` on clean completion, and
|
||||
// `error` on the failure path through this single helper so the four paths stay
|
||||
// DRY and consistent. Best-effort — publishAgentStatus never throws.
|
||||
function emitAgentStatus(
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
agent: string,
|
||||
status: AgentStatus,
|
||||
reason: string,
|
||||
): void {
|
||||
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
||||
// concurrently) so we never double-select a task. It does NOT serialize task
|
||||
@@ -298,6 +315,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
// Create an abort controller for this task
|
||||
const ac = new AbortController();
|
||||
|
||||
// #10: hoisted above the try so the catch block can report `error` status with
|
||||
// the (chat, agent) key. Empty until resolved below; guarded before use.
|
||||
let sessionId = '';
|
||||
let chatId = '';
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
await sql`
|
||||
@@ -306,9 +328,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
@@ -384,6 +403,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
role: 'assistant',
|
||||
} as WsFrame);
|
||||
|
||||
// #10: external-agent turn begins.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||
|
||||
const manifestCommands = getManifestCommands(agent);
|
||||
if (manifestCommands.length > 0) {
|
||||
setTaskCommands(taskId, manifestCommands);
|
||||
@@ -558,6 +580,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||
// #10: external-agent turn completed cleanly.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||
clearTaskCommands(taskId);
|
||||
|
||||
} catch (err) {
|
||||
@@ -570,6 +594,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
|
||||
// #10: external-agent turn failed/crashed. chatId may be unbound if the throw
|
||||
// preceded its assignment — guard so the status publish never masks the real
|
||||
// error.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'failed');
|
||||
|
||||
// Best-effort cleanup
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
clearTaskCommands(taskId);
|
||||
@@ -624,6 +653,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
// #10: hoisted so the catch can report `error` with the (chat, agent) key.
|
||||
let sessionId = '';
|
||||
let chatId = '';
|
||||
|
||||
try {
|
||||
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
||||
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
|
||||
@@ -640,8 +673,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
// it directly. Session-less creators (arena, MCP, new_task, generic
|
||||
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
||||
// ensureSession never receives a degenerate (null, agent) key.
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
if (task.chat_id && task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
chatId = task.chat_id;
|
||||
@@ -714,6 +745,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
role: 'assistant',
|
||||
} as WsFrame);
|
||||
|
||||
// #10: opencode-server turn begins.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||
|
||||
const manifestCommands = getManifestCommands(agent);
|
||||
if (manifestCommands.length > 0) {
|
||||
setTaskCommands(taskId, manifestCommands);
|
||||
@@ -873,6 +907,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
|
||||
// #10: clean completion → idle; backend-reported failure → error.
|
||||
emitAgentStatus(
|
||||
sessionId,
|
||||
chatId,
|
||||
agent,
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -882,6 +924,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
// #10: turn crashed.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -982,6 +1026,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
role: 'assistant',
|
||||
} as WsFrame);
|
||||
|
||||
// #10: warm-ACP turn begins.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||
|
||||
const manifestCommands = getManifestCommands(agent);
|
||||
if (manifestCommands.length > 0) {
|
||||
setTaskCommands(taskId, manifestCommands);
|
||||
@@ -1123,6 +1170,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
|
||||
// #10: clean completion → idle; backend-reported failure → error.
|
||||
emitAgentStatus(
|
||||
sessionId,
|
||||
chatId,
|
||||
agent,
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1132,6 +1187,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1224,6 +1281,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
role: 'assistant',
|
||||
} as WsFrame);
|
||||
|
||||
// #10: claude-SDK turn begins.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||
|
||||
const manifestCommands = getManifestCommands(agent);
|
||||
if (manifestCommands.length > 0) {
|
||||
setTaskCommands(taskId, manifestCommands);
|
||||
@@ -1364,6 +1424,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (claude SDK)');
|
||||
// #10: clean completion → idle; backend-reported failure → error.
|
||||
emitAgentStatus(
|
||||
sessionId,
|
||||
chatId,
|
||||
agent,
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1373,6 +1441,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user