diff --git a/mcp-task-manager.js b/mcp-task-manager.js index a486b8e..51aff64 100644 --- a/mcp-task-manager.js +++ b/mcp-task-manager.js @@ -106,11 +106,13 @@ const TOOLS = [ }, { name: 'report_result', - description: 'Store a structured result for this task. Other tasks that depend on you can read this via get_task_result. Use this to pass data to downstream tasks — issue lists, PR URLs, analysis results, etc.', + description: 'Store a structured result for this task. Other tasks that depend on you can read this via get_task_result. Use this to pass data to downstream tasks — issue lists, PR URLs, analysis results, etc. Also the ONLY correct way to escalate: if you genuinely cannot complete the task (a required resource is unavailable, you need a human decision, access is missing), pass blocked:true + reason instead of ending the turn silently or claiming success. A blocked task stays visible on the board, separate from both done and failed work, until a human resolves it.', inputSchema: { type: 'object', properties: { - data: { description: 'Structured result data (string or JSON object). This is what dependent tasks will receive.' }, + data: { description: 'Structured result data (string or JSON object). This is what dependent tasks will receive. Still required even when blocked:true — summarize what you tried.' }, + blocked: { type: 'boolean', description: 'Set true when you cannot complete this task right now and it needs a human to unblock it. Do not use this for an ordinary error you can retry past — only for a real external blocker.' }, + reason: { type: 'string', description: 'Required with blocked:true — one or two sentences: what is blocking you, and what a human needs to do about it.' }, }, required: ['data'], }, @@ -249,7 +251,9 @@ async function handleMessage(msg) { text = JSON.stringify(result, null, 2); break; case 'report_result': - text = 'Result stored successfully. Dependent tasks will be able to read it.'; + text = result.blocked + ? 'Result stored, task marked BLOCKED — a human will follow up. Finish your turn now; nothing further will run in this task.' + : 'Result stored successfully. Dependent tasks will be able to read it.'; break; case 'get_task_result': text = JSON.stringify(result, null, 2); diff --git a/package-lock.json b/package-lock.json index 8300126..a0520a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "bcryptjs": "^2.4.3", + "compression": "^1.8.2", "cookie-parser": "^1.4.6", "electron-updater": "^6.8.9", "express": "^4.21.0", @@ -1611,6 +1612,50 @@ "node": ">=0.10.0" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3699,6 +3744,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index f62ee6d..9160bf5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node server.js", "dev": "node --watch server.js", - "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/cli-tool-names.test.js && node test/mcp-web.test.js && node test/gateway-models.test.js && node test/plan-lib.test.js && node test/plan-import.test.js && node test/plans-api.test.js && node test/room-files.test.js && node test/room-git.test.js && node test/room-run.test.js && node test/worktree-manager.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/task-workers.test.js && node test/git-clone.test.js", + "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/cli-tool-names.test.js && node test/mcp-web.test.js && node test/gateway-models.test.js && node test/plan-lib.test.js && node test/plan-import.test.js && node test/plans-api.test.js && node test/room-files.test.js && node test/room-git.test.js && node test/room-run.test.js && node test/worktree-manager.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/task-workers.test.js && node test/git-clone.test.js && node test/task-blocked-and-empty-response.test.js", "postinstall": "node scripts/install-hooks.js", "release": "node scripts/release.js", "electron:dev": "electron .", @@ -35,6 +35,7 @@ }, "dependencies": { "bcryptjs": "^2.4.3", + "compression": "^1.8.2", "cookie-parser": "^1.4.6", "electron-updater": "^6.8.9", "express": "^4.21.0", diff --git a/public/index.html b/public/index.html index e686c04..9126355 100644 --- a/public/index.html +++ b/public/index.html @@ -13129,6 +13129,19 @@

Delegate to Agent

} catch { w.querySelector('.msg').innerHTML = renderMd(m.content); } + } else if (m.role === 'assistant' && m.type === 'plan_review') { + // Same shape as the agent_plan branch above: the card REPLACES the raw-JSON message body, it + // does not sit next to it — an earlier version inserted the card in front of the bubble and + // left the raw JSON visible right under it ("что за странное сообщение под карточкой", + // reported live). review.imported (set once the card's own outcome is known) renders the + // done state on reload, same as a live plan_import_result would show. + try { + const review = JSON.parse(m.content); + _planReviews[String(m.id)] = review; + w.querySelector('.msg').innerHTML = planReviewCardHtml(review, String(m.id), review.imported || null); + } catch { + w.querySelector('.msg').innerHTML = renderMd(m.content); + } } else if (m.role === 'assistant') { w.querySelector('.msg').innerHTML = renderMd(m.content); } else { @@ -13159,19 +13172,6 @@

Delegate to Agent

} // Add "Done" footer for completed assistant messages in history // Show on the last assistant msg before a user msg or end of conversation - if (m.role === 'assistant' && m.type === 'plan_review') { - try { - const review = JSON.parse(m.content); - // Set BEFORE rendering: planReviewCardHtml reads _planReviews[messageId] nowhere itself, but - // _prTogglePlanTask/_prApprove do once the user clicks something on this restored card, and - // without this they silently no-op (the bug this fixes: checkboxes drawn checked, the "N - // selected" count and the button both stuck at 0, because nothing had computed the default yet). - _planReviews[String(m.id)] = review; - const wrap = document.createElement('div'); - wrap.innerHTML = planReviewCardHtml(review, String(m.id), review.imported || null); - w.querySelector('.msg').before(wrap.firstElementChild); - } catch {} - } if (m.role === 'assistant' && m.type !== 'agent_plan' && m.type !== 'plan_review') { const next = _allMsgs[idx + 1]; if (!next || next.role === 'user') { diff --git a/public/kanban.html b/public/kanban.html index 488e241..3a3ae85 100644 --- a/public/kanban.html +++ b/public/kanban.html @@ -168,6 +168,7 @@ .dot-todo{ background:var(--blue); } .dot-in_progress{ background:var(--orange); } .dot-done{ background:var(--green); } +.dot-blocked{ background:var(--accent); } .dot-cancelled{ background:var(--red); } /* ─── Card ─── */ @@ -310,6 +311,7 @@ .chain-card.dragging{ opacity:.4; cursor:grabbing; } .chain-card.status-in_progress{ border-left:3px solid var(--orange); } .chain-card.status-done{ border-left:3px solid var(--green); } +.chain-card.status-blocked{ border-left:3px solid var(--accent); } .chain-card.status-cancelled{ border-left:3px solid var(--red); } .chain-header{ display:flex; align-items:center; gap:8px; padding:10px 12px; cursor:pointer; } .chain-header:hover{ background:rgba(255,255,255,.03); } @@ -331,6 +333,7 @@ .chain-task-icon.done{ background:var(--green); border-color:var(--green); color:white; } .chain-task-icon.in_progress{ border-color:var(--orange); animation:pulse-ring 1.5s ease-out infinite; } .chain-task-icon.cancelled{ background:var(--red); border-color:var(--red); color:white; } +.chain-task-icon.blocked{ background:var(--accent); border-color:var(--accent); color:white; font-size:7px; } @keyframes pulse-ring{ 0%{ box-shadow:0 0 0 0 rgba(229,164,53,.4); } 70%{ box-shadow:0 0 0 4px rgba(229,164,53,0); } 100%{ box-shadow:0 0 0 0 rgba(229,164,53,0); } } .chain-task-title{ flex:1; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .chain-task-title.done-text{ color:var(--muted); text-decoration:line-through; } @@ -462,7 +465,7 @@ 'hdr.title':'Kanban','hdr.add':'Завдання', 'proj.label':'Проект:','proj.all':'— Всі проекти —','proj.recent':'Нещодавні','proj.search':'Пошук проекту...','search.empty':'Не знайдено', 'col.backlog':'Backlog','col.todo':'До виконання','col.in_progress':'В процесі', - 'col.done':'Виконано','col.cancelled':'Скасовано','col.empty':'Немає завдань', + 'col.done':'Виконано','col.blocked':'Заблоковано','col.cancelled':'Скасовано','col.empty':'Немає завдань', 'modal.add':'Нове завдання','modal.edit':'Редагувати завдання', 'modal.name':'Назва *','modal.status':'Статус','modal.session':"Пов'язана сесія", 'modal.project':'Проект *','modal.project.ph':'— оберіть проект —', @@ -492,7 +495,7 @@ 'nav.chat':'Чат','nav.kanban':'Kanban дошка','nav.aria':'Навігація між розділами', 'stat.total':'всього', 'card.sched_tip':'Чекає запуску:','modal.sched_wait':'Чекає запуску:','locale':'uk-UA', - 'card.paused':'Ліміт використання','card.paused_tip':'Виконання зупинив ліміт використання. Автопродовження:','card.auth':'Автентифікація','card.auth_tip':'Зупинено помилкою автентифікації. Виконайте `claude login`, щоб під’єднатися знову:', + 'card.paused':'Ліміт використання','card.paused_tip':'Виконання зупинив ліміт використання. Автопродовження:','card.auth':'Автентифікація','card.auth_tip':'Зупинено помилкою автентифікації. Виконайте `claude login`, щоб під’єднатися знову:','card.blocked':'Заблоковано', 'hdr.group':'Група','group.add':'Нова група','group.edit':'Редагувати групу', 'group.title':'Назва групи *','group.tasks':'Завдання','group.add_task':'+ Додати завдання', 'group.empty':'Додайте завдання до групи','group.activate':'Запустити', @@ -510,7 +513,7 @@ 'hdr.title':'Kanban','hdr.add':'Task', 'proj.label':'Project:','proj.all':'— All projects —','proj.recent':'Recent','proj.search':'Search project...','search.empty':'Not found', 'col.backlog':'Backlog','col.todo':'To Do','col.in_progress':'In Progress', - 'col.done':'Done','col.cancelled':'Cancelled','col.empty':'No tasks', + 'col.done':'Done','col.blocked':'Blocked','col.cancelled':'Cancelled','col.empty':'No tasks', 'modal.add':'New task','modal.edit':'Edit task', 'modal.name':'Title *','modal.status':'Status','modal.session':'Linked session', 'modal.project':'Project *','modal.project.ph':'— pick a project —', @@ -540,7 +543,7 @@ 'nav.chat':'Chat','nav.kanban':'Kanban board','nav.aria':'Navigation', 'stat.total':'total', 'card.sched_tip':'Waiting for launch:','modal.sched_wait':'Waiting for launch:','locale':'en-US', - 'card.paused':'Usage limit','card.paused_tip':'Stopped by a usage limit. Resumes automatically:','card.auth':'Auth','card.auth_tip':'Stopped by an authentication failure. Run `claude login` to reconnect:', + 'card.paused':'Usage limit','card.paused_tip':'Stopped by a usage limit. Resumes automatically:','card.auth':'Auth','card.auth_tip':'Stopped by an authentication failure. Run `claude login` to reconnect:','card.blocked':'Blocked', 'hdr.group':'Group','group.add':'New group','group.edit':'Edit group', 'group.title':'Group title *','group.tasks':'Tasks','group.add_task':'+ Add task', 'group.empty':'Add tasks to the group','group.activate':'Run', @@ -558,7 +561,7 @@ 'hdr.title':'Kanban','hdr.add':'Задача', 'proj.label':'Проект:','proj.all':'— Все проекты —','proj.recent':'Недавние','proj.search':'Поиск проекта...','search.empty':'Не найдено', 'col.backlog':'Backlog','col.todo':'К выполнению','col.in_progress':'В процессе', - 'col.done':'Выполнено','col.cancelled':'Отменено','col.empty':'Нет задач', + 'col.done':'Выполнено','col.blocked':'Заблокировано','col.cancelled':'Отменено','col.empty':'Нет задач', 'modal.add':'Новая задача','modal.edit':'Редактировать задачу', 'modal.name':'Название *','modal.status':'Статус','modal.session':'Связанная сессия', 'modal.project':'Проект *','modal.project.ph':'— выберите проект —', @@ -588,7 +591,7 @@ 'nav.chat':'Чат','nav.kanban':'Kanban доска','nav.aria':'Навигация', 'stat.total':'всего', 'card.sched_tip':'Ожидает запуска:','modal.sched_wait':'Ожидает запуска:','locale':'ru-RU', - 'card.paused':'Лимит использования','card.paused_tip':'Выполнение остановил лимит использования. Автопродолжение:','card.auth':'Аутентификация','card.auth_tip':'Остановлено ошибкой аутентификации. Выполните `claude login`, чтобы подключиться снова:', + 'card.paused':'Лимит использования','card.paused_tip':'Выполнение остановил лимит использования. Автопродолжение:','card.auth':'Аутентификация','card.auth_tip':'Остановлено ошибкой аутентификации. Выполните `claude login`, чтобы подключиться снова:','card.blocked':'Заблокировано', 'hdr.group':'Группа','group.add':'Новая группа','group.edit':'Редактировать группу', 'group.title':'Название группы *','group.tasks':'Задания','group.add_task':'+ Добавить задание', 'group.empty':'Добавьте задания в группу','group.activate':'Запустить', @@ -606,7 +609,7 @@ 'hdr.title':'Kanban','hdr.add':'Tâche', 'proj.label':'Projet :','proj.all':'— Tous les projets —','proj.recent':'Récents','proj.search':'Rechercher un projet…','search.empty':'Aucun résultat', 'col.backlog':'Backlog','col.todo':'À faire','col.in_progress':'En cours', - 'col.done':'Terminé','col.cancelled':'Annulé','col.empty':'Aucune tâche', + 'col.done':'Terminé','col.blocked':'Bloqué','col.cancelled':'Annulé','col.empty':'Aucune tâche', 'modal.add':'Nouvelle tâche','modal.edit':'Modifier la tâche', 'modal.name':'Titre *','modal.status':'Statut','modal.session':'Session liée', 'modal.project':'Projet *','modal.project.ph':'— choisir un projet —', @@ -636,7 +639,7 @@ 'nav.chat':'Discussion','nav.kanban':'Tableau Kanban','nav.aria':'Navigation', 'stat.total':'au total', 'card.sched_tip':'En attente du lancement :','modal.sched_wait':'En attente du lancement :','locale':'fr-FR', - 'card.paused':"Limite d'utilisation",'card.paused_tip':"Arrêté par une limite d'utilisation. Reprise automatique :",'card.auth':'Authentification','card.auth_tip':"Arrêté par un échec d'authentification. Exécutez `claude login` pour vous reconnecter :", + 'card.paused':"Limite d'utilisation",'card.paused_tip':"Arrêté par une limite d'utilisation. Reprise automatique :",'card.auth':'Authentification','card.auth_tip':"Arrêté par un échec d'authentification. Exécutez `claude login` pour vous reconnecter :",'card.blocked':'Bloqué', 'hdr.group':'Groupe','group.add':'Nouveau groupe','group.edit':'Modifier le groupe', 'group.title':'Titre du groupe *','group.tasks':'Tâches','group.add_task':'+ Ajouter une tâche', 'group.empty':'Ajoutez des tâches au groupe','group.activate':'Lancer', @@ -654,7 +657,7 @@ 'hdr.title':'קנבן','hdr.add':'משימה', 'proj.label':'פרויקט:','proj.all':'— כל הפרויקטים —','proj.recent':'אחרונים','proj.search':'חיפוש פרויקט...','search.empty':'לא נמצא', 'col.backlog':'מאגר','col.todo':'לביצוע','col.in_progress':'בתהליך', - 'col.done':'הושלם','col.cancelled':'בוטל','col.empty':'אין משימות', + 'col.done':'הושלם','col.blocked':'חסום','col.cancelled':'בוטל','col.empty':'אין משימות', 'modal.add':'משימה חדשה','modal.edit':'עריכת משימה', 'modal.name':'כותרת *','modal.status':'סטטוס','modal.session':'סשן מקושר', 'modal.project':'פרויקט *','modal.project.ph':'— בחרו פרויקט —', @@ -684,7 +687,7 @@ 'nav.chat':'צ׳אט','nav.kanban':'לוח קנבן','nav.aria':'ניווט', 'stat.total':'סה״כ', 'card.sched_tip':'ממתין להפעלה:','modal.sched_wait':'ממתין להפעלה:','locale':'he-IL', - 'card.paused':'מגבלת שימוש','card.paused_tip':'נעצר בגלל מגבלת שימוש. יתחדש אוטומטית:','card.auth':'אימות','card.auth_tip':'נעצר עקב כשל אימות. הרץ `claude login` כדי להתחבר מחדש:', + 'card.paused':'מגבלת שימוש','card.paused_tip':'נעצר בגלל מגבלת שימוש. יתחדש אוטומטית:','card.auth':'אימות','card.auth_tip':'נעצר עקב כשל אימות. הרץ `claude login` כדי להתחבר מחדש:','card.blocked':'חסום', 'hdr.group':'קבוצה','group.add':'קבוצה חדשה','group.edit':'עריכת הקבוצה', 'group.title':'שם הקבוצה *','group.tasks':'משימות','group.add_task':'+ הוספת משימה', 'group.empty':'הוסיפו משימות לקבוצה','group.activate':'הפעלה', @@ -734,6 +737,7 @@ {id:'backlog',dot:'dot-backlog'}, {id:'todo',dot:'dot-todo'}, {id:'in_progress',dot:'dot-in_progress'}, + {id:'blocked',dot:'dot-blocked'}, {id:'done',dot:'dot-done'}, {id:'cancelled',dot:'dot-cancelled'}, ]; @@ -1269,6 +1273,14 @@ if(!m)return null; return {kind:m[1]||'auth_error',message:(m[2]||'').trim().substring(0,160)}; } +// status:'blocked' — the agent itself called report_result({blocked:true, reason}) instead +// of ending silently or claiming success. failure_reason holds that reason verbatim (no +// prefix to parse, unlike usage_limit/auth_error above — this one is set exactly once, +// by name, never re-derived from banner text). +function kbBlockedInfo(tk){ + if(!tk||tk.status!=='blocked')return null; + return {message:(tk.failure_reason||'').trim().substring(0,200)}; +} // The dials the next run will ACTUALLY use, as card badges (#84, "Kanban Visibility"). // // Pure on purpose — `test/kanban-run-badges.test.js` lifts it out of this file, so the @@ -1310,7 +1322,9 @@ const retryBadge=tk.retry_count>0?`${escH(t('card.retry'))} ×${tk.retry_count}`:''; const pausedInfo=kbUsageLimit(tk); const authInfo=kbAuthStop(tk); + const blockedInfo=kbBlockedInfo(tk); const authBadge=authInfo?`🔐 ${escH(t('card.auth'))}`:''; + const blockedBadge=blockedInfo?`⛔ ${escH(blockedInfo.message||t('card.blocked'))}`:''; const pausedBadge=pausedInfo?`⏸ ${escH(t('card.paused'))}${pausedInfo.resumeAt?' · '+escH(fmtScheduledAt(pausedInfo.resumeAt)):''}`:''; const schedBadge=(!pausedInfo&&tk.scheduled_at&&(tk.status==='todo'||tk.status==='backlog'))?`${escH(fmtScheduledAt(tk.scheduled_at))}`:''; // Only while the board shows every project: inside one project the badge would repeat @@ -1320,7 +1334,7 @@
${escH(tk.title)}
${tk.description?`
${escH(tk.description)}
`:''}
- ${projBadge}${pausedBadge}${authBadge}${schedBadge}${sessBadge}${cfgBadges}${retryBadge} + ${projBadge}${pausedBadge}${authBadge}${blockedBadge}${schedBadge}${sessBadge}${cfgBadges}${retryBadge} ${relTime(tk.updated_at)} @@ -1354,8 +1368,8 @@ let tasksHtml=''; if(chain.tasks&&chain.tasks.length){ tasksHtml=chain.tasks.map(tk=>{ - const iconCls=tk.status==='done'?'done':tk.status==='in_progress'?'in_progress':tk.status==='cancelled'?'cancelled':''; - const iconContent=tk.status==='done'?'✓':tk.status==='cancelled'?'✕':''; + const iconCls=tk.status==='done'?'done':tk.status==='in_progress'?'in_progress':tk.status==='cancelled'?'cancelled':tk.status==='blocked'?'blocked':''; + const iconContent=tk.status==='done'?'✓':tk.status==='cancelled'?'✕':tk.status==='blocked'?'⛔':''; const titleCls=tk.status==='done'?' done-text':''; const canEdit=tk.status==='backlog'||tk.status==='todo'; return `
diff --git a/server.js b/server.js index 0223511..a92cc00 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,5 @@ const express = require('express'); +const compression = require('compression'); const http = require('http'); const { WebSocketServer } = require('ws'); const path = require('path'); @@ -1455,6 +1456,11 @@ function deriveChainStatusFromTasks(tasks) { if (tasks.some(t => t.status === 'in_progress')) return 'in_progress'; if (tasks.some(t => t.status === 'cancelled') && !tasks.some(t => t.status === 'in_progress' || t.status === 'todo')) return 'cancelled'; + // A blocked member halts the chain exactly like a cancelled one does (the next member's + // depends_on never sees 'done'), but it is not a failure — showing it as 'backlog' (the + // fallthrough below) would hide that the chain actually ran and needs a human now. + if (tasks.some(t => t.status === 'blocked') && + !tasks.some(t => t.status === 'in_progress' || t.status === 'todo')) return 'blocked'; if (tasks.some(t => t.status === 'todo')) return 'todo'; return 'backlog'; } @@ -1879,6 +1885,7 @@ const MAX_TASK_WORKERS = Math.max(1, parseInt(process.env.MAX_TASK_WORKERS || '5 const taskRunning = new Set(); // task IDs currently executing const runningTaskAborts = new Map(); // taskId → AbortController const stoppingTasks = new Set(); // task IDs being manually stopped (onDone must not overwrite status) +const blockedTasks = new Map(); // taskId → reason, set by report_result({blocked:true}); consumed once when the run's final status is decided // task IDs started via the independent-worker path. Not a cap counter — the cap is // global (see processQueue) — this only tells /api/running-sessions whether a task row // still has a worker behind it. @@ -2042,6 +2049,7 @@ async function startTask(task) { } const taskAbort = new AbortController(); runningTaskAborts.set(task.id, taskAbort); + blockedTasks.delete(task.id); // clear any stale flag an earlier, crashed run left behind let fullText = '', newCid = claudeSessionId, hasError = false; taskBuffers.set(task.id, ''); // Notify watchers — use task_retrying for restarts, task_started for first run @@ -2063,6 +2071,11 @@ async function startTask(task) { let currentTaskCid = claudeSessionId; let lastTaskResult = null; let lastTaskTurnUsage = null; // usage of the most recent assistant turn → real ctx-window occupancy + // Own text of the LAST loop iteration only (not the whole run) — set at the end of + // every iteration, read once after the loop exits. Distinguishes "the agent's final + // turn actually said something" from fullText being non-empty only because of EARLIER + // turns (tool errors, auto-continue notices, …). See emptyFinalTurn below. + let lastTurnText = ''; const effectiveTaskMaxTurns = task.max_turns || UNATTENDED_MAX_TURNS; // Build MCP config for task execution — user MCPs from config + internal task-manager @@ -2152,6 +2165,7 @@ async function startTask(task) { const _tb = (taskBuffers.get(task.id) || '') + r.fullText; taskBuffers.set(task.id, _tb.length > MAX_CHAT_BUFFER ? _tb.slice(-MAX_CHAT_BUFFER) : _tb); } + lastTurnText = r.fullText || ''; // one-shot engine: this run's whole text IS its last (and only) turn if (r.cid) { newCid = r.cid; currentTaskCid = r.cid; try { stmts.updateClaudeId.run(r.cid, sessionId); } catch {} } if (!r.completed) hasError = true; lastTaskResult = r.completed ? { subtype: 'success' } : { subtype: 'error' }; @@ -2256,6 +2270,7 @@ async function startTask(task) { resolve(); }); }); + lastTurnText = fullText.slice(_ftBefore); // this iteration's own contribution only // 🌐 Transient server overload (HTTP 429/529) — short pause + retry, before the // success break and WITHOUT consuming the auto-continue budget. Same root cause as @@ -2352,6 +2367,18 @@ async function startTask(task) { const isRateLimited = isTransientOverload(fullText) || (hasError && (fullText.includes('rate_limit') || fullText.includes('overloaded') || fullText.includes('Too many'))); const MAX_CHAIN_RETRIES = 2; + // Explicit escalation: the agent itself called report_result({blocked:true}) at + // some point during this run. Read once, right here, regardless of how the CLI + // turn itself ended — a blocked call followed by end_turn is still blocked. + const blockReason = blockedTasks.get(task.id); + if (blockReason !== undefined) blockedTasks.delete(task.id); + // T-001, 2026-09-22: the CLI can end a turn subtype:'success' with literally + // nothing in it — two consecutive empty text blocks, then the run just stopped. + // Every task prompt ends with a mandatory verification report (see + // TASK_VERIFICATION_SUFFIX), so an empty final turn means that report never + // happened. 'done' would be a lie about it — route it into the failure path below + // (as 'empty_response') instead of the merge/done branch. + const emptyFinalTurn = isSuccess && blockReason === undefined && !lastTurnText.trim(); if (usageLimit) { // Re-queue instead of completing: getTodoTasks() skips a todo row until its @@ -2378,7 +2405,38 @@ async function startTask(task) { sessionTitle: _ctx.sessionTitle, projectName: _ctx.projectName, }); } - } else if (isSuccess) { + } else if (blockReason !== undefined) { + // ⛔ Explicit escalation — the agent recognized it could not proceed and said so + // through report_result, instead of the silent failure the empty-turn check + // exists to catch. Own status, distinct from both 'done' and 'cancelled': a + // human needs to look at it, but nothing here says the agent did anything + // wrong. No merge, no chain retry, no re-queue — those all assume either + // success or a retryable failure, and this is neither. + db.prepare(`UPDATE tasks SET status='blocked', failure_reason=?, worker_pid=NULL, updated_at=datetime('now') WHERE id=?`) + .run(blockReason.substring(0, 300), task.id); + log.warn(`[taskWorker] task ${task.id}: blocked — ${blockReason}`); + if (task.source_session_id) { + const _ctx = getNotificationContext(task.source_session_id); + broadcastToSession(task.source_session_id, { + type: 'notification', level: 'warn', + title: `Blocked: "${task.title}"`, + detail: blockReason, + tabId: task.source_session_id, + chainTaskId: task.id, chainStatus: 'blocked', + sessionTitle: _ctx.sessionTitle, projectName: _ctx.projectName, + }); + } + if (telegramBot && telegramBot.isRunning()) { + telegramBot.notifyTaskComplete({ + sessionId, + title: task.title || 'Task', + status: 'blocked', + duration: Date.now() - _taskStartedAt, + error: blockReason, + botId: task.bot_id || null, + }).catch(() => {}); + } + } else if (isSuccess && !emptyFinalTurn) { // ✅ Success — recurring standalone tasks re-arm directly (skip intermediate 'done') const reArmed = (task.recurrence && !task.chain_id) ? scheduleNextRun(task) : false; if (!reArmed) { @@ -2536,7 +2594,7 @@ async function startTask(task) { } } else if (task.chain_id && !usageLimitExhausted && !taskAuthStop && (task.task_retry_count || 0) < MAX_CHAIN_RETRIES) { // 🔄 Auto-retry for chain tasks — don't give up on first failure - const reason = isRateLimited ? 'rate_limited' : 'agent_incomplete'; + const reason = isRateLimited ? 'rate_limited' : emptyFinalTurn ? 'empty_response' : 'agent_incomplete'; _retryBackoffMs = isRateLimited ? Math.min(60000 * ((task.task_retry_count || 0) + 1), 300000) : 3000; db.prepare(`UPDATE tasks SET status='todo', failure_reason=?, task_retry_count=COALESCE(task_retry_count,0)+1, worker_pid=NULL, updated_at=datetime('now') WHERE id=?`) .run(reason, task.id); @@ -2560,7 +2618,7 @@ async function startTask(task) { // mirrors the parseable "usage_limit: …" prefix above. const reason = taskAuthStop ? `auth_error:${taskAuthStop.kind} ${taskAuthStop.message || ''}`.trim().substring(0, 300) - : usageLimitExhausted ? 'usage_limit_exhausted' : isRateLimited ? 'rate_limited' : 'agent_incomplete'; + : usageLimitExhausted ? 'usage_limit_exhausted' : isRateLimited ? 'rate_limited' : emptyFinalTurn ? 'empty_response' : 'agent_incomplete'; db.prepare(`UPDATE tasks SET status='cancelled', failure_reason=?, worker_pid=NULL, updated_at=datetime('now') WHERE id=?`) .run(reason, task.id); log.error(`[taskWorker] task ${task.id}: cancelled (${reason}, subtype: ${lastTaskResult?.subtype || 'unknown'})`); @@ -6062,6 +6120,11 @@ app.use((_req, res, next) => { res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); next(); }); +// Nothing here ever set Content-Encoding — kanban.html (~140KB) and /api/tasks (~190KB+, +// growing with every task: description/context are the biggest fields) went out +// uncompressed on every load and every poll. gzip/br cut plain HTML and JSON by 70-80%; +// this is the one-line fix, ahead of express.static and every route below. +app.use(compression()); app.use(express.json({ limit:'5mb' })); app.use(cookieParser()); @@ -6635,15 +6698,21 @@ app.post('/api/internal/task-manager', express.json({ limit: '1mb' }), (req, res // ── report_result ────────────────────────────────────────────────── case 'report_result': { - const { data } = req.body; + const { data, blocked, reason } = req.body; if (!callerTaskId) return res.status(400).json({ error: 'No task ID' }); if (data === undefined) return res.status(400).json({ error: 'Missing data' }); const outputJson = (typeof data === 'string' ? data : JSON.stringify(data)).substring(0, 10000); stmts.setTaskOutput.run(outputJson, callerTaskId); - log.info('[task-manager] report_result', { taskId: callerTaskId, outputLen: outputJson.length }); - return res.json({ ok: true }); + // Consumed once the run's loop exits (taskWorker, "After loop" section) — this + // just records the request; it does not touch task.status itself, because the + // process is still running and may keep going after this call. + const isBlocked = blocked === true; + if (isBlocked) blockedTasks.set(callerTaskId, String(reason || '').trim().substring(0, 280) || 'no reason given'); + + log.info('[task-manager] report_result', { taskId: callerTaskId, outputLen: outputJson.length, blocked: isBlocked }); + return res.json({ ok: true, blocked: isBlocked }); } // ── get_task_result ──────────────────────────────────────────────── diff --git a/telegram-bot.js b/telegram-bot.js index 4ffaa73..b2dd4c8 100644 --- a/telegram-bot.js +++ b/telegram-bot.js @@ -3228,6 +3228,9 @@ class TelegramBot extends EventEmitter { } else if (status === 'error') { icon = '❌'; statusText = 'Failed'; + } else if (status === 'blocked') { + icon = '⛔'; + statusText = 'Blocked'; } else { icon = 'ℹ️'; statusText = status; diff --git a/test/kanban-run-badges.test.js b/test/kanban-run-badges.test.js index a43e151..404b629 100644 --- a/test/kanban-run-badges.test.js +++ b/test/kanban-run-badges.test.js @@ -132,7 +132,10 @@ check('getTasks joins bots on the task\'s bot_id', /LEFT JOIN bots b ON t\.bot_i check('the bots join excludes soft-deleted bots', /LEFT JOIN bots b ON t\.bot_id = b\.id AND b\.deleted_at IS NULL/.test(getTasksSql), true); // And the card must render the helper rather than a hand-rolled copy of it. -const makeCardBody = SRC.slice(SRC.indexOf('function makeCard('), SRC.indexOf('function makeCard(') + 2600); +// 3200, not 2600: the 'blocked' status work (2026-09-22) added a blockedInfo/blockedBadge +// pair ahead of the footer template inside makeCard, pushing ${cfgBadges} (originally +// ~2450) past the old window. +const makeCardBody = SRC.slice(SRC.indexOf('function makeCard('), SRC.indexOf('function makeCard(') + 3200); check('makeCard builds its badges from kbRunBadges', /const cfgBadges=kbRunBadges\(tk\)/.test(makeCardBody), true); check('makeCard puts them in the footer', /\$\{cfgBadges\}/.test(makeCardBody), true); // The old single-badge form must be gone from the TASK card. It survives on the CHAIN diff --git a/test/kanban-schedule.test.js b/test/kanban-schedule.test.js index 0768077..44d7ad9 100644 --- a/test/kanban-schedule.test.js +++ b/test/kanban-schedule.test.js @@ -134,7 +134,13 @@ console.log('\nwhich task dials each run path actually receives:'); const fs2 = require('fs'), path2 = require('path'); const SRV = fs2.readFileSync(path2.join(__dirname, '..', 'server.js'), 'utf8'); const i2 = SRV.indexOf('async function startTask(task)'); - const seg = SRV.slice(i2, i2 + 20000); + // 26000, not 20000: the empty-final-turn / blocked-status work (2026-09-22) added + // ~700 chars ahead of cli.send(...) inside startTask and pushed its `effort:` field + // (originally ~19850) past the old window, failing this pin for a reason that had + // nothing to do with what it actually checks. Comment above already called this exact + // failure mode — leaving headroom this time instead of trimming to the current byte + // count again. + const seg = SRV.slice(i2, i2 + 26000); const callArgs = (needle) => { const k = seg.indexOf(needle); if (k === -1) return ''; diff --git a/test/render/plan-review.test.mjs b/test/render/plan-review.test.mjs index c6272c1..1eeb7cb 100644 --- a/test/render/plan-review.test.mjs +++ b/test/render/plan-review.test.mjs @@ -123,6 +123,18 @@ assert.ok(/case 'plan_review':[\s\S]{0,120}_prRender\(d\.review, d\.messageId\)/ const m = /_planReviews\[String\(m\.id\)\] = review;[\s\S]{0,200}?planReviewCardHtml\(review, String\(m\.id\)/.exec(html); assert.ok(m, '_planReviews is set BEFORE the DB-restore render, not after (the bug: a click on a restored card found nothing to toggle)'); } +{ + // Reported live, with a screenshot: a reloaded chat showed the card AND, right under it, the + // raw JSON it was built from as a second message. Cause: the card was inserted with .before() + // in front of the message bubble instead of replacing its content, same as agent_plan already + // does one branch up — so the bubble kept rendering the JSON as markdown text. + const restoreStart = html.indexOf("else if (m.role === 'assistant' && m.type === 'plan_review')"); + assert.notStrictEqual(restoreStart, -1, 'plan_review is its own branch in the same if/else chain agent_plan uses (so a plain assistant bubble is never built for it first)'); + const restore = html.slice(restoreStart, html.indexOf("} else if (m.role === 'assistant') {", restoreStart)); + assert.ok(!restore.includes('.before('), 'the plan_review branch no longer inserts the card next to a raw-JSON bubble'); + assert.ok(restore.includes("w.querySelector('.msg').innerHTML = planReviewCardHtml(review, String(m.id)"), 'the card REPLACES the message body, exactly like the agent_plan branch above it'); + assert.ok(restore.includes("w.querySelector('.msg').innerHTML = renderMd(m.content);"), 'a malformed row falls back to plain rendering instead of an empty bubble'); +} assert.ok(/case 'plan_import_result':[\s\S]{0,60}_prShowResult\(d\)/.test(html), "the WS 'plan_import_result' frame updates it"); assert.ok(/m\.role === 'assistant' && m\.type === 'plan_review'/.test(html), 'a reloaded session restores the card from its DB row'); assert.ok(/m\.type === 'plan_review'\) \{ _allMsgs\.push\(m\); continue; \}/.test(html), 'the ingestion loop keeps plan_review rows for the restore pass'); diff --git a/test/task-blocked-and-empty-response.test.js b/test/task-blocked-and-empty-response.test.js new file mode 100644 index 0000000..56c3589 --- /dev/null +++ b/test/task-blocked-and-empty-response.test.js @@ -0,0 +1,199 @@ +// Two terminal-status regressions, both from a real incident (T-001, 2026-09-22): a Kanban +// task run can end subtype:'success' with the CLI having produced NOTHING — two consecutive +// empty text turns — and the board marked it 'done' anyway, with no report, no escalation. +// +// - emptyFinalTurn: a task whose LAST turn's own text is empty (regardless of what earlier +// turns said — an error banner, an auto-continue notice, …) is no longer marked 'done'. +// failure_reason is 'empty_response', not the generic 'agent_incomplete', so the board +// says what actually happened. +// - report_result({blocked:true, reason}): the ONLY correct way to escalate. Status becomes +// 'blocked' — not 'done', not 'cancelled' — a card a human needs to look at, distinct from +// both a finished task and a failed one. +// +// Also checks the compression fix from the same finding: kanban.html and /api/tasks went out +// uncompressed on every load/poll (verified live against the ~190KB real board this session). +// +// Drives a REAL server + a real (scripted) `claude` binary end to end — not the source text, +// the actual taskWorker loop. report_result is fired the same way mcp-task-manager.js fires +// it: a bearer-authed POST to /api/internal/task-manager while the fake CLI is still "running" +// the task — exactly what the real MCP subprocess does under the hood. +// +// Run: node test/task-blocked-and-empty-response.test.js (TEST_PORT= to move off the default) +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +let pass = 0, fail = 0; +function check(label, actual, expected) { + try { assert.deepStrictEqual(actual, expected); pass++; console.log(` ok ${label}`); } + catch { fail++; console.error(` FAIL ${label} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +const PORT = Number(process.env.TEST_PORT || 4562); +const BASE = `http://127.0.0.1:${PORT}`; +const SECRET = 'test-task-manager-secret-blocked0123456789'; + +const APP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-blk-app-')); +const HOME_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-blk-home-')); +process.on('exit', () => { for (const d of [APP_DIR, HOME_DIR]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } }); +fs.mkdirSync(path.join(APP_DIR, 'data'), { recursive: true }); +const WORKDIR = path.join(APP_DIR, 'workspace'); +fs.mkdirSync(WORKDIR, { recursive: true }); +fs.writeFileSync(path.join(APP_DIR, 'config.json'), JSON.stringify({ mcpServers: {}, skills: {} }, null, 2)); + +const binDir = path.join(HOME_DIR, '.local', 'bin'); +fs.mkdirSync(binDir, { recursive: true }); +const claudePath = path.join(binDir, 'claude'); +function setFakeClaude(script) { + fs.writeFileSync(claudePath, script); + fs.chmodSync(claudePath, 0o755); +} +// Scenario A (set before the server even boots): a turn that ends 'success' with literally +// no text — reproduces the real T-001 transcript (two empty assistant turns, then +// subtype:'success'). +setFakeClaude(`#!/bin/sh +case " $* " in *" haiku "*) exit 0 ;; esac +printf '%s\\n' '{"type":"system","subtype":"init","session_id":"ccs-blk-fake-a"}' +printf '%s\\n' '{"type":"result","subtype":"success","session_id":"ccs-blk-fake-a"}' +exit 0 +`); + +let srvLog = ''; +const child = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { + env: { ...process.env, PORT: String(PORT), CCS_DESKTOP: '1', APP_DIR, WORKDIR, HOME: HOME_DIR, CCS_TASK_MANAGER_SECRET: SECRET }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let exited = false; +child.on('exit', () => { exited = true; }); +child.stdout.on('data', d => { srvLog += d; }); +child.stderr.on('data', d => { srvLog += d; }); +let cleanedUp = false; +function cleanup() { if (cleanedUp) return; cleanedUp = true; if (!exited) { try { child.kill('SIGTERM'); } catch {} } } +process.on('exit', cleanup); +for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { cleanup(); process.exit(1); }); +function die(msg) { console.error(msg); if (srvLog) console.error(srvLog.slice(-2000)); cleanup(); process.exit(1); } + +async function api(method, url, body) { + const res = await fetch(BASE + url, { + method, + headers: body !== undefined ? { 'content-type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; try { json = JSON.parse(text); } catch {} + return { status: res.status, json, text }; +} +// The shape mcp-task-manager.js posts: bearer token, {...args, action}. +async function tm(args) { + const res = await fetch(BASE + '/api/internal/task-manager', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${SECRET}` }, + body: JSON.stringify(args), + }); + const text = await res.text(); + let json = null; try { json = JSON.parse(text); } catch {} + return { status: res.status, json, text }; +} +async function getTask(id) { + const r = await api('GET', '/api/tasks'); + const list = Array.isArray(r.json) ? r.json : r.json?.tasks || []; + return list.find(t => t.id === id) || null; +} +async function waitForStatus(id, notIn, timeoutMs = 15000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const t = await getTask(id); + if (t && !notIn.includes(t.status)) return t; + await sleep(150); + } + throw new Error(`task ${id} never left ${JSON.stringify(notIn)} within ${timeoutMs}ms`); +} + +(async () => { + let up = false; + for (let i = 0; i < 80 && !exited; i++) { + try { const r = await fetch(BASE + '/api/health'); if (r.ok) { up = true; break; } } catch {} + await sleep(250); + } + if (exited) die(`server exited before it became ready — port ${PORT} collision or startup crash`); + if (!up) die('server did not start'); + + console.log('\n— an empty final turn is not "done" —'); + { + const created = await api('POST', '/api/tasks', { title: 'spike: empty run', status: 'todo' }); + if (created.status !== 200 || !created.json?.id) die(`could not create the task: ${created.text}`); + const t = await waitForStatus(created.json.id, ['todo', 'in_progress']); + check('an empty final turn is NOT marked done (T-001, real incident)', t.status, 'cancelled'); + check('failure_reason names it specifically, not the generic "agent_incomplete"', t.failure_reason, 'empty_response'); + } + + console.log('\n— report_result({blocked:true}) —'); + { + // Sleeps mid-run so this test has a window to fire report_result exactly the way + // mcp-task-manager.js would — a bearer-authed HTTP call while the process is alive — + // then still ends the turn 'success'. The point: blocked wins regardless of how the + // CLI's OWN turn ends. + setFakeClaude(`#!/bin/sh +case " $* " in *" haiku "*) exit 0 ;; esac +printf '%s\\n' '{"type":"system","subtype":"init","session_id":"ccs-blk-fake-b"}' +sleep 1.5 +printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"I looked, and I cannot proceed."}]}}' +printf '%s\\n' '{"type":"result","subtype":"success","session_id":"ccs-blk-fake-b"}' +exit 0 +`); + const created = await api('POST', '/api/tasks', { title: 'spike: needs a live instance', status: 'todo' }); + if (created.status !== 200 || !created.json?.id) die(`could not create the task: ${created.text}`); + const id = created.json.id; + // Wait for the run to actually start before firing report_result — realistic timing, + // not a guessed sleep. + const start = Date.now(); + while (Date.now() - start < 8000) { + const t = await getTask(id); + if (t?.status === 'in_progress') break; + await sleep(100); + } + const rr = await tm({ action: 'report_result', taskId: id, data: 'no live instance available', blocked: true, reason: 'no live Easypanel instance to test against — need one provisioned' }); + check('report_result accepts blocked:true', rr.status, 200); + check('...and echoes it back', rr.json?.blocked, true); + const t = await waitForStatus(id, ['todo', 'in_progress']); + check('the task ends "blocked", not "done" or "cancelled"', t.status, 'blocked'); + check('failure_reason carries the escalation reason verbatim', t.failure_reason, 'no live Easypanel instance to test against — need one provisioned'); + } + + console.log('\n— a normal run is unaffected —'); + { + setFakeClaude(`#!/bin/sh +case " $* " in *" haiku "*) exit 0 ;; esac +printf '%s\\n' '{"type":"system","subtype":"init","session_id":"ccs-blk-fake-c"}' +printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"All done, here is the summary."}]}}' +printf '%s\\n' '{"type":"result","subtype":"success","session_id":"ccs-blk-fake-c"}' +exit 0 +`); + const created = await api('POST', '/api/tasks', { title: 'spike: normal', status: 'todo' }); + if (created.status !== 200 || !created.json?.id) die(`could not create the task: ${created.text}`); + const t = await waitForStatus(created.json.id, ['todo', 'in_progress']); + check('a real final turn with text still marks the task done', t.status, 'done'); + check('...with no failure_reason', t.failure_reason, null); + } + + console.log('\n— responses are compressed (found live: kanban.html/api/tasks went out raw) —'); + { + const kanbanRes = await fetch(BASE + '/kanban.html', { headers: { 'Accept-Encoding': 'gzip' } }); + await kanbanRes.arrayBuffer(); + check('kanban.html is served gzip-encoded', kanbanRes.headers.get('content-encoding'), 'gzip'); + const tasksRes = await fetch(BASE + '/api/tasks', { headers: { 'Accept-Encoding': 'gzip' } }); + // fetch/undici decodes Content-Encoding transparently — the header is what proves + // the wire format changed; the body is asserted as plain JSON on top of that. + check('/api/tasks is served gzip-encoded', tasksRes.headers.get('content-encoding'), 'gzip'); + const decoded = await tasksRes.json(); + check('...and it still decodes to valid task JSON', Array.isArray(decoded), true); + } + + console.log(`\n${pass} passed, ${fail} failed`); + cleanup(); + process.exit(fail ? 1 : 0); +})().catch(e => die(`unexpected: ${e.stack || e}`));