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 { 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 {
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

View File

@ -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<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> = {
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({
<select
id="create-assignee"
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"
>
<option value="">{t('unassigned')}</option>
@ -1972,6 +2000,26 @@ function CreateTaskModal({
</select>
</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>
<label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input
@ -2061,8 +2109,10 @@ function EditTaskModal({
project_id: task.project_id ? String(task.project_id) : (projects[0]?.id ? String(projects[0].id) : ''),
assigned_to: task.assigned_to || '',
tags: task.tags ? task.tags.join(', ') : '',
target_session: task.metadata?.target_session || '',
})
const mentionTargets = useMentionTargets()
const agentSessions = useAgentSessions(formData.assigned_to || undefined)
const handleSubmit = async (e: React.FormEvent) => {
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({
<select
id="edit-assignee"
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"
>
<option value="">{t('unassigned')}</option>
@ -2194,6 +2253,26 @@ function EditTaskModal({
</select>
</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>
<label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input

View File

@ -1,5 +1,6 @@
import { getDatabase, db_helpers } from './db'
import { runOpenClaw } from './command'
import { callOpenClawGateway } from './openclaw-gateway'
import { eventBus } from './event-bus'
import { logger } from './logger'
@ -425,35 +426,72 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s
const prompt = buildTaskPrompt(task, rejectionFeedback)
// Step 1: Invoke via gateway
const gatewayAgentId = resolveGatewayAgentId(task)
const dispatchModel = classifyTaskModel(task)
const invokeParams: Record<string, unknown> = {
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<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 dispatchModel = classifyTaskModel(task)
const invokeParams: Record<string, unknown> = {
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')

View File

@ -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