mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent_orchestration): cancel detached sub-agents when parent thread is closed (#3742)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'أُلغيت',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2817,10 +2818,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'يعمل…',
|
||||
'conversations.subagent.noOutputYet': 'لا يوجد ناتج بعد',
|
||||
'conversations.subagent.close': 'إغلاق',
|
||||
'conversations.subagent.cancel': 'إلغاء المهمة',
|
||||
'conversations.subagent.cancelling': 'جارٍ الإلغاء…',
|
||||
'conversations.subagent.cancelFailed': 'تعذّر إلغاء المهمة. حاول مرة أخرى.',
|
||||
'conversations.subagent.statusRunning': 'قيد التشغيل',
|
||||
'conversations.subagent.statusCompleted': 'اكتمل',
|
||||
'conversations.subagent.statusFailed': 'فشل',
|
||||
'conversations.subagent.statusAwaitingUser': 'في انتظار المستخدم',
|
||||
'conversations.subagent.statusCancelled': 'أُلغيت',
|
||||
'conversations.agentTaskInsights.title': 'رؤى مهام الوكيل',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'مصدر عملية الوكيل',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'الخطوات',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'বাতিল হয়েছে',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2876,10 +2877,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'কাজ করছে…',
|
||||
'conversations.subagent.noOutputYet': 'এখনও কোনো আউটপুট নেই',
|
||||
'conversations.subagent.close': 'বন্ধ করুন',
|
||||
'conversations.subagent.cancel': 'কাজ বাতিল করুন',
|
||||
'conversations.subagent.cancelling': 'বাতিল করা হচ্ছে…',
|
||||
'conversations.subagent.cancelFailed': 'কাজটি বাতিল করা যায়নি। আবার চেষ্টা করুন।',
|
||||
'conversations.subagent.statusRunning': 'চলছে',
|
||||
'conversations.subagent.statusCompleted': 'সম্পন্ন',
|
||||
'conversations.subagent.statusFailed': 'ব্যর্থ',
|
||||
'conversations.subagent.statusAwaitingUser': 'ব্যবহারকারীর অপেক্ষায়',
|
||||
'conversations.subagent.statusCancelled': 'বাতিল হয়েছে',
|
||||
'conversations.agentTaskInsights.title': 'এজেন্ট টাস্ক অন্তর্দৃষ্টি',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'এজেন্ট প্রক্রিয়া উৎস',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'ধাপসমূহ',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Abgebrochen',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2946,10 +2947,15 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'Arbeitet…',
|
||||
'conversations.subagent.noOutputYet': 'Noch keine Ausgabe',
|
||||
'conversations.subagent.close': 'Schließen',
|
||||
'conversations.subagent.cancel': 'Aufgabe abbrechen',
|
||||
'conversations.subagent.cancelling': 'Wird abgebrochen…',
|
||||
'conversations.subagent.cancelFailed':
|
||||
'Aufgabe konnte nicht abgebrochen werden. Bitte erneut versuchen.',
|
||||
'conversations.subagent.statusRunning': 'läuft',
|
||||
'conversations.subagent.statusCompleted': 'abgeschlossen',
|
||||
'conversations.subagent.statusFailed': 'fehlgeschlagen',
|
||||
'conversations.subagent.statusAwaitingUser': 'warte auf Benutzer',
|
||||
'conversations.subagent.statusCancelled': 'abgebrochen',
|
||||
'conversations.agentTaskInsights.title': 'Agenten-Aufgabeneinblicke',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'Agentenprozess-Quelle',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Schritte',
|
||||
|
||||
@@ -3399,6 +3399,9 @@ const en: TranslationMap = {
|
||||
'conversations.subagent.working': 'Working…',
|
||||
'conversations.subagent.noOutputYet': 'No output yet',
|
||||
'conversations.subagent.close': 'Close',
|
||||
'conversations.subagent.cancel': 'Cancel task',
|
||||
'conversations.subagent.cancelling': 'Cancelling…',
|
||||
'conversations.subagent.cancelFailed': "Couldn't cancel the task. Try again.",
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
@@ -3410,6 +3413,7 @@ const en: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Cancelled',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -3419,6 +3423,7 @@ const en: TranslationMap = {
|
||||
'conversations.subagent.statusCompleted': 'completed',
|
||||
'conversations.subagent.statusFailed': 'failed',
|
||||
'conversations.subagent.statusAwaitingUser': 'awaiting user',
|
||||
'conversations.subagent.statusCancelled': 'cancelled',
|
||||
'conversations.agentTaskInsights.title': 'Agentic task insights',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'Agent Process Source',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Steps',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Cancelado',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2926,10 +2927,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'Trabajando…',
|
||||
'conversations.subagent.noOutputYet': 'Aún no hay resultados',
|
||||
'conversations.subagent.close': 'Cerrar',
|
||||
'conversations.subagent.cancel': 'Cancelar tarea',
|
||||
'conversations.subagent.cancelling': 'Cancelando…',
|
||||
'conversations.subagent.cancelFailed': 'No se pudo cancelar la tarea. Inténtalo de nuevo.',
|
||||
'conversations.subagent.statusRunning': 'en ejecución',
|
||||
'conversations.subagent.statusCompleted': 'completado',
|
||||
'conversations.subagent.statusFailed': 'fallido',
|
||||
'conversations.subagent.statusAwaitingUser': 'esperando al usuario',
|
||||
'conversations.subagent.statusCancelled': 'cancelado',
|
||||
'conversations.agentTaskInsights.title': 'Información de tareas del agente',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'Fuente del proceso del agente',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Pasos',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Annulé',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2941,10 +2942,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'En cours…',
|
||||
'conversations.subagent.noOutputYet': 'Aucun résultat pour l’instant',
|
||||
'conversations.subagent.close': 'Fermer',
|
||||
'conversations.subagent.cancel': 'Annuler la tâche',
|
||||
'conversations.subagent.cancelling': 'Annulation…',
|
||||
'conversations.subagent.cancelFailed': "Impossible d'annuler la tâche. Réessayez.",
|
||||
'conversations.subagent.statusRunning': 'en cours',
|
||||
'conversations.subagent.statusCompleted': 'terminé',
|
||||
'conversations.subagent.statusFailed': 'échoué',
|
||||
'conversations.subagent.statusAwaitingUser': "en attente de l'utilisateur",
|
||||
'conversations.subagent.statusCancelled': 'annulé',
|
||||
'conversations.agentTaskInsights.title': "Aperçu des tâches de l'agent",
|
||||
'conversations.agentTaskInsights.processSourceTitle': "Source du processus de l'agent",
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Étapes',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'रद्द किया गया',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2877,10 +2878,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'काम कर रहा है…',
|
||||
'conversations.subagent.noOutputYet': 'अभी तक कोई आउटपुट नहीं',
|
||||
'conversations.subagent.close': 'बंद करें',
|
||||
'conversations.subagent.cancel': 'कार्य रद्द करें',
|
||||
'conversations.subagent.cancelling': 'रद्द किया जा रहा है…',
|
||||
'conversations.subagent.cancelFailed': 'कार्य रद्द नहीं किया जा सका। पुनः प्रयास करें।',
|
||||
'conversations.subagent.statusRunning': 'चल रहा है',
|
||||
'conversations.subagent.statusCompleted': 'पूर्ण',
|
||||
'conversations.subagent.statusFailed': 'विफल',
|
||||
'conversations.subagent.statusAwaitingUser': 'उपयोगकर्ता की प्रतीक्षा',
|
||||
'conversations.subagent.statusCancelled': 'रद्द किया गया',
|
||||
'conversations.agentTaskInsights.title': 'एजेंट कार्य अंतर्दृष्टि',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'एजेंट प्रक्रिया स्रोत',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'चरण',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Dibatalkan',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2881,10 +2882,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'Bekerja…',
|
||||
'conversations.subagent.noOutputYet': 'Belum ada keluaran',
|
||||
'conversations.subagent.close': 'Tutup',
|
||||
'conversations.subagent.cancel': 'Batalkan tugas',
|
||||
'conversations.subagent.cancelling': 'Membatalkan…',
|
||||
'conversations.subagent.cancelFailed': 'Tidak dapat membatalkan tugas. Coba lagi.',
|
||||
'conversations.subagent.statusRunning': 'berjalan',
|
||||
'conversations.subagent.statusCompleted': 'selesai',
|
||||
'conversations.subagent.statusFailed': 'gagal',
|
||||
'conversations.subagent.statusAwaitingUser': 'menunggu pengguna',
|
||||
'conversations.subagent.statusCancelled': 'dibatalkan',
|
||||
'conversations.agentTaskInsights.title': 'Wawasan tugas agen',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'Sumber proses agen',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Langkah',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Annullato',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2920,10 +2921,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'In corso…',
|
||||
'conversations.subagent.noOutputYet': 'Ancora nessun output',
|
||||
'conversations.subagent.close': 'Chiudi',
|
||||
'conversations.subagent.cancel': 'Annulla attività',
|
||||
'conversations.subagent.cancelling': 'Annullamento…',
|
||||
'conversations.subagent.cancelFailed': "Impossibile annullare l'attività. Riprova.",
|
||||
'conversations.subagent.statusRunning': 'in esecuzione',
|
||||
'conversations.subagent.statusCompleted': 'completato',
|
||||
'conversations.subagent.statusFailed': 'non riuscito',
|
||||
'conversations.subagent.statusAwaitingUser': "in attesa dell'utente",
|
||||
'conversations.subagent.statusCancelled': 'annullato',
|
||||
'conversations.agentTaskInsights.title': 'Approfondimenti attività agente',
|
||||
'conversations.agentTaskInsights.processSourceTitle': "Origine del processo dell'agente",
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Passaggi',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': '취소됨',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2852,10 +2853,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': '작업 중…',
|
||||
'conversations.subagent.noOutputYet': '아직 출력이 없습니다',
|
||||
'conversations.subagent.close': '닫기',
|
||||
'conversations.subagent.cancel': '작업 취소',
|
||||
'conversations.subagent.cancelling': '취소 중…',
|
||||
'conversations.subagent.cancelFailed': '작업을 취소할 수 없습니다. 다시 시도하세요.',
|
||||
'conversations.subagent.statusRunning': '실행 중',
|
||||
'conversations.subagent.statusCompleted': '완료됨',
|
||||
'conversations.subagent.statusFailed': '실패',
|
||||
'conversations.subagent.statusAwaitingUser': '사용자 대기 중',
|
||||
'conversations.subagent.statusCancelled': '취소됨',
|
||||
'conversations.agentTaskInsights.title': '에이전트 작업 인사이트',
|
||||
'conversations.agentTaskInsights.processSourceTitle': '에이전트 프로세스 소스',
|
||||
'conversations.agentTaskInsights.stepsHeading': '단계',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Anulowano',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2909,10 +2910,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'Pracuje…',
|
||||
'conversations.subagent.noOutputYet': 'Brak wyników',
|
||||
'conversations.subagent.close': 'Zamknij',
|
||||
'conversations.subagent.cancel': 'Anuluj zadanie',
|
||||
'conversations.subagent.cancelling': 'Anulowanie…',
|
||||
'conversations.subagent.cancelFailed': 'Nie udało się anulować zadania. Spróbuj ponownie.',
|
||||
'conversations.subagent.statusRunning': 'w toku',
|
||||
'conversations.subagent.statusCompleted': 'zakończono',
|
||||
'conversations.subagent.statusFailed': 'niepowodzenie',
|
||||
'conversations.subagent.statusAwaitingUser': 'oczekiwanie na użytkownika',
|
||||
'conversations.subagent.statusCancelled': 'anulowano',
|
||||
'conversations.agentTaskInsights.title': 'Wgląd w zadania agenta',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'Źródło procesu agenta',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Kroki',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Cancelado',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2926,10 +2927,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'Trabalhando…',
|
||||
'conversations.subagent.noOutputYet': 'Ainda sem resultado',
|
||||
'conversations.subagent.close': 'Fechar',
|
||||
'conversations.subagent.cancel': 'Cancelar tarefa',
|
||||
'conversations.subagent.cancelling': 'Cancelando…',
|
||||
'conversations.subagent.cancelFailed': 'Não foi possível cancelar a tarefa. Tente novamente.',
|
||||
'conversations.subagent.statusRunning': 'em execução',
|
||||
'conversations.subagent.statusCompleted': 'concluído',
|
||||
'conversations.subagent.statusFailed': 'falhou',
|
||||
'conversations.subagent.statusAwaitingUser': 'aguardando usuário',
|
||||
'conversations.subagent.statusCancelled': 'cancelado',
|
||||
'conversations.agentTaskInsights.title': 'Insights de tarefas do agente',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'Fonte do processo do agente',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Etapas',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': 'Отменено',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2899,10 +2900,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': 'Выполняется…',
|
||||
'conversations.subagent.noOutputYet': 'Пока нет результата',
|
||||
'conversations.subagent.close': 'Закрыть',
|
||||
'conversations.subagent.cancel': 'Отменить задачу',
|
||||
'conversations.subagent.cancelling': 'Отмена…',
|
||||
'conversations.subagent.cancelFailed': 'Не удалось отменить задачу. Попробуйте снова.',
|
||||
'conversations.subagent.statusRunning': 'выполняется',
|
||||
'conversations.subagent.statusCompleted': 'завершено',
|
||||
'conversations.subagent.statusFailed': 'ошибка',
|
||||
'conversations.subagent.statusAwaitingUser': 'ожидание пользователя',
|
||||
'conversations.subagent.statusCancelled': 'отменено',
|
||||
'conversations.agentTaskInsights.title': 'Сведения о задачах агента',
|
||||
'conversations.agentTaskInsights.processSourceTitle': 'Источник процесса агента',
|
||||
'conversations.agentTaskInsights.stepsHeading': 'Шаги',
|
||||
|
||||
@@ -14,6 +14,7 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.statusCancelled': '已取消',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
@@ -2737,10 +2738,14 @@ const messages: TranslationMap = {
|
||||
'conversations.subagent.working': '处理中…',
|
||||
'conversations.subagent.noOutputYet': '暂无输出',
|
||||
'conversations.subagent.close': '关闭',
|
||||
'conversations.subagent.cancel': '取消任务',
|
||||
'conversations.subagent.cancelling': '正在取消…',
|
||||
'conversations.subagent.cancelFailed': '无法取消任务。请重试。',
|
||||
'conversations.subagent.statusRunning': '运行中',
|
||||
'conversations.subagent.statusCompleted': '已完成',
|
||||
'conversations.subagent.statusFailed': '失败',
|
||||
'conversations.subagent.statusAwaitingUser': '等待用户',
|
||||
'conversations.subagent.statusCancelled': '已取消',
|
||||
'conversations.agentTaskInsights.title': '智能体任务洞察',
|
||||
'conversations.agentTaskInsights.processSourceTitle': '智能体处理来源',
|
||||
'conversations.agentTaskInsights.stepsHeading': '步骤',
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../services/analytics';
|
||||
import { applyOpenRouterFreeModels } from '../services/api/openrouterFreeModels';
|
||||
import { subagentApi } from '../services/api/subagentApi';
|
||||
import { threadApi } from '../services/api/threadApi';
|
||||
import { chatCancel, chatSend, useRustChat } from '../services/chatService';
|
||||
import { callCoreRpc } from '../services/coreRpcClient';
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
beginInferenceTurn,
|
||||
clearRuntimeForThread,
|
||||
fetchAndHydrateTurnState,
|
||||
markSubagentCancelled,
|
||||
registerParallelRequest,
|
||||
setTaskBoardForThread,
|
||||
setToolTimelineForThread,
|
||||
@@ -2591,8 +2593,28 @@ const Conversations = ({
|
||||
}}
|
||||
/>
|
||||
<SubagentDrawer
|
||||
key={openSubagentTaskId ?? 'none'}
|
||||
subagent={openSubagentEntry?.subagent ?? null}
|
||||
status={openSubagentEntry?.status}
|
||||
onCancel={
|
||||
openSubagentEntry?.subagent && selectedThreadId
|
||||
? async () => {
|
||||
const taskId = openSubagentEntry.subagent!.taskId;
|
||||
const result = await subagentApi.cancel(taskId);
|
||||
// Only flip the row when something was actually aborted — a
|
||||
// cancelled=false result means the run already finished/unknown,
|
||||
// and overwriting its real terminal state would hide it. No
|
||||
// terminal socket event arrives for an aborted run, so the
|
||||
// optimistic mark is what surfaces the cancellation (the notice
|
||||
// itself reaches chat via the idle-gated delivery path).
|
||||
if (result.cancelled) {
|
||||
dispatch(
|
||||
markSubagentCancelled({ threadId: selectedThreadId, taskId: result.taskId })
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={() => setOpenSubagentTaskId(null)}
|
||||
/>
|
||||
<AgentProcessSourcePanel
|
||||
|
||||
@@ -43,6 +43,9 @@ export function agentNameTone(status: ToolTimelineEntryStatus | undefined): stri
|
||||
return 'text-coral-600 dark:text-coral-300';
|
||||
case 'awaiting_user':
|
||||
return 'animate-pulse text-amber-600 dark:text-amber-300';
|
||||
case 'cancelled':
|
||||
// Cancelled — terminal, so muted but NOT pulsing (it isn't in progress).
|
||||
return 'text-stone-400 dark:text-neutral-500';
|
||||
default:
|
||||
// running / unknown — in progress: muted + blinking.
|
||||
return 'animate-pulse text-stone-400 dark:text-neutral-500';
|
||||
|
||||
@@ -58,7 +58,8 @@ type StatusLabelKey =
|
||||
| 'conversations.backgroundTasks.statusRunning'
|
||||
| 'conversations.backgroundTasks.statusDone'
|
||||
| 'conversations.backgroundTasks.statusFailed'
|
||||
| 'conversations.backgroundTasks.statusNeedsYou';
|
||||
| 'conversations.backgroundTasks.statusNeedsYou'
|
||||
| 'conversations.backgroundTasks.statusCancelled';
|
||||
|
||||
function statusStyle(status: ToolTimelineEntryStatus): {
|
||||
dot: string;
|
||||
@@ -84,6 +85,12 @@ function statusStyle(status: ToolTimelineEntryStatus): {
|
||||
labelKey: 'conversations.backgroundTasks.statusNeedsYou',
|
||||
pill: 'text-blue-700 dark:text-blue-300',
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
dot: 'bg-stone-400 dark:bg-neutral-500',
|
||||
labelKey: 'conversations.backgroundTasks.statusCancelled',
|
||||
pill: 'text-stone-600 dark:text-neutral-300',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
dot: 'bg-sage-500',
|
||||
|
||||
@@ -50,7 +50,12 @@ function transcriptFromMessages(messages: ThreadMessage[]): {
|
||||
function statusTone(status: ToolTimelineEntryStatus | undefined): {
|
||||
dot: string;
|
||||
pill: string;
|
||||
label: 'statusRunning' | 'statusCompleted' | 'statusFailed' | 'statusAwaitingUser';
|
||||
label:
|
||||
| 'statusRunning'
|
||||
| 'statusCompleted'
|
||||
| 'statusFailed'
|
||||
| 'statusAwaitingUser'
|
||||
| 'statusCancelled';
|
||||
} {
|
||||
if (status === 'success') {
|
||||
return {
|
||||
@@ -66,6 +71,13 @@ function statusTone(status: ToolTimelineEntryStatus | undefined): {
|
||||
label: 'statusFailed',
|
||||
};
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return {
|
||||
dot: 'bg-stone-400 dark:bg-neutral-500',
|
||||
pill: 'bg-stone-100 dark:bg-neutral-700/40 text-stone-600 dark:text-neutral-300',
|
||||
label: 'statusCancelled',
|
||||
};
|
||||
}
|
||||
if (status === 'awaiting_user') {
|
||||
return {
|
||||
dot: 'bg-amber-400 animate-pulse',
|
||||
@@ -101,14 +113,28 @@ function formatElapsed(ms: number): string {
|
||||
export function SubagentDrawer({
|
||||
subagent,
|
||||
status,
|
||||
onCancel,
|
||||
onClose,
|
||||
}: {
|
||||
subagent: SubagentActivity | null;
|
||||
/** Lifecycle status of the owning timeline row (running/success/error). */
|
||||
status?: ToolTimelineEntryStatus;
|
||||
/**
|
||||
* Cancel this still-running detached sub-agent. When provided and the run is
|
||||
* running, a "Cancel task" affordance is shown. The parent owns the actual
|
||||
* abort + chat delivery (via `subagentApi.cancel`); the drawer only manages
|
||||
* the in-flight / error UI and closes on success. Rejecting surfaces an error.
|
||||
*/
|
||||
onCancel?: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
// Cancel-in-flight + last-error state for the "Cancel task" affordance.
|
||||
// The parent keys this drawer by task id, so a different sub-agent remounts
|
||||
// with fresh state — no effect-driven reset needed (which would trip the
|
||||
// repo's `react-hooks/set-state-in-effect` rule).
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelError, setCancelError] = useState(false);
|
||||
|
||||
// Close on Escape for keyboard parity with the backdrop click.
|
||||
useEffect(() => {
|
||||
@@ -162,7 +188,25 @@ export function SubagentDrawer({
|
||||
if (!subagent) return null;
|
||||
|
||||
const tone = statusTone(status);
|
||||
const isRunning = status !== 'success' && status !== 'error';
|
||||
const isRunning = status !== 'success' && status !== 'error' && status !== 'cancelled';
|
||||
// The "Cancel task" CTA is only meaningful for a live, still-running run the
|
||||
// parent gave us a cancel handler for.
|
||||
const canCancel = status === 'running' && Boolean(onCancel);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!onCancel || cancelling) return;
|
||||
setCancelling(true);
|
||||
setCancelError(false);
|
||||
try {
|
||||
await onCancel();
|
||||
// Success: the parent flips the row to cancelled and the notice rides the
|
||||
// idle-delivery path into chat — close the drawer.
|
||||
onClose();
|
||||
} catch {
|
||||
setCancelling(false);
|
||||
setCancelError(true);
|
||||
}
|
||||
};
|
||||
// Only trust the fetched transcript when it belongs to the current worker.
|
||||
const fetchedForCurrent =
|
||||
fetched && workerThreadId && fetched.workerThreadId === workerThreadId ? fetched : null;
|
||||
@@ -219,6 +263,18 @@ export function SubagentDrawer({
|
||||
{subagent.mode ? <span>{subagent.mode}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{canCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
data-testid="subagent-cancel"
|
||||
className="shrink-0 rounded-full border border-coral-200 px-2.5 py-1 text-xs font-medium text-coral-600 hover:bg-coral-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-coral-500/40 dark:text-coral-300 dark:hover:bg-coral-500/10">
|
||||
{cancelling
|
||||
? t('conversations.subagent.cancelling')
|
||||
: t('conversations.subagent.cancel')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -227,6 +283,14 @@ export function SubagentDrawer({
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
{cancelError ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="subagent-cancel-error"
|
||||
className="border-b border-coral-200 bg-coral-50 px-4 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('conversations.subagent.cancelFailed')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Body — a parent↔subagent conversation: the parent's delegation
|
||||
prompt opens it, then the sub-agent replies as one chronological
|
||||
|
||||
@@ -27,6 +27,12 @@ describe('agentNameTone', () => {
|
||||
it('treats an unknown status as in-progress', () => {
|
||||
expect(agentNameTone(undefined)).toContain('animate-pulse');
|
||||
});
|
||||
|
||||
it('renders a cancelled agent muted and static (terminal, not pulsing)', () => {
|
||||
const tone = agentNameTone('cancelled');
|
||||
expect(tone).not.toContain('animate-pulse');
|
||||
expect(tone).toContain('stone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentTimelineRail', () => {
|
||||
|
||||
@@ -128,12 +128,13 @@ describe('BackgroundProcessesPanel', () => {
|
||||
expect(screen.getByText(/No background tasks in this chat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders every status variant (running / done / failed / needs-you)', () => {
|
||||
it('renders every status variant (running / done / failed / needs-you / cancelled)', () => {
|
||||
const all: BackgroundProcess[] = [
|
||||
{ taskId: 'r', name: 'R', goal: 'g', status: 'running', toolCount: 2 },
|
||||
{ taskId: 'd', name: 'D', goal: 'g', status: 'success', toolCount: 2 },
|
||||
{ taskId: 'e', name: 'E', goal: 'g', status: 'error', toolCount: 2 },
|
||||
{ taskId: 'a', name: 'A', goal: 'g', status: 'awaiting_user', toolCount: 2 },
|
||||
{ taskId: 'c', name: 'C', goal: 'g', status: 'cancelled', toolCount: 2 },
|
||||
];
|
||||
render(
|
||||
<BackgroundProcessesPanel open processes={all} onClose={vi.fn()} onOpenProcess={vi.fn()} />
|
||||
@@ -142,6 +143,7 @@ describe('BackgroundProcessesPanel', () => {
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs you')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancelled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders singular tool-call wording, step count, and suppresses an empty goal', () => {
|
||||
|
||||
@@ -173,4 +173,73 @@ describe('SubagentDrawer', () => {
|
||||
await userEvent.click(screen.getByText('✕'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the Cancel task CTA only while running and with an onCancel handler', () => {
|
||||
// No handler → no CTA.
|
||||
const { rerender } = render(
|
||||
<SubagentDrawer subagent={activity()} status="running" onClose={() => {}} />
|
||||
);
|
||||
expect(screen.queryByTestId('subagent-cancel')).toBeNull();
|
||||
|
||||
// Handler present but already finished → no CTA.
|
||||
rerender(
|
||||
<SubagentDrawer
|
||||
subagent={activity()}
|
||||
status="success"
|
||||
onCancel={vi.fn()}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('subagent-cancel')).toBeNull();
|
||||
|
||||
// Running + handler → CTA shown.
|
||||
rerender(
|
||||
<SubagentDrawer
|
||||
subagent={activity()}
|
||||
status="running"
|
||||
onCancel={vi.fn()}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('subagent-cancel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('cancels via onCancel, then closes on success', async () => {
|
||||
const onCancel = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SubagentDrawer
|
||||
subagent={activity()}
|
||||
status="running"
|
||||
onCancel={onCancel}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('subagent-cancel'));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByTestId('subagent-cancel-error')).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces an error and stays open when cancel fails', async () => {
|
||||
const onCancel = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SubagentDrawer
|
||||
subagent={activity()}
|
||||
status="running"
|
||||
onCancel={onCancel}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('subagent-cancel'));
|
||||
await waitFor(() => expect(screen.getByTestId('subagent-cancel-error')).toBeTruthy());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the cancelled status label', () => {
|
||||
render(<SubagentDrawer subagent={activity()} status="cancelled" onClose={() => {}} />);
|
||||
// Case-robust: the label may be rendered as "Cancelled" or "cancelled".
|
||||
expect(screen.getByTestId('subagent-drawer').textContent?.toLowerCase()).toContain('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
import { subagentApi } from './subagentApi';
|
||||
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
const mockCall = vi.mocked(callCoreRpc);
|
||||
|
||||
describe('subagentApi.cancel', () => {
|
||||
beforeEach(() => {
|
||||
mockCall.mockReset();
|
||||
});
|
||||
|
||||
it('calls openhuman.subagent_cancel with the trimmed taskId', async () => {
|
||||
mockCall.mockResolvedValue({ cancelled: true, taskId: 'sub-1' });
|
||||
const result = await subagentApi.cancel(' sub-1 ');
|
||||
expect(mockCall).toHaveBeenCalledWith({
|
||||
method: 'openhuman.subagent_cancel',
|
||||
params: { taskId: 'sub-1' },
|
||||
});
|
||||
expect(result).toEqual({ cancelled: true, taskId: 'sub-1' });
|
||||
});
|
||||
|
||||
it('forwards a non-empty reason and omits a blank one', async () => {
|
||||
mockCall.mockResolvedValue({ cancelled: true, taskId: 'sub-1' });
|
||||
await subagentApi.cancel('sub-1', ' user changed their mind ');
|
||||
expect(mockCall).toHaveBeenCalledWith({
|
||||
method: 'openhuman.subagent_cancel',
|
||||
params: { taskId: 'sub-1', reason: 'user changed their mind' },
|
||||
});
|
||||
|
||||
mockCall.mockClear();
|
||||
await subagentApi.cancel('sub-1', ' ');
|
||||
expect(mockCall).toHaveBeenCalledWith({
|
||||
method: 'openhuman.subagent_cancel',
|
||||
params: { taskId: 'sub-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty taskId without calling core', async () => {
|
||||
await expect(subagentApi.cancel(' ')).rejects.toThrow('taskId is required');
|
||||
expect(mockCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through cancelled=false (already finished / unknown)', async () => {
|
||||
mockCall.mockResolvedValue({ cancelled: false, taskId: 'sub-x' });
|
||||
const result = await subagentApi.cancel('sub-x');
|
||||
expect(result.cancelled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Frontend client for user-driven control of detached background sub-agents
|
||||
* (`openhuman.subagent_cancel`). Backs the "Cancel task" affordance in the
|
||||
* background-tasks drawer: aborting a still-running detached sub-agent and
|
||||
* surfacing a "cancelled" notice back in the parent chat.
|
||||
*
|
||||
* The wire payload is camelCase on both directions, so this client only types
|
||||
* the shape and forwards `taskId` (+ optional `reason`).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('subagentApi');
|
||||
|
||||
/** Result of a cancel request. Mirrors the Rust handler payload. */
|
||||
export interface SubagentCancelResult {
|
||||
/** True if a running sub-agent was aborted; false if it was already done/unknown. */
|
||||
cancelled: boolean;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export const subagentApi = {
|
||||
/**
|
||||
* Cancel a still-running detached background sub-agent by its spawn task id.
|
||||
* Resolves with `cancelled: false` (not an error) when the sub-agent already
|
||||
* finished or the id is unknown.
|
||||
*/
|
||||
cancel: async (taskId: string, reason?: string): Promise<SubagentCancelResult> => {
|
||||
const id = taskId.trim();
|
||||
if (!id) throw new Error('subagentApi.cancel: taskId is required');
|
||||
const params: Record<string, unknown> = { taskId: id };
|
||||
const trimmedReason = reason?.trim();
|
||||
if (trimmedReason) params.reason = trimmedReason;
|
||||
log('cancel taskId=%s', id);
|
||||
const result = await callCoreRpc<SubagentCancelResult>({
|
||||
method: 'openhuman.subagent_cancel',
|
||||
params,
|
||||
});
|
||||
log('cancel received cancelled=%s', result.cancelled);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import reducer, {
|
||||
appendSubagentStreamDelta,
|
||||
markSubagentCancelled,
|
||||
recordSubagentTranscriptTool,
|
||||
resolveSubagentTranscriptTool,
|
||||
setToolTimelineForThread,
|
||||
@@ -190,3 +191,25 @@ describe('subagent transcript reducers', () => {
|
||||
expect(transcriptOf(unknownRow)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markSubagentCancelled', () => {
|
||||
it('flips the matching row (and its subagent) to cancelled, located by taskId', () => {
|
||||
const state = reducer(
|
||||
withSubagentRow(),
|
||||
markSubagentCancelled({ threadId: THREAD, taskId: 'sub-1' })
|
||||
);
|
||||
const entry = state.toolTimelineByThread[THREAD][0];
|
||||
expect(entry.status).toBe('cancelled');
|
||||
expect(entry.subagent?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('is a no-op for an unknown taskId or thread', () => {
|
||||
const base = withSubagentRow();
|
||||
expect(reducer(base, markSubagentCancelled({ threadId: THREAD, taskId: 'nope' }))).toEqual(
|
||||
base
|
||||
);
|
||||
expect(
|
||||
reducer(base, markSubagentCancelled({ threadId: 'other-thread', taskId: 'sub-1' }))
|
||||
).toEqual(base);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,12 @@ import { resetUserScopedState } from './resetActions';
|
||||
|
||||
const turnStateLog = debug('chatRuntime.turnState');
|
||||
|
||||
export type ToolTimelineEntryStatus = 'running' | 'success' | 'error' | 'awaiting_user';
|
||||
export type ToolTimelineEntryStatus =
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'awaiting_user'
|
||||
| 'cancelled';
|
||||
|
||||
export interface InferenceStatus {
|
||||
phase: 'thinking' | 'tool_use' | 'subagent';
|
||||
@@ -400,8 +405,9 @@ function timelineStatusFromRun(status: AgentRun['status']): ToolTimelineEntrySta
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'cancelled';
|
||||
case 'failed':
|
||||
case 'interrupted':
|
||||
return 'error';
|
||||
case 'awaiting_user':
|
||||
@@ -514,6 +520,19 @@ const chatRuntimeSlice = createSlice({
|
||||
clearToolTimelineForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.toolTimelineByThread[action.payload.threadId];
|
||||
},
|
||||
/**
|
||||
* Optimistically mark a detached background sub-agent as cancelled after the
|
||||
* user confirms a cancel via `openhuman.subagent_cancel`. The aborted run
|
||||
* emits no terminal socket event, so without this the row would keep showing
|
||||
* "running" forever. Located by the subagent's stable `taskId`.
|
||||
*/
|
||||
markSubagentCancelled: (state, action: PayloadAction<{ threadId: string; taskId: string }>) => {
|
||||
const { threadId, taskId } = action.payload;
|
||||
const entry = state.toolTimelineByThread[threadId]?.find(e => e.subagent?.taskId === taskId);
|
||||
if (!entry) return;
|
||||
entry.status = 'cancelled';
|
||||
if (entry.subagent) entry.subagent.status = 'cancelled';
|
||||
},
|
||||
/**
|
||||
* Append a streamed `subagent_text_delta` / `subagent_thinking_delta`
|
||||
* chunk to the ordered transcript of the matching subagent row. The row
|
||||
@@ -909,6 +928,7 @@ export const {
|
||||
clearParallelRequest,
|
||||
setToolTimelineForThread,
|
||||
clearToolTimelineForThread,
|
||||
markSubagentCancelled,
|
||||
appendSubagentStreamDelta,
|
||||
recordSubagentTranscriptTool,
|
||||
resolveSubagentTranscriptTool,
|
||||
|
||||
@@ -309,6 +309,10 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
// Git-worktree isolation manager — list / status / diff / remove worker worktrees (#3376)
|
||||
controllers
|
||||
.extend(crate::openhuman::agent_orchestration::all_worktree_registered_controllers());
|
||||
// User-driven cancel of detached background sub-agents (#3711)
|
||||
controllers.extend(
|
||||
crate::openhuman::agent_orchestration::all_subagent_control_registered_controllers(),
|
||||
);
|
||||
controllers
|
||||
}
|
||||
|
||||
@@ -445,6 +449,9 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(crate::openhuman::agent_orchestration::all_agent_team_controller_schemas());
|
||||
// Git-worktree isolation manager (#3376)
|
||||
schemas.extend(crate::openhuman::agent_orchestration::all_worktree_controller_schemas());
|
||||
// User-driven cancel of detached background sub-agents (#3711)
|
||||
schemas
|
||||
.extend(crate::openhuman::agent_orchestration::all_subagent_control_controller_schemas());
|
||||
schemas
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! tagged by its sub-agent process id. This module owns only the queue + the
|
||||
//! notice formatting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// One finished background sub-agent's deliverable result.
|
||||
@@ -26,14 +26,57 @@ pub struct CompletedBackgroundAgent {
|
||||
pub parent_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
static QUEUE: OnceLock<Mutex<HashMap<String, Vec<CompletedBackgroundAgent>>>> = OnceLock::new();
|
||||
/// Upper bound on the cancelled-thread tombstone set. A thread id is a one-shot
|
||||
/// UUID, so only the *recently* cancelled threads can still be racing a late
|
||||
/// completion; older tombstones are evicted in insertion order. 512 is far more
|
||||
/// than the number of sub-agents that could realistically be mid-flight when a
|
||||
/// batch of threads is deleted.
|
||||
const CANCELLED_TOMBSTONE_CAP: usize = 512;
|
||||
|
||||
fn queue() -> &'static Mutex<HashMap<String, Vec<CompletedBackgroundAgent>>> {
|
||||
QUEUE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
/// Shared state behind a single mutex so the cancellation check in
|
||||
/// [`record_completion`] is atomic against the tombstone+sweep in
|
||||
/// [`discard_for_thread`] — otherwise the cooperative-abort race could enqueue a
|
||||
/// completion for a thread that was just deleted (see issue #3711 review).
|
||||
#[derive(Default)]
|
||||
struct QueueState {
|
||||
/// Finished results awaiting idle delivery, keyed by `parent_session`.
|
||||
pending: HashMap<String, Vec<CompletedBackgroundAgent>>,
|
||||
/// Threads whose sub-agents were cancelled because the thread was
|
||||
/// deleted/purged. A completion that lands here *after* the discard sweep
|
||||
/// (Tokio `abort()` is cooperative — a task already past its last `.await`
|
||||
/// still runs to `record_completion`) is dropped instead of delivered into
|
||||
/// a thread that no longer exists.
|
||||
cancelled_threads: HashSet<String>,
|
||||
/// Insertion order for `cancelled_threads`, used to bound the set.
|
||||
cancelled_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl QueueState {
|
||||
/// Tombstone `thread_id` so any straggler completion for it is dropped.
|
||||
fn tombstone(&mut self, thread_id: &str) {
|
||||
if self.cancelled_threads.insert(thread_id.to_string()) {
|
||||
self.cancelled_order.push_back(thread_id.to_string());
|
||||
while self.cancelled_order.len() > CANCELLED_TOMBSTONE_CAP {
|
||||
if let Some(evicted) = self.cancelled_order.pop_front() {
|
||||
self.cancelled_threads.remove(&evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static QUEUE: OnceLock<Mutex<QueueState>> = OnceLock::new();
|
||||
|
||||
fn queue() -> &'static Mutex<QueueState> {
|
||||
QUEUE.get_or_init(|| Mutex::new(QueueState::default()))
|
||||
}
|
||||
|
||||
/// Record a finished background sub-agent for later idle delivery, keyed by
|
||||
/// `parent_session`. Idempotent on `task_id` within a session.
|
||||
///
|
||||
/// Drops the result outright if its parent thread has been tombstoned by
|
||||
/// [`discard_for_thread`] — closing the race where a detached sub-agent finishes
|
||||
/// (and records) concurrently with its parent thread being deleted.
|
||||
pub fn record_completion(
|
||||
parent_session: impl Into<String>,
|
||||
task_id: impl Into<String>,
|
||||
@@ -48,10 +91,20 @@ pub fn record_completion(
|
||||
summary: summary.into(),
|
||||
parent_thread_id,
|
||||
};
|
||||
let mut map = queue()
|
||||
let mut state = queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned");
|
||||
let pending = map.entry(parent_session).or_default();
|
||||
if let Some(thread_id) = entry.parent_thread_id.as_deref() {
|
||||
if state.cancelled_threads.contains(thread_id) {
|
||||
log::debug!(
|
||||
"[background_completions] dropping completion task_id={} for cancelled thread_id={}",
|
||||
entry.task_id,
|
||||
thread_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let pending = state.pending.entry(parent_session).or_default();
|
||||
if pending.iter().any(|c| c.task_id == entry.task_id) {
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +116,7 @@ pub fn has_pending(parent_session: &str) -> bool {
|
||||
queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned")
|
||||
.pending
|
||||
.get(parent_session)
|
||||
.is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
@@ -72,6 +126,7 @@ pub fn pending_count(parent_session: &str) -> usize {
|
||||
queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned")
|
||||
.pending
|
||||
.get(parent_session)
|
||||
.map_or(0, Vec::len)
|
||||
}
|
||||
@@ -83,10 +138,55 @@ pub fn take_pending(parent_session: &str) -> Vec<CompletedBackgroundAgent> {
|
||||
queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned")
|
||||
.pending
|
||||
.remove(parent_session)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Drop every queued completion whose `parent_thread_id` is `thread_id`, across
|
||||
/// **all** sessions, and **tombstone** the thread so any straggler completion
|
||||
/// that records *after* this sweep (the cooperative-abort race) is dropped by
|
||||
/// [`record_completion`] rather than delivered into a thread that no longer
|
||||
/// exists. Called when that thread is deleted. Returns the number of queued
|
||||
/// completions removed.
|
||||
pub fn discard_for_thread(thread_id: &str) -> usize {
|
||||
let mut state = queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned");
|
||||
state.tombstone(thread_id);
|
||||
let mut removed = 0;
|
||||
for pending in state.pending.values_mut() {
|
||||
let before = pending.len();
|
||||
pending.retain(|c| c.parent_thread_id.as_deref() != Some(thread_id));
|
||||
removed += before - pending.len();
|
||||
}
|
||||
// Drop now-empty session buckets so the map doesn't accumulate keys.
|
||||
state.pending.retain(|_, v| !v.is_empty());
|
||||
let sessions_left = state.pending.len();
|
||||
log::debug!(
|
||||
"[background_completions] discard_for_thread thread_id={} removed={} sessions_left={}",
|
||||
thread_id,
|
||||
removed,
|
||||
sessions_left
|
||||
);
|
||||
removed
|
||||
}
|
||||
|
||||
/// Wipe every queued completion across all sessions. Called on a full thread
|
||||
/// purge. Tombstones are left intact (the per-thread protection set by
|
||||
/// [`discard_for_thread`]); the purge path tombstones each in-flight sub-agent's
|
||||
/// thread before calling this, so stragglers are still dropped. Returns the
|
||||
/// number of queued completions removed.
|
||||
pub fn clear_all() -> usize {
|
||||
let mut state = queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned");
|
||||
let removed: usize = state.pending.values().map(Vec::len).sum();
|
||||
state.pending.clear();
|
||||
log::debug!("[background_completions] clear_all removed={}", removed);
|
||||
removed
|
||||
}
|
||||
|
||||
/// The thread id to deliver a batch into — the first record that carries one.
|
||||
pub fn batch_thread_id(completed: &[CompletedBackgroundAgent]) -> Option<String> {
|
||||
completed.iter().find_map(|c| c.parent_thread_id.clone())
|
||||
@@ -126,6 +226,17 @@ pub fn build_batched_notice(completed: &[CompletedBackgroundAgent]) -> Option<St
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::MutexGuard;
|
||||
|
||||
/// Serializes every test that touches the global [`QUEUE`]. We reuse the
|
||||
/// crate-wide `TEST_ENV_LOCK` because `clear_all` is also reachable from the
|
||||
/// `threads::ops` purge test (which holds the same lock); a module-local
|
||||
/// mutex wouldn't prevent that cross-module race.
|
||||
fn test_guard() -> MutexGuard<'static, ()> {
|
||||
crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn c(task: &str, agent: &str, summary: &str) -> CompletedBackgroundAgent {
|
||||
CompletedBackgroundAgent {
|
||||
@@ -138,6 +249,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn record_and_drain_is_session_scoped_and_batches() {
|
||||
let _guard = test_guard();
|
||||
let s = "sess-batch-A";
|
||||
record_completion(s, "sub-1", "researcher", "eiffel", Some("thread-A".into()));
|
||||
record_completion(s, "sub-2", "researcher", "liberty", Some("thread-A".into()));
|
||||
@@ -163,6 +275,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn record_is_idempotent_on_task_id() {
|
||||
let _guard = test_guard();
|
||||
let s = "sess-dupe";
|
||||
record_completion(s, "sub-1", "researcher", "first", None);
|
||||
record_completion(s, "sub-1", "researcher", "second", None);
|
||||
@@ -196,4 +309,96 @@ mod tests {
|
||||
fn empty_batch_is_none() {
|
||||
assert_eq!(build_batched_notice(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_for_thread_removes_matching_across_sessions() {
|
||||
let _guard = test_guard();
|
||||
// Two sessions, each with a completion for the doomed thread plus one
|
||||
// for a thread that must survive.
|
||||
record_completion(
|
||||
"sess-d1",
|
||||
"sub-a",
|
||||
"researcher",
|
||||
"x",
|
||||
Some("thread-DEL".into()),
|
||||
);
|
||||
record_completion(
|
||||
"sess-d1",
|
||||
"sub-b",
|
||||
"researcher",
|
||||
"y",
|
||||
Some("thread-KEEP".into()),
|
||||
);
|
||||
record_completion(
|
||||
"sess-d2",
|
||||
"sub-c",
|
||||
"researcher",
|
||||
"z",
|
||||
Some("thread-DEL".into()),
|
||||
);
|
||||
// Headless completion (no parent thread) must survive.
|
||||
record_completion("sess-d2", "sub-d", "researcher", "w", None);
|
||||
|
||||
let removed = discard_for_thread("thread-DEL");
|
||||
assert_eq!(removed, 2, "both thread-DEL completions removed");
|
||||
|
||||
// thread-KEEP survives in sess-d1; sess-d2 keeps only the headless one.
|
||||
assert_eq!(pending_count("sess-d1"), 1);
|
||||
let d1 = take_pending("sess-d1");
|
||||
assert_eq!(d1[0].task_id, "sub-b");
|
||||
|
||||
assert_eq!(pending_count("sess-d2"), 1);
|
||||
let d2 = take_pending("sess-d2");
|
||||
assert_eq!(d2[0].task_id, "sub-d");
|
||||
|
||||
// Idempotent: nothing left to discard.
|
||||
assert_eq!(discard_for_thread("thread-DEL"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_after_discard_is_dropped_by_tombstone() {
|
||||
let _guard = test_guard();
|
||||
// Deleting the thread tombstones it...
|
||||
discard_for_thread("thread-race");
|
||||
// ...so a straggler completion that records *after* the sweep (the
|
||||
// cooperative-abort race) is dropped rather than queued.
|
||||
record_completion(
|
||||
"sess-race",
|
||||
"sub-late",
|
||||
"researcher",
|
||||
"stale",
|
||||
Some("thread-race".into()),
|
||||
);
|
||||
assert_eq!(
|
||||
pending_count("sess-race"),
|
||||
0,
|
||||
"late completion for a cancelled thread must be dropped"
|
||||
);
|
||||
// A completion for a different, live thread still records normally.
|
||||
record_completion(
|
||||
"sess-race",
|
||||
"sub-ok",
|
||||
"researcher",
|
||||
"fresh",
|
||||
Some("thread-live-race".into()),
|
||||
);
|
||||
assert_eq!(pending_count("sess-race"), 1);
|
||||
take_pending("sess-race");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_all_empties_the_queue() {
|
||||
let _guard = test_guard();
|
||||
record_completion("sess-c1", "sub-1", "researcher", "a", Some("t1".into()));
|
||||
record_completion("sess-c2", "sub-2", "researcher", "b", None);
|
||||
|
||||
let removed = clear_all();
|
||||
assert!(
|
||||
removed >= 2,
|
||||
"clear_all should report at least the two just queued, got {removed}"
|
||||
);
|
||||
assert!(!has_pending("sess-c1"));
|
||||
assert!(!has_pending("sess-c2"));
|
||||
assert_eq!(clear_all(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod command_center;
|
||||
mod ops;
|
||||
pub(crate) mod parent_context;
|
||||
pub mod running_subagents;
|
||||
pub mod subagent_control;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
pub mod workflow_runs;
|
||||
@@ -26,6 +27,10 @@ pub use command_center::{
|
||||
all_command_center_controller_schemas, all_command_center_registered_controllers,
|
||||
};
|
||||
pub use ops::{AgentOrchestrationSession, OrchestrationError};
|
||||
pub use subagent_control::{
|
||||
all_controller_schemas as all_subagent_control_controller_schemas,
|
||||
all_registered_controllers as all_subagent_control_registered_controllers,
|
||||
};
|
||||
pub use types::{
|
||||
AgentMessage, AgentOrchestrationEvent, AgentSnapshot, AgentStatus, CloseAgentRequest,
|
||||
FollowUpRequest, MessageAgentRequest, ResumeAgentRequest, SpawnAgentRequest,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! grow unbounded if a parent never waits (the Codex "spawn-slot leak" failure
|
||||
//! mode — openai/codex#18335).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -48,9 +48,12 @@ impl SubagentStatus {
|
||||
}
|
||||
|
||||
struct RunningSubagentEntry {
|
||||
#[allow(dead_code)]
|
||||
agent_id: String,
|
||||
parent_session: String,
|
||||
/// Parent chat thread that spawned this sub-agent, captured at registration.
|
||||
/// `None` for a headless spawn with no originating thread. Used to abort the
|
||||
/// sub-agent when its parent thread is deleted (see [`cancel_for_thread`]).
|
||||
parent_thread_id: Option<String>,
|
||||
run_queue: Arc<RunQueue>,
|
||||
abort: AbortHandle,
|
||||
status: watch::Receiver<SubagentStatus>,
|
||||
@@ -90,6 +93,7 @@ pub fn register(
|
||||
task_id: String,
|
||||
agent_id: String,
|
||||
parent_session: String,
|
||||
parent_thread_id: Option<String>,
|
||||
run_queue: Arc<RunQueue>,
|
||||
abort: AbortHandle,
|
||||
status: watch::Receiver<SubagentStatus>,
|
||||
@@ -97,6 +101,7 @@ pub fn register(
|
||||
let entry = RunningSubagentEntry {
|
||||
agent_id,
|
||||
parent_session,
|
||||
parent_thread_id,
|
||||
run_queue,
|
||||
abort,
|
||||
status,
|
||||
@@ -231,6 +236,41 @@ pub async fn wait(
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata captured when a sub-agent is cancelled, so the caller can surface
|
||||
/// the cancellation back in the parent chat (record a "cancelled" completion
|
||||
/// for idle-gated delivery).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CancelledSubagent {
|
||||
pub agent_id: String,
|
||||
pub parent_session: String,
|
||||
pub parent_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Abort and drop the sub-agent with `task_id`, returning its metadata so the
|
||||
/// caller can deliver a "cancelled" notice into the parent chat. Returns `None`
|
||||
/// if no such sub-agent is registered (already finished, or unknown id).
|
||||
///
|
||||
/// Unlike [`close`], this is keyed by `task_id` alone with no parent-session
|
||||
/// ownership check — it backs the user-facing "Cancel" affordance, and the
|
||||
/// desktop user owns every sub-agent in their own core.
|
||||
pub fn cancel_by_task(task_id: &str) -> Option<CancelledSubagent> {
|
||||
let mut map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let entry = map.remove(task_id)?;
|
||||
entry.abort.abort();
|
||||
log::debug!(
|
||||
"[running_subagents] cancel_by_task task_id={} agent_id={} parent_thread_id={:?} live_entries={}",
|
||||
task_id,
|
||||
entry.agent_id,
|
||||
entry.parent_thread_id,
|
||||
map.len()
|
||||
);
|
||||
Some(CancelledSubagent {
|
||||
agent_id: entry.agent_id,
|
||||
parent_session: entry.parent_session,
|
||||
parent_thread_id: entry.parent_thread_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Abort a running sub-agent and drop its registry entry. Kept for a future
|
||||
/// `close_agent` tool; the abort handle is stored at spawn time.
|
||||
pub fn close(task_id: &str, parent_session: &str) -> bool {
|
||||
@@ -245,6 +285,59 @@ pub fn close(task_id: &str, parent_session: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Abort and drop every running sub-agent whose parent chat thread is
|
||||
/// `thread_id`. Called when that thread is deleted so detached children don't
|
||||
/// keep running (and later try to deliver) against a thread that no longer
|
||||
/// exists. Returns the number of sub-agents cancelled.
|
||||
pub fn cancel_for_thread(thread_id: &str) -> usize {
|
||||
let mut map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let to_cancel: Vec<String> = map
|
||||
.iter()
|
||||
.filter(|(_, e)| e.parent_thread_id.as_deref() == Some(thread_id))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in &to_cancel {
|
||||
if let Some(entry) = map.remove(id) {
|
||||
entry.abort.abort();
|
||||
}
|
||||
}
|
||||
let count = to_cancel.len();
|
||||
log::debug!(
|
||||
"[running_subagents] cancel_for_thread thread_id={} cancelled={} live_entries={}",
|
||||
thread_id,
|
||||
count,
|
||||
map.len()
|
||||
);
|
||||
count
|
||||
}
|
||||
|
||||
/// Abort and drop **every** registered sub-agent. Called on a full thread purge
|
||||
/// where no parent thread survives. Returns the **distinct parent thread ids**
|
||||
/// that had sub-agents, so the purge path can tombstone them in
|
||||
/// [`super::background_completions`] and drop any straggler completion that wins
|
||||
/// the cooperative-abort race. Headless sub-agents (no parent thread) are still
|
||||
/// aborted but contribute no id.
|
||||
pub fn cancel_all() -> Vec<String> {
|
||||
let mut map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let count = map.len();
|
||||
let mut thread_ids: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for (_, entry) in map.drain() {
|
||||
entry.abort.abort();
|
||||
if let Some(thread_id) = entry.parent_thread_id {
|
||||
if seen.insert(thread_id.clone()) {
|
||||
thread_ids.push(thread_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::debug!(
|
||||
"[running_subagents] cancel_all cancelled={} distinct_threads={}",
|
||||
count,
|
||||
thread_ids.len()
|
||||
);
|
||||
thread_ids
|
||||
}
|
||||
|
||||
fn prune(task_id: &str) {
|
||||
registry()
|
||||
.lock()
|
||||
@@ -262,6 +355,19 @@ fn now_ms() -> u64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::MutexGuard;
|
||||
|
||||
/// Serializes every test that touches the global [`REGISTRY`]. We reuse the
|
||||
/// crate-wide `TEST_ENV_LOCK` (rather than a module-local mutex) because the
|
||||
/// destructive `cancel_all` path is also reachable from the `threads::ops`
|
||||
/// tests — those hold the same lock, so this prevents a purge there from
|
||||
/// wiping entries a test here is mid-way through.
|
||||
fn test_guard() -> MutexGuard<'static, ()> {
|
||||
// Recover from a poisoned guard so one panicking test doesn't cascade.
|
||||
crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn dummy_abort() -> AbortHandle {
|
||||
tokio::spawn(async {}).abort_handle()
|
||||
@@ -273,12 +379,24 @@ mod tests {
|
||||
task_id: &str,
|
||||
parent_session: &str,
|
||||
rq: Arc<RunQueue>,
|
||||
) -> watch::Sender<SubagentStatus> {
|
||||
register_test_with_thread(task_id, parent_session, None, rq)
|
||||
}
|
||||
|
||||
/// Like [`register_test`] but lets a test set the parent thread id so it can
|
||||
/// exercise [`cancel_for_thread`].
|
||||
fn register_test_with_thread(
|
||||
task_id: &str,
|
||||
parent_session: &str,
|
||||
parent_thread_id: Option<&str>,
|
||||
rq: Arc<RunQueue>,
|
||||
) -> watch::Sender<SubagentStatus> {
|
||||
let (tx, rx) = status_channel();
|
||||
register(
|
||||
task_id.into(),
|
||||
"researcher".into(),
|
||||
parent_session.into(),
|
||||
parent_thread_id.map(Into::into),
|
||||
rq,
|
||||
dummy_abort(),
|
||||
rx,
|
||||
@@ -288,6 +406,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_pushes_into_the_subagent_queue() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let tx = register_test("task-steer", "session-A", rq.clone());
|
||||
|
||||
@@ -323,6 +442,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_rejects_cross_parent_and_unknown() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let _tx = register_test("task-owned", "session-owner", rq);
|
||||
|
||||
@@ -351,6 +471,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_after_terminal_is_rejected() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let tx = register_test("task-term", "session-A", rq);
|
||||
let _ = tx.send(SubagentStatus::Failed {
|
||||
@@ -366,6 +487,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_returns_completion_once_published() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let tx = register_test("task-wait", "session-A", rq);
|
||||
|
||||
@@ -398,6 +520,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_times_out_and_leaves_entry_intact() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let _tx = register_test("task-slow", "session-A", rq);
|
||||
|
||||
@@ -420,4 +543,97 @@ mod tests {
|
||||
.is_ok());
|
||||
prune("task-slow");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_for_thread_aborts_only_matching_entries() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let _a = register_test_with_thread("task-tA-1", "session-A", Some("thread-X"), rq.clone());
|
||||
let _b = register_test_with_thread("task-tA-2", "session-A", Some("thread-X"), rq.clone());
|
||||
// Different thread — must survive.
|
||||
let _c = register_test_with_thread("task-tB", "session-A", Some("thread-Y"), rq.clone());
|
||||
// Headless (no parent thread) — must survive.
|
||||
let _d = register_test_with_thread("task-headless", "session-A", None, rq);
|
||||
|
||||
let cancelled = cancel_for_thread("thread-X");
|
||||
assert_eq!(cancelled, 2, "both thread-X entries should be cancelled");
|
||||
|
||||
// The two cancelled entries are gone (steer can't find them).
|
||||
assert_eq!(
|
||||
steer("task-tA-1", "session-A", "x".into(), QueueMode::Steer).await,
|
||||
Err(SteerError::Unknown)
|
||||
);
|
||||
assert_eq!(
|
||||
steer("task-tA-2", "session-A", "x".into(), QueueMode::Steer).await,
|
||||
Err(SteerError::Unknown)
|
||||
);
|
||||
|
||||
// Non-matching entries stay live and steerable.
|
||||
assert!(steer("task-tB", "session-A", "x".into(), QueueMode::Steer)
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(
|
||||
steer("task-headless", "session-A", "x".into(), QueueMode::Steer)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// Idempotent: a second pass cancels nothing.
|
||||
assert_eq!(cancel_for_thread("thread-X"), 0);
|
||||
|
||||
prune("task-tB");
|
||||
prune("task-headless");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_by_task_returns_metadata_and_removes_entry() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let _tx =
|
||||
register_test_with_thread("task-cbt", "session-Z", Some("thread-cbt"), rq.clone());
|
||||
|
||||
let meta = cancel_by_task("task-cbt").expect("known task should cancel");
|
||||
assert_eq!(meta.agent_id, "researcher");
|
||||
assert_eq!(meta.parent_session, "session-Z");
|
||||
assert_eq!(meta.parent_thread_id.as_deref(), Some("thread-cbt"));
|
||||
|
||||
// Entry is gone — steer can no longer find it, and a second cancel is a no-op.
|
||||
assert_eq!(
|
||||
steer("task-cbt", "session-Z", "x".into(), QueueMode::Steer).await,
|
||||
Err(SteerError::Unknown)
|
||||
);
|
||||
assert!(cancel_by_task("task-cbt").is_none());
|
||||
// Unknown ids are simply None.
|
||||
assert!(cancel_by_task("never-existed").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_all_clears_everything() {
|
||||
let _guard = test_guard();
|
||||
let rq = RunQueue::new();
|
||||
let _a = register_test_with_thread("task-all-1", "session-A", Some("thread-1"), rq.clone());
|
||||
// Headless (no parent thread) — aborted, but contributes no thread id.
|
||||
let _b = register_test_with_thread("task-all-2", "session-B", None, rq);
|
||||
|
||||
let cancelled_threads = cancel_all();
|
||||
assert!(
|
||||
cancelled_threads.contains(&"thread-1".to_string()),
|
||||
"cancel_all should report the parent thread of the cancelled sub-agent"
|
||||
);
|
||||
assert!(
|
||||
!cancelled_threads.iter().any(|t| t.is_empty()),
|
||||
"headless sub-agents must not contribute an id"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
steer("task-all-1", "session-A", "x".into(), QueueMode::Steer).await,
|
||||
Err(SteerError::Unknown)
|
||||
);
|
||||
assert_eq!(
|
||||
steer("task-all-2", "session-B", "x".into(), QueueMode::Steer).await,
|
||||
Err(SteerError::Unknown)
|
||||
);
|
||||
// Registry is empty now.
|
||||
assert!(cancel_all().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Controller schema + JSON-RPC dispatcher for user-driven control of detached
|
||||
//! background sub-agents (`spawn_async_subagent`).
|
||||
//!
|
||||
//! Exposes `openhuman.subagent_cancel`: the frontend "Cancel" affordance in the
|
||||
//! background-tasks drawer calls this to abort a still-running detached
|
||||
//! sub-agent. Cancellation aborts the in-flight task via the
|
||||
//! [`super::running_subagents`] registry and records a "cancelled" pseudo-
|
||||
//! completion so the existing idle-gated delivery path
|
||||
//! ([`super::background_delivery`]) surfaces it back in the parent chat.
|
||||
//!
|
||||
//! This is the *manual* counterpart to the *automatic* thread-close
|
||||
//! cancellation in [`crate::openhuman::threads`]: there the thread is being
|
||||
//! deleted (so nothing is delivered and the thread is tombstoned), whereas here
|
||||
//! the thread stays alive and the user expects to see that their sub-agent was
|
||||
//! cancelled.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::agent_orchestration::{background_completions, running_subagents};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
/// Controller schemas exposed for detached sub-agent control.
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![schema_for("subagent_cancel")]
|
||||
}
|
||||
|
||||
/// Registered controllers (schema + handler) for detached sub-agent control.
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![RegisteredController {
|
||||
schema: schema_for("subagent_cancel"),
|
||||
handler: handle_subagent_cancel,
|
||||
}]
|
||||
}
|
||||
|
||||
fn schema_for(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"subagent_cancel" => ControllerSchema {
|
||||
namespace: "subagent",
|
||||
function: "cancel",
|
||||
description: "Cancel a still-running detached background sub-agent by its spawn task \
|
||||
id. Aborts the in-flight run and posts a 'cancelled' notice back into \
|
||||
the parent chat thread. No-op (cancelled=false) if the sub-agent already \
|
||||
finished or the id is unknown.",
|
||||
inputs: vec![
|
||||
required_str(
|
||||
"taskId",
|
||||
"Spawn task id (`sub-…`) of the background sub-agent.",
|
||||
),
|
||||
optional_str(
|
||||
"reason",
|
||||
"Optional reason, included in the cancelled notice shown in chat.",
|
||||
),
|
||||
],
|
||||
outputs: vec![json_output(
|
||||
"result",
|
||||
"{ cancelled: bool, taskId: string } — cancelled=false if nothing was running.",
|
||||
)],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "subagent",
|
||||
function: "unknown",
|
||||
description: "unknown subagent control function",
|
||||
inputs: vec![],
|
||||
outputs: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_subagent_cancel(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let cid = new_correlation_id();
|
||||
let task_id = require_str(¶ms, "taskId")?;
|
||||
let reason = opt_str(¶ms, "reason");
|
||||
log::debug!(
|
||||
target: "subagent_control_rpc",
|
||||
"[subagent_control_rpc][{cid}] cancel.entry task_id={task_id}"
|
||||
);
|
||||
|
||||
let cancelled = match running_subagents::cancel_by_task(&task_id) {
|
||||
Some(meta) => {
|
||||
let summary = match reason.as_deref().map(str::trim).filter(|r| !r.is_empty()) {
|
||||
Some(r) => format!(
|
||||
"You cancelled this background sub-agent before it finished. Reason: {r}"
|
||||
),
|
||||
None => {
|
||||
"You cancelled this background sub-agent before it finished.".to_string()
|
||||
}
|
||||
};
|
||||
// The thread is still alive (unlike the delete path), so we
|
||||
// record a completion that flows through the same idle-gated
|
||||
// delivery and surfaces the cancellation in chat.
|
||||
background_completions::record_completion(
|
||||
meta.parent_session,
|
||||
&task_id,
|
||||
meta.agent_id,
|
||||
summary,
|
||||
meta.parent_thread_id,
|
||||
);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
target: "subagent_control_rpc",
|
||||
"[subagent_control_rpc][{cid}] cancel.done task_id={task_id} cancelled={cancelled}"
|
||||
);
|
||||
to_json(json!({ "cancelled": cancelled, "taskId": task_id }))
|
||||
})
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(value: T) -> Result<Value, String> {
|
||||
RpcOutcome::new(value, vec![]).into_cli_compatible_json()
|
||||
}
|
||||
|
||||
fn new_correlation_id() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string()[..8].to_string()
|
||||
}
|
||||
|
||||
fn required_str(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::String,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_str(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment,
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Json,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a required non-empty string param, **trimmed**, or an RPC-facing
|
||||
/// error. Trimming matters for `taskId`: a whitespace-padded id would otherwise
|
||||
/// pass validation yet never match the registry key in `cancel_by_task`.
|
||||
fn require_str(params: &Map<String, Value>, key: &str) -> Result<String, String> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("missing required param: {key}"))
|
||||
}
|
||||
|
||||
/// Extract an optional non-empty string param.
|
||||
fn opt_str(params: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registered_controllers_match_schemas() {
|
||||
let schemas = all_controller_schemas();
|
||||
let registered = all_registered_controllers();
|
||||
assert_eq!(schemas.len(), registered.len());
|
||||
assert_eq!(schemas.len(), 1);
|
||||
assert_eq!(schema_for("subagent_cancel").namespace, "subagent");
|
||||
assert_eq!(schema_for("subagent_cancel").function, "cancel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_str_rejects_blank_and_missing() {
|
||||
let mut params = Map::new();
|
||||
assert!(require_str(¶ms, "taskId").is_err());
|
||||
params.insert("taskId".into(), json!(" "));
|
||||
assert!(require_str(¶ms, "taskId").is_err());
|
||||
params.insert("taskId".into(), json!("sub-1"));
|
||||
assert_eq!(require_str(¶ms, "taskId").unwrap(), "sub-1");
|
||||
// Whitespace-padded ids are trimmed so they match the registry key.
|
||||
params.insert("taskId".into(), json!(" sub-1 "));
|
||||
assert_eq!(require_str(¶ms, "taskId").unwrap(), "sub-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_unknown_task_is_a_noop_false() {
|
||||
let _lock = crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let mut params = Map::new();
|
||||
params.insert("taskId".into(), json!("sub-does-not-exist"));
|
||||
let out = handle_subagent_cancel(params).await.expect("handler ok");
|
||||
// RpcOutcome wraps the payload under `data`.
|
||||
let cancelled = out
|
||||
.get("data")
|
||||
.and_then(|d| d.get("cancelled"))
|
||||
.or_else(|| out.get("cancelled"))
|
||||
.and_then(Value::as_bool);
|
||||
assert_eq!(cancelled, Some(false));
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,18 @@ impl Tool for SpawnAsyncSubagentTool {
|
||||
// finished result can be delivered back into it as a system turn.
|
||||
let background_parent_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
// Kept on this side (the closure moves its own clone) so the registry
|
||||
// entry knows which parent thread owns this sub-agent — that's how
|
||||
// `cancel_for_thread` aborts it when the thread is deleted.
|
||||
let register_parent_thread_id = background_parent_thread_id.clone();
|
||||
// Lifecycle-critical wiring: log the parent-thread binding so the
|
||||
// thread-close cancellation path (`cancel_for_thread`) is grep-friendly.
|
||||
log::debug!(
|
||||
"[spawn_async_subagent] register task_id={} parent_session={} parent_thread_id={}",
|
||||
task_id,
|
||||
parent_session,
|
||||
register_parent_thread_id.as_deref().unwrap_or("none")
|
||||
);
|
||||
let background_prompt = add_background_contract(&prompt);
|
||||
|
||||
let join = tokio::spawn(async move {
|
||||
@@ -362,6 +374,7 @@ impl Tool for SpawnAsyncSubagentTool {
|
||||
task_id.clone(),
|
||||
definition.id.clone(),
|
||||
parent_session.clone(),
|
||||
register_parent_thread_id,
|
||||
steer_queue,
|
||||
join.abort_handle(),
|
||||
status_rx,
|
||||
|
||||
@@ -514,6 +514,24 @@ pub async fn thread_delete(
|
||||
// session for the now-deleted thread could linger and try to
|
||||
// append to a thread index row that no longer exists.
|
||||
web_channel::invalidate_thread_sessions(&request.thread_id).await;
|
||||
// Cancel any detached sub-agents this thread spawned BEFORE clearing their
|
||||
// queued results: abort the in-flight ones first so a child can't record a
|
||||
// completion in the gap between the two calls, then discard anything already
|
||||
// queued for delivery. Both target a thread that's being deleted, so there's
|
||||
// nowhere left to deliver to — abort + cleanup is the whole behavior.
|
||||
let cancelled = crate::openhuman::agent_orchestration::running_subagents::cancel_for_thread(
|
||||
&request.thread_id,
|
||||
);
|
||||
let discarded =
|
||||
crate::openhuman::agent_orchestration::background_completions::discard_for_thread(
|
||||
&request.thread_id,
|
||||
);
|
||||
log::debug!(
|
||||
"[threads] thread_delete thread_id={} cancelled_subagents={} discarded_completions={}",
|
||||
request.thread_id,
|
||||
cancelled,
|
||||
discarded
|
||||
);
|
||||
// Drop any persisted in-flight turn snapshot for this thread —
|
||||
// otherwise `threads_turn_state_list` keeps surfacing it (as
|
||||
// `Interrupted` on next restart) for a thread that no longer
|
||||
@@ -541,6 +559,24 @@ pub async fn threads_purge(
|
||||
) -> Result<RpcOutcome<ApiEnvelope<PurgeConversationThreadsResponse>>, String> {
|
||||
let dir = workspace_dir().await?;
|
||||
let stats = conversations::purge_threads(dir.clone())?;
|
||||
// No parent thread survives a purge, so cancel every detached sub-agent and
|
||||
// wipe every queued result. Same ordering as `thread_delete`: abort the
|
||||
// in-flight runs first, then clear the delivery queue. Tombstone each
|
||||
// cancelled sub-agent's thread BEFORE the final wipe so a straggler that
|
||||
// wins the cooperative-abort race (records after the wipe) is still dropped
|
||||
// by `record_completion` rather than delivered into a purged thread.
|
||||
use crate::openhuman::agent_orchestration::{background_completions, running_subagents};
|
||||
let cancelled_threads = running_subagents::cancel_all();
|
||||
let mut discarded = 0;
|
||||
for thread_id in &cancelled_threads {
|
||||
discarded += background_completions::discard_for_thread(thread_id);
|
||||
}
|
||||
discarded += background_completions::clear_all();
|
||||
log::debug!(
|
||||
"[threads] threads_purge cancelled_threads={} discarded_completions={}",
|
||||
cancelled_threads.len(),
|
||||
discarded
|
||||
);
|
||||
// Threads are gone, so any orphan turn snapshots can never be
|
||||
// reattached to a live thread. Wipe them in the same call so
|
||||
// `turn_state_list` returns an empty set after a purge. Use the
|
||||
|
||||
@@ -623,6 +623,18 @@ async fn thread_delete_removes_persisted_turn_state_snapshot() {
|
||||
turn_state::store::put(dir.clone(), &snapshot).expect("put snapshot");
|
||||
assert!(turn_state::store::get(dir, thread_id).unwrap().is_some());
|
||||
|
||||
// Queue a finished background sub-agent result for this thread; deleting the
|
||||
// thread must discard it so it's never delivered into a dead thread.
|
||||
use crate::openhuman::agent_orchestration::background_completions as bg;
|
||||
bg::record_completion(
|
||||
"sess-del",
|
||||
"sub-del-1",
|
||||
"researcher",
|
||||
"result",
|
||||
Some(thread_id.to_string()),
|
||||
);
|
||||
assert_eq!(bg::pending_count("sess-del"), 1);
|
||||
|
||||
thread_delete(DeleteConversationThreadRequest {
|
||||
thread_id: thread_id.to_string(),
|
||||
deleted_at: "2026-01-01T00:02:00Z".into(),
|
||||
@@ -630,6 +642,12 @@ async fn thread_delete_removes_persisted_turn_state_snapshot() {
|
||||
.await
|
||||
.expect("delete thread");
|
||||
|
||||
assert_eq!(
|
||||
bg::pending_count("sess-del"),
|
||||
0,
|
||||
"queued completion for the deleted thread should be discarded"
|
||||
);
|
||||
|
||||
let turn_state = turn_state_get(GetTurnStateRequest {
|
||||
thread_id: thread_id.to_string(),
|
||||
})
|
||||
@@ -663,10 +681,31 @@ async fn threads_purge_removes_valid_and_corrupted_turn_state_files() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Queue background sub-agent results across sessions; a full purge must wipe
|
||||
// them all since no parent thread survives.
|
||||
use crate::openhuman::agent_orchestration::background_completions as bg;
|
||||
bg::record_completion(
|
||||
"sess-p1",
|
||||
"sub-p1",
|
||||
"researcher",
|
||||
"x",
|
||||
Some("thread-a".into()),
|
||||
);
|
||||
bg::record_completion(
|
||||
"sess-p2",
|
||||
"sub-p2",
|
||||
"researcher",
|
||||
"y",
|
||||
Some("thread-b".into()),
|
||||
);
|
||||
|
||||
threads_purge(EmptyRequest {})
|
||||
.await
|
||||
.expect("purge threads should also clear snapshots");
|
||||
|
||||
assert!(!bg::has_pending("sess-p1"));
|
||||
assert!(!bg::has_pending("sess-p2"));
|
||||
|
||||
if turn_state_dir.exists() {
|
||||
let remaining_json: Vec<_> = std::fs::read_dir(&turn_state_dir)
|
||||
.unwrap()
|
||||
|
||||
Reference in New Issue
Block a user