diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx
index 406cab331..9122fb626 100644
--- a/app/src/components/intelligence/IntelligenceTasksTab.tsx
+++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx
@@ -214,6 +214,26 @@ export default function IntelligenceTasksTab() {
};
}, [loadAll]);
+ // Background board mutations — the dispatcher poller claiming a card,
+ // `update_task` from an autonomous run, `write_back`, triage, stale-reclaim —
+ // update the persisted board but emit no live socket event to this tab (the
+ // progress→socket bridge only fires inside an interactive, client-connected
+ // turn). So the user-tasks + task-sources boards would otherwise look frozen
+ // while work happens in the background. Re-read just those two on a light
+ // interval while the tab is visible; they're local in-process RPC, so it's
+ // cheap. (A push-based fix would need a core DomainEvent + socket broadcast;
+ // this keeps the fix isolated to the tab and catches every mutation source.)
+ useEffect(() => {
+ const POLL_MS = 4000;
+ const tick = () => {
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
+ void fetchPersonalBoard();
+ void fetchTaskSourcesBoard();
+ };
+ const handle = window.setInterval(tick, POLL_MS);
+ return () => window.clearInterval(handle);
+ }, [fetchPersonalBoard, fetchTaskSourcesBoard]);
+
// A task created from the composer lands either on the personal board or
// on a chosen conversation thread. `add` returns the updated board, so we
// merge it directly — re-fetching listTurnStates would return a stale
@@ -397,7 +417,7 @@ export default function IntelligenceTasksTab() {
status: 'todo',
objective: draft.objective,
notes: draft.notes,
- assignedAgent: 'agent_coder',
+ assignedAgent: 'orchestrator',
approvalMode: 'not_required',
plan: draft.plan,
allowedTools: draft.allowedTools,
diff --git a/app/src/components/intelligence/UserTaskComposer.test.tsx b/app/src/components/intelligence/UserTaskComposer.test.tsx
index 78d8b5088..5c0b129e5 100644
--- a/app/src/components/intelligence/UserTaskComposer.test.tsx
+++ b/app/src/components/intelligence/UserTaskComposer.test.tsx
@@ -18,10 +18,11 @@ vi.mock('../../store/hooks', () => ({
vi.mock('../../services/api/todosApi', () => ({
USER_TASKS_THREAD_ID: 'user-tasks',
- todosApi: { add: vi.fn() },
+ todosApi: { add: vi.fn(), edit: vi.fn() },
}));
const mockAdd = vi.mocked(todosApi.add);
+const mockEdit = vi.mocked(todosApi.edit);
function emptyBoard(threadId: string) {
return { threadId, cards: [], updatedAt: '' };
@@ -83,6 +84,57 @@ describe('UserTaskComposer', () => {
expect(mockAdd.mock.calls[0][0].threadId).toBe('t-1');
});
+ it('assigns the new card to the orchestrator atomically when "assign to agent" is on', async () => {
+ mockAdd.mockResolvedValueOnce(emptyBoard(USER_TASKS_THREAD_ID));
+ render();
+
+ fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
+ target: { value: 'Ship it' },
+ });
+ fireEvent.click(screen.getByRole('checkbox'));
+ fireEvent.click(screen.getByRole('button', { name: 'Create task' }));
+
+ // Single atomic add carries the assignment — no separate edit (no race).
+ await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1));
+ expect(mockAdd).toHaveBeenCalledWith(
+ expect.objectContaining({
+ threadId: USER_TASKS_THREAD_ID,
+ content: 'Ship it',
+ assignedAgent: 'orchestrator',
+ approvalMode: 'not_required',
+ })
+ );
+ expect(mockEdit).not.toHaveBeenCalled();
+ });
+
+ it('does not assign an agent when the toggle is left off', async () => {
+ mockAdd.mockResolvedValueOnce(emptyBoard(USER_TASKS_THREAD_ID));
+ render();
+
+ fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
+ target: { value: 'Buy milk' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Create task' }));
+
+ await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1));
+ expect(mockEdit).not.toHaveBeenCalled();
+ });
+
+ it('disables assign-to-agent when the task is attached to a conversation', () => {
+ render();
+ fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
+ target: { value: 'Book hotel' },
+ });
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).toBeEnabled();
+ // Attaching to a thread takes it off the personal board — the poller doesn't
+ // poll conversation threads, so auto-run is disabled there.
+ fireEvent.change(screen.getByDisplayValue('Personal (no conversation)'), {
+ target: { value: 't-1' },
+ });
+ expect(checkbox).toBeDisabled();
+ });
+
it('surfaces an error and keeps the modal open on failure', async () => {
mockAdd.mockRejectedValueOnce(new Error('boom'));
const onClose = vi.fn();
diff --git a/app/src/components/intelligence/UserTaskComposer.tsx b/app/src/components/intelligence/UserTaskComposer.tsx
index 53fb25390..b8247205e 100644
--- a/app/src/components/intelligence/UserTaskComposer.tsx
+++ b/app/src/components/intelligence/UserTaskComposer.tsx
@@ -39,6 +39,11 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps)
const [objective, setObjective] = useState('');
const [notes, setNotes] = useState('');
const [attachThreadId, setAttachThreadId] = useState('');
+ // When on, the new personal-board card is assigned to the orchestrator so the
+ // task dispatcher's poller auto-picks and runs it. Off → a plain manual todo
+ // the poller never touches. Only meaningful on the personal board (the poller
+ // doesn't poll attached conversation threads), so it's disabled when attaching.
+ const [assignToAgent, setAssignToAgent] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
@@ -53,17 +58,25 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps)
const trimmedTitle = title.trim();
if (!trimmedTitle || submitting) return;
const threadId = attachThreadId || USER_TASKS_THREAD_ID;
+ // Auto-pick only works on the personal board (the poller doesn't poll
+ // attached conversation threads), so ignore the toggle when attaching.
+ const assign = assignToAgent && !attachThreadId;
setSubmitting(true);
setError(null);
- log('submit threadId=%s status=%s', threadId, status);
+ log('submit threadId=%s status=%s assign=%s', threadId, status, assign);
try {
+ // Assigning to the orchestrator + waiving the per-card approval gate so
+ // the dispatcher's poller picks it up and runs it — done atomically in
+ // the single `add` call (no create-then-edit race / partial failure).
const board = await todosApi.add({
threadId,
content: trimmedTitle,
status,
objective: objective.trim() || null,
notes: notes.trim() || null,
+ ...(assign ? { assignedAgent: 'orchestrator', approvalMode: 'not_required' } : {}),
});
+
onCreated(threadId, board);
onClose();
} catch (err) {
@@ -168,6 +181,24 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps)
/>
+
+
{error && (
{t('intelligence.tasks.composer.createFailed')}: {error}
diff --git a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx
index cebae12ac..98c1191f3 100644
--- a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx
+++ b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx
@@ -336,7 +336,7 @@ describe('IntelligenceTasksTab', () => {
expect.objectContaining({
threadId: 'user-tasks',
id: 'agent-task-1',
- assignedAgent: 'agent_coder',
+ assignedAgent: 'orchestrator',
approvalMode: 'not_required',
})
);
@@ -497,6 +497,35 @@ describe('IntelligenceTasksTab', () => {
expect(hoisted.todosRemove).toHaveBeenCalledWith('user-tasks', 'card-0');
});
+ test('re-polls the personal + task-source boards on an interval (background runs show live)', async () => {
+ vi.useFakeTimers();
+ try {
+ hoisted.todosList.mockImplementation((threadId: string) =>
+ Promise.resolve(makeBoard(threadId, []))
+ );
+ vi.resetModules();
+ const Tab = await importTab();
+ renderTab(Tab);
+
+ // Flush the mount effect's setTimeout(0) + the initial loadAll fetches.
+ await vi.advanceTimersByTimeAsync(0);
+ const userBefore = hoisted.todosList.mock.calls.filter(c => c[0] === 'user-tasks').length;
+ const sourceBefore = hoisted.todosList.mock.calls.filter(c => c[0] === 'task-sources').length;
+ expect(userBefore).toBeGreaterThan(0);
+ expect(sourceBefore).toBeGreaterThan(0);
+
+ // One poll interval later, both boards are re-read (so a background poller
+ // run's board changes surface without a manual refresh).
+ await vi.advanceTimersByTimeAsync(4000);
+ const userAfter = hoisted.todosList.mock.calls.filter(c => c[0] === 'user-tasks').length;
+ const sourceAfter = hoisted.todosList.mock.calls.filter(c => c[0] === 'task-sources').length;
+ expect(userAfter).toBeGreaterThan(userBefore);
+ expect(sourceAfter).toBeGreaterThan(sourceBefore);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
test('opens the composer and applies the created personal board', async () => {
vi.resetModules();
const Tab = await importTab();
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index ff6066929..f6d7515fc 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -2601,6 +2601,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'إنشاء مهمة',
'intelligence.tasks.composer.creating': 'خلق...',
'intelligence.tasks.composer.createFailed': 'لا يمكن خلق المهمة',
+ 'intelligence.tasks.composer.assignAgentLabel': 'دع وكيلًا يعمل على هذه المهمة تلقائيًا',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'تلتقطها لوحة المهام وتنفّذها نيابةً عنك. اتركها مغلقة للحصول على مهمة شخصية عادية.',
'intelligence.tasks.sourceList.subtitle': 'مهام المصادر التي تنتظر التحول إلى عمل للوكيل.',
'intelligence.tasks.sourceList.empty': 'لا توجد مهام مصادر في الانتظار.',
'intelligence.tasks.sourceList.queued': 'في قائمة الانتظار',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index f730b2131..0a046ea99 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -2650,6 +2650,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'কাজ তৈরি করুন ( T)',
'intelligence.tasks.composer.creating': 'তৈরি করা হচ্ছে...',
'intelligence.tasks.composer.createFailed': 'কাজ তৈরি করতে ব্যর্থ',
+ 'intelligence.tasks.composer.assignAgentLabel': 'একজন এজেন্টকে স্বয়ংক্রিয়ভাবে এটি করতে দিন',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'টাস্ক বোর্ড এটি তুলে নিয়ে আপনার হয়ে চালাবে। সাধারণ ব্যক্তিগত কাজের জন্য বন্ধ রাখুন।',
'intelligence.tasks.sourceList.subtitle': 'এজেন্টের কাজে রূপান্তরের অপেক্ষায় থাকা উৎস কাজ।',
'intelligence.tasks.sourceList.empty': 'অপেক্ষায় কোনো উৎস কাজ নেই।',
'intelligence.tasks.sourceList.queued': 'সারিবদ্ধ',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index e368cf807..47c825116 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -2716,6 +2716,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Aufgabe erstellen',
'intelligence.tasks.composer.creating': 'Erstellen…',
'intelligence.tasks.composer.createFailed': 'Die Aufgabe konnte nicht erstellt werden',
+ 'intelligence.tasks.composer.assignAgentLabel': 'Einen Agenten automatisch daran arbeiten lassen',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'Das Aufgabenboard übernimmt sie und erledigt sie für dich. Für eine einfache persönliche Aufgabe ausgeschaltet lassen.',
'intelligence.tasks.sourceList.subtitle':
'Quellaufgaben, die in Agentenarbeit umgewandelt werden sollen.',
'intelligence.tasks.sourceList.empty': 'Keine Quellaufgaben in der Warteschlange.',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index cf1525081..7a0ac1ad2 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -3036,6 +3036,9 @@ const en: TranslationMap = {
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
+ 'intelligence.tasks.composer.assignAgentLabel': 'Let an agent work on this automatically',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'The task board picks it up and runs it for you. Leave off for a plain personal to-do.',
'intelligence.tasks.sourceList.subtitle': 'Source tasks waiting to become agent work.',
'intelligence.tasks.sourceList.empty': 'No source tasks waiting.',
'intelligence.tasks.sourceList.queued': 'Queued',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 3af52424f..64ea9458a 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -2700,6 +2700,10 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Crear tarea',
'intelligence.tasks.composer.creating': 'Creando…',
'intelligence.tasks.composer.createFailed': 'No se pudo crear la tarea',
+ 'intelligence.tasks.composer.assignAgentLabel':
+ 'Deja que un agente se encargue de esto automáticamente',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'El tablero de tareas la toma y la ejecuta por ti. Déjalo desactivado para una tarea personal simple.',
'intelligence.tasks.sourceList.subtitle':
'Tareas de fuentes esperando convertirse en trabajo del agente.',
'intelligence.tasks.sourceList.empty': 'No hay tareas de fuentes en espera.',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 83f7de6ce..47710d080 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -2709,6 +2709,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Créer une tâche',
'intelligence.tasks.composer.creating': 'Création…',
'intelligence.tasks.composer.createFailed': 'Impossible de créer la tâche',
+ 'intelligence.tasks.composer.assignAgentLabel': "Laisser un agent s'en charger automatiquement",
+ 'intelligence.tasks.composer.assignAgentHint':
+ "Le tableau des tâches la prend en charge et l'exécute pour vous. Laissez désactivé pour une simple tâche personnelle.",
'intelligence.tasks.sourceList.subtitle':
"Tâches de sources en attente de devenir du travail d'agent.",
'intelligence.tasks.sourceList.empty': 'Aucune tâche de source en attente.',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 0f7877d05..3bd4a1963 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -2655,6 +2655,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'कार्य',
'intelligence.tasks.composer.creating': 'बनाना',
'intelligence.tasks.composer.createFailed': 'काम नहीं कर सका',
+ 'intelligence.tasks.composer.assignAgentLabel': 'किसी एजेंट को इसे स्वचालित रूप से करने दें',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'टास्क बोर्ड इसे उठाकर आपके लिए चला देगा। सामान्य निजी कार्य के लिए इसे बंद रखें।',
'intelligence.tasks.sourceList.subtitle': 'एजेंट कार्य बनने की प्रतीक्षा कर रहे स्रोत कार्य।',
'intelligence.tasks.sourceList.empty': 'कोई स्रोत कार्य प्रतीक्षा में नहीं है।',
'intelligence.tasks.sourceList.queued': 'कतार में',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 33dde55c1..b35e8ffe9 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -2657,6 +2657,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Buat tugas',
'intelligence.tasks.composer.creating': 'Membuat…',
'intelligence.tasks.composer.createFailed': 'Gagal membuat tugas',
+ 'intelligence.tasks.composer.assignAgentLabel': 'Biarkan agen mengerjakan ini secara otomatis',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'Papan tugas akan mengambil dan menjalankannya untuk Anda. Biarkan nonaktif untuk tugas pribadi biasa.',
'intelligence.tasks.sourceList.subtitle': 'Tugas sumber yang menunggu menjadi pekerjaan agen.',
'intelligence.tasks.sourceList.empty': 'Tidak ada tugas sumber yang menunggu.',
'intelligence.tasks.sourceList.queued': 'Dalam antrean',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index a3cac5a9b..1806d658d 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -2690,6 +2690,10 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Crea attività',
'intelligence.tasks.composer.creating': 'Creando…',
'intelligence.tasks.composer.createFailed': 'Impossibile creare il task',
+ 'intelligence.tasks.composer.assignAgentLabel':
+ 'Lascia che un agente se ne occupi automaticamente',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'La bacheca delle attività la prende in carico e la esegue per te. Lascia disattivato per una semplice attività personale.',
'intelligence.tasks.sourceList.subtitle':
"Attività dalle fonti in attesa di diventare lavoro dell'agente.",
'intelligence.tasks.sourceList.empty': 'Nessuna attività da fonti in attesa.',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index b6e65e04b..bc3feedd9 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -2630,6 +2630,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': '작업 만들기',
'intelligence.tasks.composer.creating': '만드는 중…',
'intelligence.tasks.composer.createFailed': '작업을 만들 수 없습니다',
+ 'intelligence.tasks.composer.assignAgentLabel': '에이전트가 이 작업을 자동으로 처리하도록 하기',
+ 'intelligence.tasks.composer.assignAgentHint':
+ '작업 보드가 이를 가져와 대신 실행합니다. 일반 개인 할 일은 꺼 두세요.',
'intelligence.tasks.sourceList.subtitle': '에이전트 작업으로 전환될 소스 작업입니다.',
'intelligence.tasks.sourceList.empty': '대기 중인 소스 작업이 없습니다.',
'intelligence.tasks.sourceList.queued': '대기열에 추가됨',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 6d7e61b37..a21e43a65 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -2687,6 +2687,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Utwórz zadanie',
'intelligence.tasks.composer.creating': 'Tworzenie…',
'intelligence.tasks.composer.createFailed': 'Nie udało się utworzyć zadania',
+ 'intelligence.tasks.composer.assignAgentLabel': 'Pozwól agentowi zająć się tym automatycznie',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'Tablica zadań przejmie je i wykona za Ciebie. Wyłącz dla zwykłego zadania osobistego.',
'intelligence.tasks.sourceList.subtitle':
'Zadania ze źródeł czekające na przekształcenie w pracę agenta.',
'intelligence.tasks.sourceList.empty': 'Brak oczekujących zadań ze źródeł.',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 84b4ab252..d53f6c584 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -2698,6 +2698,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Criar tarefa',
'intelligence.tasks.composer.creating': 'Criando…',
'intelligence.tasks.composer.createFailed': 'Não foi possível criar a tarefa',
+ 'intelligence.tasks.composer.assignAgentLabel': 'Deixe um agente cuidar disto automaticamente',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'O quadro de tarefas o executa por você. Deixe desativado para uma tarefa pessoal simples.',
'intelligence.tasks.sourceList.subtitle':
'Tarefas de fontes aguardando virar trabalho de agente.',
'intelligence.tasks.sourceList.empty': 'Nenhuma tarefa de fonte aguardando.',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index e5de78ece..0a3c44d35 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -2670,6 +2670,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Создать задачу',
'intelligence.tasks.composer.creating': 'Создание…',
'intelligence.tasks.composer.createFailed': 'Не удалось создать задачу',
+ 'intelligence.tasks.composer.assignAgentLabel': 'Поручить агенту выполнить это автоматически',
+ 'intelligence.tasks.composer.assignAgentHint':
+ 'Доска задач возьмёт её и выполнит за вас. Оставьте выключенным для обычной личной задачи.',
'intelligence.tasks.sourceList.subtitle':
'Задачи из источников, ожидающие превращения в работу агента.',
'intelligence.tasks.sourceList.empty': 'Нет ожидающих задач из источников.',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index c6d68f59d..b914e8b58 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -2523,6 +2523,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': '创建任务',
'intelligence.tasks.composer.creating': '正在创建…',
'intelligence.tasks.composer.createFailed': '无法创建任务',
+ 'intelligence.tasks.composer.assignAgentLabel': '让智能体自动处理此任务',
+ 'intelligence.tasks.composer.assignAgentHint':
+ '任务看板会接手并替你执行。普通的个人待办请保持关闭。',
'intelligence.tasks.sourceList.subtitle': '等待转为智能体工作的来源任务。',
'intelligence.tasks.sourceList.empty': '没有等待处理的来源任务。',
'intelligence.tasks.sourceList.queued': '已入队',
diff --git a/app/src/services/api/todosApi.ts b/app/src/services/api/todosApi.ts
index d396086a1..af981bf36 100644
--- a/app/src/services/api/todosApi.ts
+++ b/app/src/services/api/todosApi.ts
@@ -49,6 +49,8 @@ export interface AddTodoInput {
status?: TaskBoardCardStatus;
objective?: string | null;
notes?: string | null;
+ assignedAgent?: string | null;
+ approvalMode?: TaskApprovalMode | null;
}
/** Fields accepted when editing a card. Omitted fields are left unchanged. */
@@ -120,6 +122,8 @@ export const todosApi = {
status: input.status,
objective: input.objective,
notes: input.notes,
+ assignedAgent: input.assignedAgent,
+ approvalMode: input.approvalMode,
}),
});
return snapshotToBoard(snap, input.threadId);
diff --git a/src/openhuman/agent/task_dispatcher.rs b/src/openhuman/agent/task_dispatcher.rs
index e1ee7e492..8df1ea2ec 100644
--- a/src/openhuman/agent/task_dispatcher.rs
+++ b/src/openhuman/agent/task_dispatcher.rs
@@ -26,9 +26,9 @@ use crate::openhuman::agent::harness::definition::{AgentDefinitionRegistry, Prom
use crate::openhuman::agent::harness::session::Agent;
use crate::openhuman::agent::harness::subagent_runner::with_autonomous_iter_cap;
use crate::openhuman::agent::personality_paths::PersonalityContext;
-use crate::openhuman::agent::task_board::{TaskBoardCard, TaskCardStatus};
+use crate::openhuman::agent::task_board::{TaskApprovalMode, TaskBoardCard, TaskCardStatus};
use crate::openhuman::config::Config;
-use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch};
+use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch, USER_TASKS_THREAD_ID};
use crate::openhuman::todos::runs::{self, RunLimits, RunOutcome};
/// Max chars of a personality SOUL.md / MEMORY.md or skill guideline block
@@ -129,6 +129,33 @@ pub fn build_task_prompt(card: &TaskBoardCard) -> String {
lines.join("\n")
}
+/// Instruction appended to the run prompt so the autonomous turn keeps its own
+/// task card current via the `update_task` tool while it works.
+///
+/// The card is already `in_progress` (the dispatcher claimed it before
+/// spawning the run), addressed by the exact card id + board the run owns
+/// (without the explicit `threadId` the tool defaults to the `task-sources`
+/// board and would miss a `user-tasks` card). Two things this asks for:
+/// 1. *progress* updates (notes/evidence) as the run works, and
+/// 2. an explicit `status: blocked` + `blocker` when the run needs a
+/// decision/information from the user or cannot proceed — which
+/// [`write_back`] now preserves rather than force-completing, so the task
+/// pauses for the user instead of being silently marked done.
+fn build_progress_instruction(card_id: &str, thread_id: &str) -> String {
+ format!(
+ "\n\nThis task is tracked as card `{card_id}` on the `{thread_id}` board. As you work, \
+ call the `update_task` tool (id `{card_id}`, threadId `{thread_id}`) to keep the card \
+ current — append `notes`/`evidence` as you make progress.\n\nIf you need a decision or \
+ information from the user, or you genuinely cannot proceed (missing access, ambiguous \
+ requirement, an action that needs the user's confirmation), call `update_task` with \
+ `status: blocked` and a `blocker` that states exactly what you need from the user. The \
+ task will stay paused in that blocked state until the user responds — do NOT guess, \
+ fabricate, or take a risky irreversible action just to avoid blocking. If instead you \
+ finish the work, end with a summary of what you did and the evidence; completion is \
+ recorded automatically."
+ )
+}
+
/// Outcome of a dispatch attempt.
#[derive(Debug)]
pub enum DispatchOutcome {
@@ -161,7 +188,16 @@ pub async fn dispatch_card(
// approval before it can run. `Ready` (already approved) bypasses. We
// attempt the AwaitingApproval claim first so the gate is also atomic —
// two dispatchers racing the same Todo card won't both park it.
- if config.autonomy.require_task_plan_approval {
+ //
+ // A card explicitly marked `approval_mode = NotRequired` also bypasses the
+ // gate: it has already cleared human review (e.g. a task approved out of
+ // the `task-sources` inbox onto the `user-tasks` board, stamped
+ // `not_required` at approval time). Re-parking it under the global default
+ // would strand it on a board nobody approves from. Per-card opt-out wins.
+ if requires_plan_approval(
+ config.autonomy.require_task_plan_approval,
+ card.approval_mode.as_ref(),
+ ) {
match ops::claim_card(
&location,
&card_id,
@@ -199,7 +235,14 @@ pub async fn dispatch_card(
)
.map_err(|e| format!("[task_dispatcher] claim rejected for {card_id}: {e}"))?;
- let prompt = build_task_prompt(&fresh_card);
+ let mut prompt = build_task_prompt(&fresh_card);
+ // Tell the run which card it owns so it can post live progress via the
+ // `update_task` tool (notes/evidence) as it works. The terminal
+ // `done`/`blocked` transition is still stamped deterministically by
+ // `write_back` from the run outcome.
+ if let Some(thread_id) = location.thread_id() {
+ prompt.push_str(&build_progress_instruction(&card_id, thread_id));
+ }
let run_id = uuid::Uuid::new_v4().to_string();
@@ -400,48 +443,77 @@ async fn run_autonomous(
/// Success → `done` + evidence; failure → `blocked` + blocker reason. An
/// external write failure here is logged, never propagated — the run already
/// happened.
+/// Current persisted status of a card, or `None` if the board can't be read or
+/// the card is gone. Used by `write_back` to detect a run that blocked itself.
+fn current_card_status(location: &BoardLocation, card_id: &str) -> Option {
+ ops::list(location)
+ .ok()
+ .and_then(|snap| snap.cards.into_iter().find(|c| c.id == card_id))
+ .map(|c| c.status)
+}
+
fn write_back(
location: &BoardLocation,
card_id: &str,
run_id: &str,
outcome: Result,
) {
- let patch = match &outcome {
- Ok(output) => {
- tracing::info!(
- card_id = %card_id,
- run_id = %run_id,
- output_chars = output.chars().count(),
- "[task_dispatcher] run complete → done"
- );
- CardPatch {
- status: Some(TaskCardStatus::Done),
- evidence: Some(vec![truncate_chars(output.trim(), EVIDENCE_MAX_CHARS)]),
- ..Default::default()
+ // Respect a status the run set for itself: if the agent marked the card
+ // `blocked` via `update_task` (it needs a decision/input from the user, or
+ // genuinely cannot proceed), leave it blocked — do NOT force-complete it.
+ // The task then stays paused in that state until the user responds, instead
+ // of a "clean turn" being silently recorded as done. Otherwise mark done
+ // with evidence; a run error marks blocked with the error as the blocker.
+ let agent_self_blocked =
+ outcome.is_ok() && current_card_status(location, card_id) == Some(TaskCardStatus::Blocked);
+
+ let patch = if agent_self_blocked {
+ tracing::info!(
+ card_id = %card_id,
+ run_id = %run_id,
+ "[task_dispatcher] run ended with card self-blocked → leaving blocked (awaiting user input), not auto-completing"
+ );
+ None
+ } else {
+ match &outcome {
+ Ok(output) => {
+ tracing::info!(
+ card_id = %card_id,
+ run_id = %run_id,
+ output_chars = output.chars().count(),
+ "[task_dispatcher] run complete → done"
+ );
+ Some(CardPatch {
+ status: Some(TaskCardStatus::Done),
+ evidence: Some(vec![truncate_chars(output.trim(), EVIDENCE_MAX_CHARS)]),
+ ..Default::default()
+ })
}
- }
- Err(err) => {
- tracing::warn!(
- card_id = %card_id,
- run_id = %run_id,
- error = %err,
- "[task_dispatcher] run failed → blocked"
- );
- CardPatch {
- status: Some(TaskCardStatus::Blocked),
- blocker: Some(truncate_chars(err, EVIDENCE_MAX_CHARS)),
- ..Default::default()
+ Err(err) => {
+ tracing::warn!(
+ card_id = %card_id,
+ run_id = %run_id,
+ error = %err,
+ "[task_dispatcher] run failed → blocked"
+ );
+ Some(CardPatch {
+ status: Some(TaskCardStatus::Blocked),
+ blocker: Some(truncate_chars(err, EVIDENCE_MAX_CHARS)),
+ ..Default::default()
+ })
}
}
};
- if let Err(e) = ops::edit(location, card_id, patch) {
- tracing::error!(
- card_id = %card_id,
- run_id = %run_id,
- error = %e,
- "[task_dispatcher] board write-back failed (run outcome lost from board)"
- );
+ if let Some(patch) = patch {
+ if let Err(e) = ops::edit(location, card_id, patch) {
+ tracing::error!(
+ card_id = %card_id,
+ run_id = %run_id,
+ error = %e,
+ "[task_dispatcher] board write-back failed (run outcome lost from board)"
+ );
+ }
}
let (run_outcome, run_error, run_evidence) = match &outcome {
@@ -510,9 +582,19 @@ pub fn start_board_poller() {
});
}
-/// One poller tick: dispatch the highest-urgency `todo` card on the
-/// task-sources board, if any and if capacity allows. `pub(crate)` so tests can
+/// One poller tick: sweep each executor board and dispatch its highest-urgency
+/// dispatchable card, if any and if capacity allows. `pub(crate)` so tests can
/// drive a tick without the real interval.
+///
+/// Two boards are swept, each independently (own stale-reclaim + single
+/// `in_progress` cap):
+/// - **`user-tasks`** (the kanban work board) — always swept, but only
+/// **agent-assigned** cards are run, so a human's manually-created todo is
+/// never auto-executed. This is where tasks approved out of the inbox run.
+/// - **`task-sources`** (the proactive inbox) — swept only when ingestion is
+/// enabled. With plan-approval required this only ever parks a `todo` at
+/// `awaiting_approval`; it runs a card directly only when approval is off.
+/// Kept in the sweep so its stale/wedged runs are still reclaimed.
pub(crate) async fn poll_once() -> Result<(), String> {
// Gate on background-AI capacity (autonomy / power / pause). Dropping the
// permit immediately is fine: this is a "may background work start now"
@@ -525,21 +607,50 @@ pub(crate) async fn poll_once() -> Result<(), String> {
let config = Config::load_or_init()
.await
.map_err(|e| format!("load config: {e:#}"))?;
- if !config.task_sources.enabled {
- return Ok(());
+
+ // (board location, agent_assigned_only). user-tasks first — it's the real
+ // work board; task-sources is only included for parking + reclaim.
+ let mut boards: Vec<(BoardLocation, bool)> = vec![(
+ BoardLocation::Thread {
+ workspace_dir: config.workspace_dir.clone(),
+ thread_id: USER_TASKS_THREAD_ID.to_string(),
+ },
+ true,
+ )];
+ if config.task_sources.enabled {
+ boards.push((
+ BoardLocation::Thread {
+ workspace_dir: config.workspace_dir.clone(),
+ thread_id: crate::openhuman::task_sources::TASK_SOURCES_THREAD_ID.to_string(),
+ },
+ false,
+ ));
}
- let location = BoardLocation::Thread {
- workspace_dir: config.workspace_dir.clone(),
- thread_id: crate::openhuman::task_sources::TASK_SOURCES_THREAD_ID.to_string(),
- };
+ for (location, agent_assigned_only) in boards {
+ if let Err(e) = poll_board(&location, agent_assigned_only).await {
+ tracing::warn!(
+ thread_id = ?location.thread_id(),
+ error = %e,
+ "[task_dispatcher:poller] board sweep failed (continuing)"
+ );
+ }
+ }
+ Ok(())
+}
+/// Sweep one board: reclaim stale runs, then (unless one is already running)
+/// dispatch its highest-urgency dispatchable card. When `agent_assigned_only`
+/// is set, only cards with an `assigned_agent` are eligible — the guard that
+/// keeps the poller off a human's manual `user-tasks` cards.
+async fn poll_board(location: &BoardLocation, agent_assigned_only: bool) -> Result<(), String> {
// Reclaim stale/wedged runs before looking for new work. Reclaimed
// cards move back to `todo` (re-dispatchable) so they appear in the
// snapshot below and can be picked up in the same tick.
- match runs::reclaim_stale(&location, &RunLimits::default()) {
+ match runs::reclaim_stale(location, &RunLimits::default()) {
Ok(result) if result.reclaimed_count > 0 || result.blocked_count > 0 => {
tracing::info!(
+ thread_id = ?location.thread_id(),
reclaimed = result.reclaimed_count,
blocked = result.blocked_count,
"[task_dispatcher:poller] stale runs reclaimed"
@@ -547,6 +658,7 @@ pub(crate) async fn poll_once() -> Result<(), String> {
}
Err(e) => {
tracing::warn!(
+ thread_id = ?location.thread_id(),
error = %e,
"[task_dispatcher:poller] stale reclaim failed (continuing)"
);
@@ -554,7 +666,7 @@ pub(crate) async fn poll_once() -> Result<(), String> {
_ => {}
}
- let snapshot = ops::list(&location)?;
+ let snapshot = ops::list(location)?;
// `enforce_single_in_progress` caps the board at one running card, so if
// one is already in progress there's nothing for this tick to claim.
@@ -566,26 +678,39 @@ pub(crate) async fn poll_once() -> Result<(), String> {
return Ok(());
}
- let Some(card) = pick_next_todo(&snapshot.cards) else {
+ let Some(card) = pick_next_todo(&snapshot.cards, agent_assigned_only) else {
return Ok(());
};
tracing::info!(
card_id = %card.id,
+ thread_id = ?location.thread_id(),
urgency = card_urgency(&card),
- "[task_dispatcher:poller] dispatching highest-urgency todo card"
+ agent_assigned_only,
+ "[task_dispatcher:poller] dispatching highest-urgency dispatchable card"
);
- dispatch_card(location, card).await.map(|_| ())
+ dispatch_card(location.clone(), card).await.map(|_| ())
}
/// Highest-urgency dispatchable card (`todo` or approved `ready`; urgency from
/// `source_metadata.urgency`, default 0.0; ties broken toward the lower board
/// `order`). Returns a clone. `dispatch_card` then either runs a `ready` card
/// or parks a `todo` one for approval, per the autonomy setting.
-fn pick_next_todo(cards: &[TaskBoardCard]) -> Option {
+///
+/// When `agent_assigned_only` is set, cards without an `assigned_agent` are
+/// excluded — used on the `user-tasks` board so the poller runs only
+/// agent-generated tasks and never picks up a human's manually-created card.
+fn pick_next_todo(cards: &[TaskBoardCard], agent_assigned_only: bool) -> Option {
cards
.iter()
.filter(|c| matches!(c.status, TaskCardStatus::Todo | TaskCardStatus::Ready))
+ .filter(|c| {
+ !agent_assigned_only
+ || c.assigned_agent
+ .as_deref()
+ .map(|a| !a.trim().is_empty())
+ .unwrap_or(false)
+ })
.max_by(|a, b| {
card_urgency(a)
.partial_cmp(&card_urgency(b))
@@ -597,6 +722,18 @@ fn pick_next_todo(cards: &[TaskBoardCard]) -> Option {
.cloned()
}
+/// Whether a card must be parked at `awaiting_approval` before it can run.
+///
+/// The global `require_task_plan_approval` setting applies *unless* the card is
+/// explicitly marked `approval_mode = NotRequired` — a per-card opt-out for
+/// tasks that have already cleared human review (e.g. approved out of the
+/// `task-sources` inbox onto `user-tasks`). Per-card opt-out wins over the
+/// global default; without this, an already-approved card would be re-parked
+/// and stranded.
+fn requires_plan_approval(global_required: bool, approval_mode: Option<&TaskApprovalMode>) -> bool {
+ global_required && approval_mode != Some(&TaskApprovalMode::NotRequired)
+}
+
fn card_urgency(card: &TaskBoardCard) -> f64 {
card.source_metadata
.as_ref()
@@ -728,7 +865,7 @@ mod tests {
card_with("c", TaskCardStatus::Todo, Some(0.8), 2),
card_with("d", TaskCardStatus::Todo, None, 3),
];
- let picked = pick_next_todo(&cards).expect("a todo card is available");
+ let picked = pick_next_todo(&cards, false).expect("a todo card is available");
assert_eq!(
picked.id, "c",
"highest-urgency todo wins, done card ignored"
@@ -741,13 +878,13 @@ mod tests {
card_with("late", TaskCardStatus::Todo, Some(0.5), 5),
card_with("early", TaskCardStatus::Todo, Some(0.5), 2),
];
- assert_eq!(pick_next_todo(&cards).unwrap().id, "early");
+ assert_eq!(pick_next_todo(&cards, false).unwrap().id, "early");
}
#[test]
fn poller_returns_none_when_no_todo_cards() {
let cards = vec![card_with("a", TaskCardStatus::Done, Some(0.9), 0)];
- assert!(pick_next_todo(&cards).is_none());
+ assert!(pick_next_todo(&cards, false).is_none());
}
#[test]
@@ -759,7 +896,7 @@ mod tests {
card_with("rej", TaskCardStatus::Rejected, Some(0.95), 1),
card_with("ready", TaskCardStatus::Ready, Some(0.5), 2),
];
- assert_eq!(pick_next_todo(&cards).unwrap().id, "ready");
+ assert_eq!(pick_next_todo(&cards, false).unwrap().id, "ready");
}
#[test]
@@ -768,7 +905,65 @@ mod tests {
card_with("ready-low", TaskCardStatus::Ready, Some(0.3), 0),
card_with("todo-high", TaskCardStatus::Todo, Some(0.9), 1),
];
- assert_eq!(pick_next_todo(&cards).unwrap().id, "todo-high");
+ assert_eq!(pick_next_todo(&cards, false).unwrap().id, "todo-high");
+ }
+
+ #[test]
+ fn poller_agent_only_skips_unassigned_cards() {
+ // On the user-tasks board we run only agent-assigned cards. A human's
+ // manual todo (no assigned_agent) must be skipped even at high urgency.
+ let mut human = card_with("human", TaskCardStatus::Todo, Some(0.99), 0);
+ human.assigned_agent = None;
+ let mut agent = card_with("agent", TaskCardStatus::Todo, Some(0.20), 1);
+ agent.assigned_agent = Some("orchestrator".into());
+ let cards = vec![human, agent];
+
+ // Agent-only: the lower-urgency assigned card wins; the human card is invisible.
+ assert_eq!(pick_next_todo(&cards, true).unwrap().id, "agent");
+ // Unfiltered (task-sources behaviour): highest urgency wins regardless.
+ assert_eq!(pick_next_todo(&cards, false).unwrap().id, "human");
+ }
+
+ #[test]
+ fn poller_agent_only_returns_none_when_all_unassigned() {
+ let mut a = card_with("a", TaskCardStatus::Todo, Some(0.9), 0);
+ a.assigned_agent = None;
+ let mut b = card_with("b", TaskCardStatus::Todo, Some(0.5), 1);
+ b.assigned_agent = Some(" ".into()); // blank handle is not "assigned"
+ let cards = vec![a, b];
+ assert!(pick_next_todo(&cards, true).is_none());
+ }
+
+ #[test]
+ fn approval_gate_respects_global_and_per_card_optout() {
+ // Global off → never park.
+ assert!(!requires_plan_approval(false, None));
+ assert!(!requires_plan_approval(
+ false,
+ Some(&TaskApprovalMode::Required)
+ ));
+ // Global on → park, unless the card opts out via NotRequired.
+ assert!(requires_plan_approval(true, None));
+ assert!(requires_plan_approval(
+ true,
+ Some(&TaskApprovalMode::Required)
+ ));
+ assert!(!requires_plan_approval(
+ true,
+ Some(&TaskApprovalMode::NotRequired)
+ ));
+ }
+
+ #[test]
+ fn progress_instruction_names_card_thread_and_tool() {
+ let s = build_progress_instruction("task-42", "user-tasks");
+ assert!(s.contains("task-42"));
+ assert!(s.contains("user-tasks"));
+ assert!(s.contains("update_task"));
+ // It must instruct the agent to self-block (status: blocked + blocker)
+ // when it needs the user, so write_back can preserve that state.
+ assert!(s.contains("status: blocked"));
+ assert!(s.contains("blocker"));
}
#[test]
@@ -839,6 +1034,58 @@ mod tests {
assert!(card.evidence.iter().any(|e| e.contains("opened PR #5")));
}
+ #[test]
+ fn write_back_preserves_agent_set_blocked_on_clean_run() {
+ // The run marked its own card `blocked` (needs user input) via
+ // update_task, then ended cleanly. write_back must NOT force it to
+ // `done` — the task stays blocked, with the agent's blocker intact,
+ // awaiting the user.
+ let dir = tempfile::tempdir().unwrap();
+ let loc = board_loc(dir.path());
+ let id = ops::add(&loc, "update alan", CardPatch::default())
+ .unwrap()
+ .cards[0]
+ .id
+ .clone();
+ ops::update_status(&loc, &id, TaskCardStatus::InProgress).unwrap();
+ // Agent self-blocks mid-run, as build_progress_instruction asks it to.
+ ops::edit(
+ &loc,
+ &id,
+ CardPatch {
+ status: Some(TaskCardStatus::Blocked),
+ blocker: Some("Slack isn't connected — confirm how to reach Alan".to_string()),
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ // Run returns Ok (the turn finished) — but the card is self-blocked.
+ write_back(
+ &loc,
+ &id,
+ "run-2",
+ Ok("I checked GitHub and memory…".to_string()),
+ );
+
+ let card = ops::list(&loc)
+ .unwrap()
+ .cards
+ .into_iter()
+ .find(|c| c.id == id)
+ .unwrap();
+ assert_eq!(
+ card.status,
+ TaskCardStatus::Blocked,
+ "a clean run over a self-blocked card must stay blocked, not auto-done"
+ );
+ assert_eq!(
+ card.blocker.as_deref(),
+ Some("Slack isn't connected — confirm how to reach Alan"),
+ "the agent's blocker reason is preserved"
+ );
+ }
+
#[test]
fn write_back_marks_blocked_with_reason_on_failure() {
let dir = tempfile::tempdir().unwrap();
diff --git a/src/openhuman/agent/tools.rs b/src/openhuman/agent/tools.rs
index 8dd117f64..c73d52a38 100644
--- a/src/openhuman/agent/tools.rs
+++ b/src/openhuman/agent/tools.rs
@@ -6,6 +6,7 @@ pub mod remember_preference;
mod run_skill;
pub mod save_preference;
mod todo;
+mod update_task;
pub mod workflow_tools;
pub use ask_clarification::AskClarificationTool;
@@ -16,4 +17,5 @@ pub use remember_preference::RememberPreferenceTool;
pub use run_skill::{RunSkillTool, RUN_SKILL_TOOL_NAME};
pub use save_preference::SavePreferenceTool;
pub use todo::TodoTool;
+pub use update_task::UpdateTaskTool;
pub use workflow_tools::{WorkflowLoadTool, WorkflowPhaseTool};
diff --git a/src/openhuman/agent/tools/update_task.rs b/src/openhuman/agent/tools/update_task.rs
new file mode 100644
index 000000000..be222c832
--- /dev/null
+++ b/src/openhuman/agent/tools/update_task.rs
@@ -0,0 +1,226 @@
+//! `update_task` — move or update a specific task card on a thread's board.
+//!
+//! `todo` only reaches the *current* thread's board. `update_task` addresses a
+//! card **by id** on a *target* board — defaulting to the proactive
+//! `task-sources` board — so the orchestrator (or an autonomous task run on its
+//! own thread) can advance the task it's working: move it to
+//! `in_progress`/`blocked`/`done`, or update its objective/notes/evidence/blocker.
+//!
+//! It is a thin wrapper over [`crate::openhuman::todos::ops::edit`], which
+//! applies the status move + field updates atomically, enforces the
+//! single-`in_progress` invariant, and emits the board-progress event that the
+//! Tasks board UI listens on.
+
+use crate::openhuman::task_sources::TASK_SOURCES_THREAD_ID;
+use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch};
+use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
+use async_trait::async_trait;
+use serde_json::json;
+use std::path::PathBuf;
+
+pub struct UpdateTaskTool;
+
+impl UpdateTaskTool {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl Default for UpdateTaskTool {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl Tool for UpdateTaskTool {
+ fn name(&self) -> &str {
+ "update_task"
+ }
+
+ fn description(&self) -> &str {
+ "Move or update a specific task card on a task board, addressed by `id`. \
+ Use this to advance the task you're working on: set `status` \
+ (`todo`/`in_progress`/`blocked`/`done`) to move it between columns, and/or \
+ update `objective`, `notes`, `evidence`, `blocker`, `plan`, \
+ `acceptanceCriteria`. When you finish, set `status: done` with `evidence`; \
+ if you cannot proceed, set `status: blocked` with a `blocker` reason. \
+ Targets the proactive `task-sources` board by default — pass `threadId` to \
+ target another thread's board. Returns the updated card list + a markdown \
+ rendering. At most one card may be `in_progress` at a time."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ json!({
+ "type": "object",
+ "properties": {
+ "id": { "type": "string", "description": "Id of the card to move/update (required)." },
+ "status": {
+ "type": "string",
+ "enum": ["todo", "in_progress", "blocked", "done"],
+ "description": "New status — moves the card to that column."
+ },
+ "objective": { "type": "string", "description": "Updated desired outcome for the task." },
+ "notes": { "type": "string", "description": "Progress notes / running summary." },
+ "blocker": { "type": "string", "description": "Why the task is blocked (set with status=blocked)." },
+ "evidence": {
+ "type": "array",
+ "description": "Links, output, or files proving the work (set with status=done).",
+ "items": { "type": "string" }
+ },
+ "plan": {
+ "type": "array",
+ "description": "Updated ordered execution steps.",
+ "items": { "type": "string" }
+ },
+ "acceptanceCriteria": {
+ "type": "array",
+ "description": "Updated checklist that must hold before the task is done.",
+ "items": { "type": "string" }
+ },
+ "threadId": {
+ "type": "string",
+ "description": "Board to target; defaults to the `task-sources` board."
+ }
+ },
+ "required": ["id"]
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::None
+ }
+
+ async fn execute(&self, args: serde_json::Value) -> anyhow::Result {
+ let Some(id) = optional_string(&args, "id") else {
+ return Ok(ToolResult::error("missing required field `id`".to_string()));
+ };
+
+ let patch = match build_patch(&args) {
+ Ok(patch) => patch,
+ Err(err) => return Ok(ToolResult::error(err)),
+ };
+ if patch_is_empty(&patch) {
+ return Ok(ToolResult::error(
+ "nothing to update — provide `status` and/or a field \
+ (objective/notes/evidence/blocker/plan/acceptanceCriteria)"
+ .to_string(),
+ ));
+ }
+
+ let location = match resolve_location(&args).await {
+ Ok(location) => location,
+ Err(err) => return Ok(ToolResult::error(err)),
+ };
+
+ Ok(apply(&location, &id, patch))
+ }
+}
+
+/// Apply the move/update to the card and render the result. Split out from
+/// `execute` so the edit + response shaping is testable without a fork/thread
+/// context (which `resolve_location` needs).
+fn apply(location: &BoardLocation, id: &str, patch: CardPatch) -> ToolResult {
+ tracing::info!(
+ card_id = %id,
+ thread_id = ?location.thread_id(),
+ status = ?patch.status,
+ "[tool][update_task] move/update task card"
+ );
+ match ops::edit(location, id, patch) {
+ Ok(snap) => {
+ let payload = json!({
+ "threadId": snap.thread_id,
+ "cards": snap.cards,
+ "markdown": snap.markdown,
+ });
+ ToolResult::success(payload.to_string())
+ }
+ Err(err) => ToolResult::error(err),
+ }
+}
+
+/// Resolve the board to act on: the explicit `threadId` arg, else the proactive
+/// `task-sources` board. The workspace root comes from the running agent's fork
+/// context when present, otherwise from the loaded config.
+async fn resolve_location(args: &serde_json::Value) -> Result {
+ let thread_id =
+ optional_string(args, "threadId").unwrap_or_else(|| TASK_SOURCES_THREAD_ID.to_string());
+ Ok(BoardLocation::Thread {
+ workspace_dir: workspace_dir().await?,
+ thread_id,
+ })
+}
+
+async fn workspace_dir() -> Result {
+ if let Some(parent) = crate::openhuman::agent::harness::fork_context::current_parent() {
+ return Ok(parent.workspace_dir.clone());
+ }
+ crate::openhuman::config::ops::load_config_with_timeout()
+ .await
+ .map(|config| config.workspace_dir)
+ .map_err(|e| format!("update_task: failed to load config for workspace dir: {e}"))
+}
+
+fn build_patch(args: &serde_json::Value) -> Result {
+ let status = match args.get("status").and_then(|v| v.as_str()) {
+ Some(s) => Some(ops::parse_status(s)?),
+ None => None,
+ };
+ Ok(CardPatch {
+ status,
+ objective: optional_string(args, "objective"),
+ plan: optional_string_array(args, "plan")?,
+ acceptance_criteria: optional_string_array(args, "acceptanceCriteria")?,
+ evidence: optional_string_array(args, "evidence")?,
+ notes: optional_string(args, "notes"),
+ blocker: optional_string(args, "blocker"),
+ ..Default::default()
+ })
+}
+
+fn patch_is_empty(patch: &CardPatch) -> bool {
+ patch.status.is_none()
+ && patch.objective.is_none()
+ && patch.plan.is_none()
+ && patch.acceptance_criteria.is_none()
+ && patch.evidence.is_none()
+ && patch.notes.is_none()
+ && patch.blocker.is_none()
+}
+
+fn optional_string(args: &serde_json::Value, key: &str) -> Option {
+ args.get(key)
+ .and_then(|v| v.as_str())
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+}
+
+fn optional_string_array(
+ args: &serde_json::Value,
+ key: &str,
+) -> Result