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 @@
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}`));