feat(tasks): autonomously execute approved/assigned task-board work (poller + update_task + repetition fix) (#3326)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-06-04 00:38:39 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 1a85d242e2
commit f36c66b5b5
29 changed files with 987 additions and 59 deletions
@@ -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,
@@ -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(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
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(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
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(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
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();
@@ -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<string | null>(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)
/>
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={assignToAgent && !attachThreadId}
disabled={attachThreadId !== ''}
onChange={e => setAssignToAgent(e.target.checked)}
className="mt-0.5 h-4 w-4 flex-none rounded border-stone-300 text-ocean-600 focus:ring-ocean-500 disabled:opacity-50 dark:border-neutral-600 dark:bg-neutral-950"
/>
<span className="text-xs text-stone-600 dark:text-neutral-300">
<span className="font-semibold text-stone-700 dark:text-neutral-200">
{t('intelligence.tasks.composer.assignAgentLabel')}
</span>
<span className="mt-0.5 block text-stone-500 dark:text-neutral-400">
{t('intelligence.tasks.composer.assignAgentHint')}
</span>
</span>
</label>
{error && (
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('intelligence.tasks.composer.createFailed')}: {error}
@@ -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();
+3
View File
@@ -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': 'في قائمة الانتظار',
+3
View File
@@ -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': 'সারিবদ্ধ',
+3
View File
@@ -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.',
+3
View File
@@ -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',
+4
View File
@@ -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.',
+3
View File
@@ -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.',
+3
View File
@@ -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': 'कतार में',
+3
View File
@@ -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',
+4
View File
@@ -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.',
+3
View File
@@ -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': '대기열에 추가됨',
+3
View File
@@ -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ł.',
+3
View File
@@ -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.',
+3
View File
@@ -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': 'Нет ожидающих задач из источников.',
+3
View File
@@ -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': '已入队',
+4
View File
@@ -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);
+301 -54
View File
@@ -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<TaskCardStatus> {
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<String, String>,
) {
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<TaskBoardCard> {
///
/// 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<TaskBoardCard> {
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<TaskBoardCard> {
.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();
+2
View File
@@ -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};
+226
View File
@@ -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<ToolResult> {
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<BoardLocation, String> {
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<PathBuf, String> {
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<CardPatch, String> {
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<String> {
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<Option<Vec<String>>, String> {
match args.get(key) {
None => Ok(None),
Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::Array(items)) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
let s = item
.as_str()
.ok_or_else(|| format!("`{key}` must be an array of strings"))?
.trim();
if !s.is_empty() {
out.push(s.to_string());
}
}
Ok(Some(out))
}
Some(_) => Err(format!("`{key}` must be an array of strings")),
}
}
#[cfg(test)]
#[path = "update_task_tests.rs"]
mod tests;
@@ -0,0 +1,123 @@
//! Tests for the `update_task` tool.
use super::*;
use crate::openhuman::agent::task_board::TaskCardStatus;
use crate::openhuman::tools::traits::Tool;
use serde_json::json;
// ── build_patch ──────────────────────────────────────────────────────────────
#[test]
fn build_patch_maps_status_and_all_fields() {
let patch = build_patch(&json!({
"status": "in_progress",
"objective": "ship it",
"notes": "halfway",
"blocker": "",
"evidence": ["https://x/1", " ", "note"],
"plan": ["a", "b"],
"acceptanceCriteria": ["covered"]
}))
.expect("valid patch");
assert_eq!(patch.status, Some(TaskCardStatus::InProgress));
assert_eq!(patch.objective.as_deref(), Some("ship it"));
assert_eq!(patch.notes.as_deref(), Some("halfway"));
// empty/blank array entries are dropped; blanks elsewhere become None via edit.
assert_eq!(
patch.evidence,
Some(vec!["https://x/1".to_string(), "note".to_string()])
);
assert_eq!(patch.plan, Some(vec!["a".to_string(), "b".to_string()]));
assert_eq!(patch.acceptance_criteria, Some(vec!["covered".to_string()]));
}
#[test]
fn build_patch_empty_args_is_empty() {
let patch = build_patch(&json!({})).expect("ok");
assert!(patch_is_empty(&patch));
}
#[test]
fn build_patch_with_only_status_is_not_empty() {
let patch = build_patch(&json!({ "status": "done" })).expect("ok");
assert!(!patch_is_empty(&patch));
assert_eq!(patch.status, Some(TaskCardStatus::Done));
}
#[test]
fn build_patch_rejects_unknown_status() {
assert!(build_patch(&json!({ "status": "nonsense" })).is_err());
}
#[test]
fn build_patch_rejects_non_string_array_item() {
assert!(build_patch(&json!({ "evidence": [1, 2] })).is_err());
}
// ── execute() guard rails (no board/workspace needed) ────────────────────────
#[tokio::test]
async fn execute_without_id_is_an_error() {
let res = UpdateTaskTool::new()
.execute(json!({ "status": "done" }))
.await
.unwrap();
assert!(res.is_error, "missing id must be a tool error");
}
#[tokio::test]
async fn execute_with_id_but_no_changes_is_an_error() {
// id present but nothing to change → rejected before any board write.
let res = UpdateTaskTool::new()
.execute(json!({ "id": "task-1" }))
.await
.unwrap();
assert!(res.is_error, "empty update must be a tool error");
}
// ── the move + update applied through ops::edit (the tool's real effect) ─────
#[test]
fn apply_moves_and_updates_a_card_and_returns_success() {
let dir = tempfile::tempdir().unwrap();
let location = BoardLocation::Thread {
workspace_dir: dir.path().to_path_buf(),
thread_id: TASK_SOURCES_THREAD_ID.to_string(),
};
let id = ops::add(&location, "Review PR #5", CardPatch::default())
.unwrap()
.cards[0]
.id
.clone();
// Move to done + attach evidence — the exact shape the tool produces.
let patch = build_patch(&json!({
"status": "done",
"evidence": ["posted review on PR #5"]
}))
.unwrap();
let res = apply(&location, &id, patch);
assert!(!res.is_error, "successful move/update must not be an error");
// The board reflects the move + evidence.
let card = ops::list(&location)
.unwrap()
.cards
.into_iter()
.find(|c| c.id == id)
.unwrap();
assert_eq!(card.status, TaskCardStatus::Done);
assert!(card.evidence.iter().any(|e| e.contains("posted review")));
}
#[test]
fn apply_on_unknown_id_returns_error() {
let dir = tempfile::tempdir().unwrap();
let location = BoardLocation::Thread {
workspace_dir: dir.path().to_path_buf(),
thread_id: TASK_SOURCES_THREAD_ID.to_string(),
};
let patch = build_patch(&json!({ "status": "blocked", "blocker": "no channel" })).unwrap();
let res = apply(&location, "task-does-not-exist", patch);
assert!(res.is_error, "missing card must surface as a tool error");
}
@@ -148,6 +148,12 @@ named = [
# downstream agents — the orchestrator coordinates, it doesn't
# touch files itself.
"todowrite",
# `update_task` moves/updates a specific task card by id on a target board
# (defaults to the proactive `task-sources` board) — so the orchestrator can
# advance the task it's working: → in_progress when it starts, → done with
# evidence when finished, or → blocked with a reason when stuck. Complements
# `todowrite`, which only touches the current thread's board.
"update_task",
"plan_exit",
# Skill chaining: let an in-flight autonomous skill (e.g.
# `github-issue-crusher` after the draft PR is open) spawn another
+65 -1
View File
@@ -41,6 +41,19 @@ use compatible_types::{
ResponsesRequest, StreamChunkResponse, StreamingToolCall, ToolCall,
};
/// `frequency_penalty` applied to streaming chat-completions requests.
///
/// Autoregressive models have a self-reinforcing bias toward repeating spans
/// already in their context; with no penalty a momentary repeat can spiral into
/// the same line emitted until the output-token cap (degenerate decoding). A
/// small positive penalty damps that loop without harming coherence. Carried on
/// the streaming path (where those loops occur — long autonomous turns) and
/// retried without it if a strict provider rejects it; the buffered
/// non-streaming fallback omits it for maximum compatibility. Skipped in
/// serialisation when `None` so providers that don't accept the field are
/// unaffected.
const CHAT_FREQUENCY_PENALTY: f64 = 0.3;
/// A provider that speaks the OpenAI-compatible chat completions API.
/// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot,
/// Synthetic, `OpenCode` Zen, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc.
@@ -929,6 +942,23 @@ impl OpenAiCompatibleProvider {
Self::is_native_tool_schema_unsupported(reqwest::StatusCode::BAD_REQUEST, error)
}
/// Detect a provider rejecting the `frequency_penalty` sampling field. Some
/// strict OpenAI-compatible backends 400 on unknown params; when this fires
/// the caller retries once with the field omitted (mirrors the no-tools
/// retry). String-based because the streamed transport error surfaces the
/// API error body.
fn err_indicates_frequency_penalty_unsupported(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("frequency_penalty")
&& (lower.contains("unsupported")
|| lower.contains("unknown")
|| lower.contains("unrecognized")
|| lower.contains("not supported")
|| lower.contains("does not support")
|| lower.contains("invalid")
|| lower.contains("unexpected"))
}
/// Detect a 404 whose body says the model is completion-only and cannot be
/// served from `/v1/chat/completions` (OpenAI: "This is not a chat model
/// and thus not supported in the v1/chat/completions endpoint. Did you
@@ -1914,6 +1944,7 @@ impl Provider for OpenAiCompatibleProvider {
include_usage: true,
}),
options: self.build_ollama_options(),
frequency_penalty: Some(CHAT_FREQUENCY_PENALTY),
};
let stream_dump_seq = reserve_dump_seq();
dump_prompt_if_enabled(&self.name, model, stream_dump_seq, &native_request);
@@ -1956,6 +1987,31 @@ impl Provider for OpenAiCompatibleProvider {
);
}
}
} else if Self::err_indicates_frequency_penalty_unsupported(&err_str) {
// Symmetric to the no-tools retry: a strict provider that
// 400s on `frequency_penalty` should degrade gracefully
// rather than fail the whole chat path.
log::info!(
"[stream] {} rejected frequency_penalty — retrying streaming without it",
self.name,
);
let retry_request = NativeChatRequest {
frequency_penalty: None,
..native_request.clone()
};
match self
.stream_native_chat(credential, &retry_request, tx, stream_dump_seq)
.await
{
Ok(resp) => return Ok(resp),
Err(retry_err) => {
log::warn!(
"[stream] {} retry without frequency_penalty also failed, falling back to non-streaming: {}",
self.name,
retry_err
);
}
}
} else {
log::warn!(
"[stream] {} streaming chat failed, falling back to non-streaming: {}",
@@ -1963,7 +2019,9 @@ impl Provider for OpenAiCompatibleProvider {
err
);
}
// Fall through to the non-streaming path below.
// Fall through to the non-streaming path below. The
// non-streaming request below omits `frequency_penalty` so a
// provider that rejected it (streaming or not) still succeeds.
}
}
}
@@ -1985,6 +2043,12 @@ impl Provider for OpenAiCompatibleProvider {
thread_id,
stream_options: None,
options: self.build_ollama_options(),
// The buffered (non-streaming) path is the fallback / non-streaming
// provider path — omit `frequency_penalty` here for maximum
// compatibility (a provider that rejects it still succeeds). The
// streaming path above carries it (where degenerate repetition loops
// actually occur) and retries without it on rejection.
frequency_penalty: None,
};
let dump_seq = reserve_dump_seq();
dump_prompt_if_enabled(&self.name, model, dump_seq, &native_request);
@@ -66,6 +66,7 @@ fn native_request_emits_thread_id_when_present() {
thread_id: Some("thread-abc".to_string()),
stream_options: None,
options: None,
frequency_penalty: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(
@@ -84,6 +85,7 @@ fn native_request_emits_thread_id_when_present() {
thread_id: None,
stream_options: None,
options: None,
frequency_penalty: None,
};
let json_no_thread = serde_json::to_value(&req_no_thread).unwrap();
assert!(
@@ -92,6 +94,58 @@ fn native_request_emits_thread_id_when_present() {
);
}
#[test]
fn native_request_serializes_frequency_penalty_only_when_set() {
let base = super::NativeChatRequest {
model: "kimi".to_string(),
messages: Vec::new(),
temperature: Some(0.7),
stream: Some(false),
tools: None,
tool_choice: None,
thread_id: None,
stream_options: None,
options: None,
frequency_penalty: Some(0.3),
};
let json = serde_json::to_value(&base).unwrap();
assert_eq!(
json.get("frequency_penalty")
.and_then(serde_json::Value::as_f64),
Some(0.3),
"a set frequency_penalty must be forwarded to damp repetition loops"
);
let none = super::NativeChatRequest {
frequency_penalty: None,
..base
};
let json_none = serde_json::to_value(&none).unwrap();
assert!(
json_none.get("frequency_penalty").is_none(),
"absent frequency_penalty must be omitted so providers that reject it are unaffected"
);
}
#[test]
fn detects_frequency_penalty_rejection_for_retry() {
use super::OpenAiCompatibleProvider as P;
// Strict providers that 400 on the field → retry without it.
assert!(P::err_indicates_frequency_penalty_unsupported(
"400 Bad Request: unknown parameter 'frequency_penalty'"
));
assert!(P::err_indicates_frequency_penalty_unsupported(
"frequency_penalty is not supported by this model"
));
// Unrelated errors, or the field merely mentioned, must NOT trigger a retry.
assert!(!P::err_indicates_frequency_penalty_unsupported(
"rate limit exceeded"
));
assert!(!P::err_indicates_frequency_penalty_unsupported(
"applied frequency_penalty 0.3"
));
}
/// Streaming responses arrive without `usage` unless the request asks
/// for `stream_options.include_usage = true` (OpenAI spec). Without it
/// the OpenHuman backend's `openhuman.billing` block also never lands,
@@ -111,6 +165,7 @@ fn streaming_request_sets_stream_options_include_usage() {
include_usage: true,
}),
options: None,
frequency_penalty: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(
@@ -133,6 +188,7 @@ fn non_streaming_request_omits_stream_options() {
thread_id: None,
stream_options: None,
options: None,
frequency_penalty: None,
};
let json = serde_json::to_value(&req).unwrap();
assert!(
@@ -155,6 +211,7 @@ fn ollama_options_num_ctx_serializes_correctly() {
options: Some(super::compatible_types::OllamaOptions {
num_ctx: Some(32768),
}),
frequency_penalty: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(
@@ -176,6 +233,7 @@ fn ollama_options_none_is_omitted() {
thread_id: None,
stream_options: None,
options: None,
frequency_penalty: None,
};
let json = serde_json::to_value(&req).unwrap();
assert!(
@@ -756,6 +814,7 @@ async fn streaming_chat_config_rejection_propagates_error_without_sentry_report(
include_usage: true,
}),
options: None,
frequency_penalty: None,
};
let (delta_tx, _delta_rx) = tokio::sync::mpsc::channel(8);
@@ -187,6 +187,13 @@ pub(crate) struct NativeChatRequest {
/// `num_ctx` override. Ignored (skipped) for non-Ollama providers.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) options: Option<OllamaOptions>,
/// OpenAI-compatible `frequency_penalty`. Set to a small positive value on
/// real requests to damp degenerate repetition loops — without it a model
/// that starts repeating a line keeps emitting it until the output-token
/// cap (self-reinforcing decoding). Skipped when `None` so providers that
/// don't accept it are unaffected.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) frequency_penalty: Option<f64>,
}
/// Ollama-specific request options passed in the `options` field.
+9
View File
@@ -19,6 +19,15 @@ use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use uuid::Uuid;
/// Thread id backing the personal "user tasks" kanban board.
///
/// Must match the frontend constant in `app/src/services/api/todosApi.ts`.
/// This is the board the kanban UI renders as the user's working lane and that
/// the task dispatcher's board poller executes **agent-assigned** cards on
/// (tasks approved out of the proactive `task-sources` inbox). Manually-created
/// human cards on this board carry no `assigned_agent` and are never auto-run.
pub const USER_TASKS_THREAD_ID: &str = "user-tasks";
use super::store::{global_scratch_store, ScratchTodoStore};
/// Serialise scratch CRUD so each public op's load → mutate → save
+5
View File
@@ -158,6 +158,11 @@ pub fn all_tools_with_runtime(
// build-mode pass. The plan→build mode switch itself is a
// follow-up; the tool emits a stable marker today.
Box::new(TodoTool::new()),
// Move/update a specific task card by id on a target board (defaults to
// the proactive `task-sources` board) — lets the agent advance the task
// it's working (in_progress / done+evidence / blocked+reason) from any
// thread, complementing `todo` which only touches the current thread.
Box::new(UpdateTaskTool::new()),
Box::new(PlanExitTool::new()),
// Skill chaining: let an in-flight autonomous skill (e.g.
// `github-issue-crusher`) kick off another bundled skill_run as a