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.
This commit is contained in:
HonzysClawdbot 2026-03-16 05:34:28 +01:00 committed by GitHub
parent e7aa7e6a91
commit 0acf7daf32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 170 additions and 30 deletions

View File

@ -2,6 +2,26 @@ import { NextRequest, NextResponse } from "next/server"
import { requireRole } from "@/lib/auth" import { requireRole } from "@/lib/auth"
import { getDatabase } from "@/lib/db" import { getDatabase } from "@/lib/db"
function ensureGatewaysTable(db: ReturnType<typeof getDatabase>) {
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 { interface GatewayEntry {
id: number id: number
name: string 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 }) if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const db = getDatabase() const db = getDatabase()
ensureGatewaysTable(db)
const gateways = db.prepare("SELECT * FROM gateways ORDER BY is_primary DESC, name ASC").all() as GatewayEntry[] 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 // Build set of user-configured gateway hosts so the SSRF filter allows them

View File

@ -95,6 +95,29 @@ const STATUS_COLUMN_KEYS = [
{ key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' }, { 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<Array<{ key: string; id: string; channel?: string; label?: string }>>([])
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<string, string> = { const priorityColors: Record<string, string> = {
low: 'border-l-green-500', low: 'border-l-green-500',
medium: 'border-l-yellow-500', medium: 'border-l-yellow-500',
@ -1818,8 +1841,10 @@ function CreateTaskModal({
project_id: projects[0]?.id ? String(projects[0].id) : '', project_id: projects[0]?.id ? String(projects[0].id) : '',
assigned_to: '', assigned_to: '',
tags: '', tags: '',
target_session: '',
}) })
const t = useTranslations('taskBoard') const t = useTranslations('taskBoard')
const agentSessions = useAgentSessions(formData.assigned_to || undefined)
const [isRecurring, setIsRecurring] = useState(false) const [isRecurring, setIsRecurring] = useState(false)
const [scheduleInput, setScheduleInput] = useState('') const [scheduleInput, setScheduleInput] = useState('')
const [parsedSchedule, setParsedSchedule] = useState<{ cronExpr: string; humanReadable: string } | null>(null) const [parsedSchedule, setParsedSchedule] = useState<{ cronExpr: string; humanReadable: string } | null>(null)
@ -1861,6 +1886,9 @@ function CreateTaskModal({
parent_task_id: null, parent_task_id: null,
} }
} }
if (formData.target_session) {
metadata.target_session = formData.target_session
}
try { try {
const response = await fetch('/api/tasks', { const response = await fetch('/api/tasks', {
@ -1960,7 +1988,7 @@ function CreateTaskModal({
<select <select
id="create-assignee" id="create-assignee"
value={formData.assigned_to} value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value, target_session: '' }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
> >
<option value="">{t('unassigned')}</option> <option value="">{t('unassigned')}</option>
@ -1972,6 +2000,26 @@ function CreateTaskModal({
</select> </select>
</div> </div>
{formData.assigned_to && agentSessions.length > 0 && (
<div>
<label htmlFor="create-target-session" className="block text-sm text-muted-foreground mb-1">Target Session</label>
<select
id="create-target-session"
value={formData.target_session}
onChange={(e) => setFormData(prev => ({ ...prev, target_session: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">New session (default)</option>
{agentSessions.map(s => (
<option key={s.key} value={s.key}>
{s.label || s.channel || s.key}
</option>
))}
</select>
<p className="text-[11px] text-muted-foreground mt-1">Send task to an existing agent session instead of creating a new one.</p>
</div>
)}
<div> <div>
<label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label> <label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input <input
@ -2061,8 +2109,10 @@ function EditTaskModal({
project_id: task.project_id ? String(task.project_id) : (projects[0]?.id ? String(projects[0].id) : ''), project_id: task.project_id ? String(task.project_id) : (projects[0]?.id ? String(projects[0].id) : ''),
assigned_to: task.assigned_to || '', assigned_to: task.assigned_to || '',
tags: task.tags ? task.tags.join(', ') : '', tags: task.tags ? task.tags.join(', ') : '',
target_session: task.metadata?.target_session || '',
}) })
const mentionTargets = useMentionTargets() const mentionTargets = useMentionTargets()
const agentSessions = useAgentSessions(formData.assigned_to || undefined)
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -2070,6 +2120,14 @@ function EditTaskModal({
if (!formData.title.trim()) return if (!formData.title.trim()) return
try { 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}`, { const response = await fetch(`/api/tasks/${task.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -2077,7 +2135,8 @@ function EditTaskModal({
...formData, ...formData,
project_id: formData.project_id ? Number(formData.project_id) : undefined, project_id: formData.project_id ? Number(formData.project_id) : undefined,
tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [], 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({
<select <select
id="edit-assignee" id="edit-assignee"
value={formData.assigned_to} value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value, target_session: '' }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
> >
<option value="">{t('unassigned')}</option> <option value="">{t('unassigned')}</option>
@ -2194,6 +2253,26 @@ function EditTaskModal({
</select> </select>
</div> </div>
{formData.assigned_to && agentSessions.length > 0 && (
<div>
<label htmlFor="edit-target-session" className="block text-sm text-muted-foreground mb-1">Target Session</label>
<select
id="edit-target-session"
value={formData.target_session}
onChange={(e) => setFormData(prev => ({ ...prev, target_session: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">New session (default)</option>
{agentSessions.map(s => (
<option key={s.key} value={s.key}>
{s.label || s.channel || s.key}
</option>
))}
</select>
<p className="text-[11px] text-muted-foreground mt-1">Send task to an existing agent session instead of creating a new one.</p>
</div>
)}
<div> <div>
<label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label> <label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input <input

View File

@ -1,5 +1,6 @@
import { getDatabase, db_helpers } from './db' import { getDatabase, db_helpers } from './db'
import { runOpenClaw } from './command' import { runOpenClaw } from './command'
import { callOpenClawGateway } from './openclaw-gateway'
import { eventBus } from './event-bus' import { eventBus } from './event-bus'
import { logger } from './logger' import { logger } from './logger'
@ -425,7 +426,43 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s
const prompt = buildTaskPrompt(task, rejectionFeedback) const prompt = buildTaskPrompt(task, rejectionFeedback)
// Step 1: Invoke via gateway // 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
let agentResponse: AgentResponseParsed
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<any>(
'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 gatewayAgentId = resolveGatewayAgentId(task)
const dispatchModel = classifyTaskModel(task) const dispatchModel = classifyTaskModel(task)
const invokeParams: Record<string, unknown> = { const invokeParams: Record<string, unknown> = {
@ -448,12 +485,13 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s
const finalPayload = parseGatewayJson(finalResult.stdout) const finalPayload = parseGatewayJson(finalResult.stdout)
?? parseGatewayJson(String((finalResult as any)?.stderr || '')) ?? parseGatewayJson(String((finalResult as any)?.stderr || ''))
const agentResponse = parseAgentResponse( agentResponse = parseAgentResponse(
finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout
) )
if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) {
agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId
} }
} // end else (new session dispatch)
if (!agentResponse.text) { if (!agentResponse.text) {
throw new Error('Agent returned empty response') throw new Error('Agent returned empty response')

View File

@ -12,6 +12,8 @@ type DashboardLayoutUpdater = string[] | null | ((current: string[] | null) => s
export interface Session { export interface Session {
id: string id: string
key: string key: string
agent?: string
channel?: string
kind: string kind: string
age: string age: string
model: string model: string