From 0acf7daf320dd7b0a4e1dbbe506af7a4ad0cdeac Mon Sep 17 00:00:00 2001 From: HonzysClawdbot Date: Mon, 16 Mar 2026 05:34:28 +0100 Subject: [PATCH] feat: task assignment session targeting (#399) * feat: add task session targeting - dispatch tasks to specific agent sessions (#395) When assigning a task to an agent, users can now optionally select an existing session to dispatch the task to instead of creating a new one. Changes: - task-dispatch.ts: When target_session is set in task metadata, use gateway chat.send to dispatch to that specific session instead of creating a new one via call agent - task-board-panel.tsx: Add session selector dropdown in both Create and Edit task modals that appears when an agent is selected and has active sessions - store/index.ts: Add agent and channel fields to Session interface Closes #395 * fix(gateway): ensure gateways table exists before health probe The gateways table is created lazily by the gateways API (ensureTable). The health route was querying it directly without CREATE IF NOT EXISTS, causing SqliteError: no such table: gateways in fresh databases (E2E tests, Docker first-boot). Add ensureGatewaysTable() inline to mirror the pattern in route.ts. --- src/app/api/gateways/health/route.ts | 21 +++++ src/components/panels/task-board-panel.tsx | 85 +++++++++++++++++++- src/lib/task-dispatch.ts | 92 +++++++++++++++------- src/store/index.ts | 2 + 4 files changed, 170 insertions(+), 30 deletions(-) diff --git a/src/app/api/gateways/health/route.ts b/src/app/api/gateways/health/route.ts index df2cad0..3f58d33 100644 --- a/src/app/api/gateways/health/route.ts +++ b/src/app/api/gateways/health/route.ts @@ -2,6 +2,26 @@ import { NextRequest, NextResponse } from "next/server" import { requireRole } from "@/lib/auth" import { getDatabase } from "@/lib/db" +function ensureGatewaysTable(db: ReturnType) { + db.exec(` + CREATE TABLE IF NOT EXISTS gateways ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL DEFAULT '127.0.0.1', + port INTEGER NOT NULL DEFAULT 18789, + token TEXT NOT NULL DEFAULT '', + is_primary INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'unknown', + last_seen INTEGER, + latency INTEGER, + sessions_count INTEGER NOT NULL DEFAULT 0, + agents_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `) +} + interface GatewayEntry { id: number name: string @@ -144,6 +164,7 @@ export async function POST(request: NextRequest) { if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const db = getDatabase() + ensureGatewaysTable(db) const gateways = db.prepare("SELECT * FROM gateways ORDER BY is_primary DESC, name ASC").all() as GatewayEntry[] // Build set of user-configured gateway hosts so the SSRF filter allows them diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index ed601ae..12b983d 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -95,6 +95,29 @@ const STATUS_COLUMN_KEYS = [ { key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' }, ] +/** Fetch active gateway sessions for a given agent name. */ +function useAgentSessions(agentName: string | undefined) { + const [sessions, setSessions] = useState>([]) + useEffect(() => { + if (!agentName) { setSessions([]); return } + let cancelled = false + fetch('/api/sessions?include_local=1') + .then(r => r.json()) + .then(data => { + if (cancelled) return + const all = (data.sessions || []) as Array<{ key: string; id: string; agent?: string; channel?: string; label?: string; active?: boolean }> + const filtered = all.filter(s => + s.agent?.toLowerCase() === agentName.toLowerCase() || + s.key?.toLowerCase().includes(agentName.toLowerCase()) + ) + setSessions(filtered.map(s => ({ key: s.key, id: s.id, channel: s.channel, label: s.label }))) + }) + .catch(() => { if (!cancelled) setSessions([]) }) + return () => { cancelled = true } + }, [agentName]) + return sessions +} + const priorityColors: Record = { low: 'border-l-green-500', medium: 'border-l-yellow-500', @@ -1818,8 +1841,10 @@ function CreateTaskModal({ project_id: projects[0]?.id ? String(projects[0].id) : '', assigned_to: '', tags: '', + target_session: '', }) const t = useTranslations('taskBoard') + const agentSessions = useAgentSessions(formData.assigned_to || undefined) const [isRecurring, setIsRecurring] = useState(false) const [scheduleInput, setScheduleInput] = useState('') const [parsedSchedule, setParsedSchedule] = useState<{ cronExpr: string; humanReadable: string } | null>(null) @@ -1861,6 +1886,9 @@ function CreateTaskModal({ parent_task_id: null, } } + if (formData.target_session) { + metadata.target_session = formData.target_session + } try { const response = await fetch('/api/tasks', { @@ -1960,7 +1988,7 @@ function CreateTaskModal({ + {formData.assigned_to && agentSessions.length > 0 && ( +
+ + +

Send task to an existing agent session instead of creating a new one.

+
+ )} +
{ e.preventDefault() @@ -2070,6 +2120,14 @@ function EditTaskModal({ if (!formData.title.trim()) return try { + const existingMeta = task.metadata || {} + const updatedMeta = { ...existingMeta } + if (formData.target_session) { + updatedMeta.target_session = formData.target_session + } else { + delete updatedMeta.target_session + } + const response = await fetch(`/api/tasks/${task.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -2077,7 +2135,8 @@ function EditTaskModal({ ...formData, project_id: formData.project_id ? Number(formData.project_id) : undefined, tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [], - assigned_to: formData.assigned_to || undefined + assigned_to: formData.assigned_to || undefined, + metadata: updatedMeta, }) }) @@ -2182,7 +2241,7 @@ function EditTaskModal({
+ {formData.assigned_to && agentSessions.length > 0 && ( +
+ + +

Send task to an existing agent session instead of creating a new one.

+
+ )} +
= { - message: prompt, - agentId: gatewayAgentId, - idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, - deliver: false, - } - // Route to appropriate model tier based on task complexity. - // null = no override, agent uses its own configured default model. - if (dispatchModel) invokeParams.model = dispatchModel + // Check if task has a target session specified in metadata + const taskMeta = (() => { + try { + const row = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(task.id) as { metadata: string } | undefined + return row?.metadata ? JSON.parse(row.metadata) : {} + } catch { return {} } + })() + const targetSession: string | null = typeof taskMeta?.target_session === 'string' && taskMeta.target_session + ? taskMeta.target_session + : null - // Use --expect-final to block until the agent completes and returns the full - // response payload (result.payloads[0].text). The two-step agent → agent.wait - // pattern only returns lifecycle metadata and never includes the agent's text. - const finalResult = await runOpenClaw( - ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], - { timeoutMs: 125_000 } - ) - const finalPayload = parseGatewayJson(finalResult.stdout) - ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) + let agentResponse: AgentResponseParsed - const agentResponse = parseAgentResponse( - finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout - ) - if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { - agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId - } + if (targetSession) { + // Dispatch to a specific existing session via chat.send + logger.info({ taskId: task.id, targetSession, agent: task.agent_name }, 'Dispatching task to targeted session') + const sendResult = await callOpenClawGateway( + 'chat.send', + { + sessionKey: targetSession, + message: prompt, + idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, + deliver: false, + }, + 125_000, + ) + const status = String(sendResult?.status || '').toLowerCase() + if (status !== 'started' && status !== 'ok' && status !== 'in_flight') { + throw new Error(`chat.send to session ${targetSession} returned status: ${status}`) + } + // chat.send is fire-and-forget; we record the session but won't get inline response text + agentResponse = { + text: `Task dispatched to existing session ${targetSession}. The agent will process it within that session context.`, + sessionId: sendResult?.runId || targetSession, + } + } else { + // Step 1: Invoke via gateway (new session) + const gatewayAgentId = resolveGatewayAgentId(task) + const dispatchModel = classifyTaskModel(task) + const invokeParams: Record = { + message: prompt, + agentId: gatewayAgentId, + idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, + deliver: false, + } + // Route to appropriate model tier based on task complexity. + // null = no override, agent uses its own configured default model. + if (dispatchModel) invokeParams.model = dispatchModel + + // Use --expect-final to block until the agent completes and returns the full + // response payload (result.payloads[0].text). The two-step agent → agent.wait + // pattern only returns lifecycle metadata and never includes the agent's text. + const finalResult = await runOpenClaw( + ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], + { timeoutMs: 125_000 } + ) + const finalPayload = parseGatewayJson(finalResult.stdout) + ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) + + agentResponse = parseAgentResponse( + finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout + ) + if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { + agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId + } + } // end else (new session dispatch) if (!agentResponse.text) { throw new Error('Agent returned empty response') diff --git a/src/store/index.ts b/src/store/index.ts index 658343f..c2ea23e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,6 +12,8 @@ type DashboardLayoutUpdater = string[] | null | ((current: string[] | null) => s export interface Session { id: string key: string + agent?: string + channel?: string kind: string age: string model: string