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:
parent
e7aa7e6a91
commit
0acf7daf32
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,35 +426,72 @@ 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 gatewayAgentId = resolveGatewayAgentId(task)
|
const taskMeta = (() => {
|
||||||
const dispatchModel = classifyTaskModel(task)
|
try {
|
||||||
const invokeParams: Record<string, unknown> = {
|
const row = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(task.id) as { metadata: string } | undefined
|
||||||
message: prompt,
|
return row?.metadata ? JSON.parse(row.metadata) : {}
|
||||||
agentId: gatewayAgentId,
|
} catch { return {} }
|
||||||
idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`,
|
})()
|
||||||
deliver: false,
|
const targetSession: string | null = typeof taskMeta?.target_session === 'string' && taskMeta.target_session
|
||||||
}
|
? taskMeta.target_session
|
||||||
// Route to appropriate model tier based on task complexity.
|
: null
|
||||||
// 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
|
let agentResponse: AgentResponseParsed
|
||||||
// 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 || ''))
|
|
||||||
|
|
||||||
const agentResponse = parseAgentResponse(
|
if (targetSession) {
|
||||||
finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout
|
// Dispatch to a specific existing session via chat.send
|
||||||
)
|
logger.info({ taskId: task.id, targetSession, agent: task.agent_name }, 'Dispatching task to targeted session')
|
||||||
if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) {
|
const sendResult = await callOpenClawGateway<any>(
|
||||||
agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId
|
'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) {
|
if (!agentResponse.text) {
|
||||||
throw new Error('Agent returned empty response')
|
throw new Error('Agent returned empty response')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue