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:
sanil-23
2026-06-18 12:54:28 +05:30
committed by GitHub
co-authored by Claude
parent 975a478eda
commit f64e294f29
33 changed files with 1130 additions and 14 deletions
+5
View File
@@ -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': 'الخطوات',
+5
View File
@@ -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': 'ধাপসমূহ',
+6
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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 linstant',
'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',
+5
View File
@@ -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': 'चरण',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': '단계',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': 'Шаги',
+5
View File
@@ -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': '步骤',
+22
View File
@@ -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');
});
});
+51
View File
@@ -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);
});
});
+43
View File
@@ -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);
});
});
+22 -2
View File
@@ -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,
+7
View File
@@ -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);
}
}
+5
View File
@@ -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(&params, "taskId")?;
let reason = opt_str(&params, "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(&params, "taskId").is_err());
params.insert("taskId".into(), json!(" "));
assert!(require_str(&params, "taskId").is_err());
params.insert("taskId".into(), json!("sub-1"));
assert_eq!(require_str(&params, "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(&params, "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,
+36
View File
@@ -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
+39
View File
@@ -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()