Tenant
diff --git a/static/panels.js b/static/panels.js
index 8b9dfcc2..8bdc815a 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -1424,14 +1424,75 @@ function _kanbanBoardQuery(extra){
}
async function nudgeKanbanDispatcher(){
+ // Dry-run dispatch: show what WOULD be spawned, without actually spawning
+ // workers. Uses ?dry_run=1 so the dispatcher reports its plan without
+ // mutating the board. The result shape includes spawned/skipped_unassigned/
+ // skipped_nonspawnable/promoted/auto_blocked so users can diagnose why a
+ // Ready task isn't being picked up before they commit to a real run.
try {
const dispatchEndpoint = '/api/kanban/dispatch';
- await api(dispatchEndpoint + '?dry_run=1&max=1' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), {method: 'POST'});
- showToast(t('kanban_nudge_dispatcher'));
+ const result = await api(
+ dispatchEndpoint + '?dry_run=1&max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''),
+ {method: 'POST'},
+ );
+ showToast(_kanbanFormatDispatchResult(result, true), 'info', 6000);
await loadKanban(true);
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
}
+async function runKanbanDispatcher(){
+ // Real dispatch: claims Ready tasks and spawns worker subprocesses
+ // (one `hermes -p
` per claimed row, up to max=8 per call).
+ // Confirmation dialog first because this actually consumes API budget on
+ // each spawned worker. Result toast surfaces what happened so users see
+ // the dispatcher actually doing work.
+ if (!_kanbanCurrentBoard) {
+ showToast(t('kanban_unavailable') || 'Kanban unavailable', 'error');
+ return;
+ }
+ const ok = await showConfirmDialog({
+ title: t('kanban_run_dispatcher') || 'Run dispatcher',
+ message: t('kanban_run_dispatcher_confirm')
+ || 'This will claim Ready tasks on this board and spawn worker subprocesses (one per task, up to 8 per click). Continue?',
+ confirmLabel: t('kanban_run_dispatcher') || 'Run dispatcher',
+ });
+ if (!ok) return;
+ try {
+ const dispatchEndpoint = '/api/kanban/dispatch';
+ const result = await api(
+ dispatchEndpoint + '?max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''),
+ {method: 'POST'},
+ );
+ showToast(_kanbanFormatDispatchResult(result, false), 'info', 8000);
+ await loadKanban(true);
+ } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
+}
+
+function _kanbanFormatDispatchResult(result, dryRun){
+ // Produce a human-readable one-line summary of dispatch_once's output so
+ // users can see exactly what happened rather than a generic "OK" toast.
+ const r = result || {};
+ const spawned = (r.spawned || []).length;
+ const promoted = r.promoted || 0;
+ const reclaimed = r.reclaimed || 0;
+ const skippedUnassigned = (r.skipped_unassigned || []).length;
+ const skippedNonspawnable = (r.skipped_nonspawnable || []).length;
+ const autoBlocked = (r.auto_blocked || []).length;
+ const timedOut = (r.timed_out || []).length;
+ const crashed = (r.crashed || []).length;
+ const verb = dryRun ? (t('kanban_dispatch_preview_prefix') || 'Preview:') : (t('kanban_dispatch_run_prefix') || 'Dispatched:');
+ const parts = [];
+ parts.push(spawned + ' ' + (t('kanban_dispatch_spawned') || 'spawned'));
+ if (promoted) parts.push(promoted + ' ' + (t('kanban_dispatch_promoted') || 'promoted'));
+ if (reclaimed) parts.push(reclaimed + ' ' + (t('kanban_dispatch_reclaimed') || 'reclaimed'));
+ if (skippedUnassigned) parts.push(skippedUnassigned + ' ' + (t('kanban_dispatch_skipped_unassigned') || 'skipped (no assignee)'));
+ if (skippedNonspawnable) parts.push(skippedNonspawnable + ' ' + (t('kanban_dispatch_skipped_nonspawnable') || 'skipped (unknown profile)'));
+ if (autoBlocked) parts.push(autoBlocked + ' ' + (t('kanban_dispatch_auto_blocked') || 'auto-blocked'));
+ if (timedOut) parts.push(timedOut + ' ' + (t('kanban_dispatch_timed_out') || 'timed out'));
+ if (crashed) parts.push(crashed + ' ' + (t('kanban_dispatch_crashed') || 'crashed'));
+ return verb + ' ' + parts.join(', ');
+}
+
function _kanbanSelectedTaskIds(){
const selected = Array.from(document.querySelectorAll('.kanban-card.selected')).map(card => card.dataset.kanbanTaskId).filter(Boolean);
return selected.length ? selected : (_kanbanCurrentTaskId ? [_kanbanCurrentTaskId] : []);
@@ -1596,48 +1657,240 @@ async function createKanbanTask(){
// assignee, tenant, priority, status — see api/kanban_bridge.py:306).
// ────────────────────────────────────────────────────────────────────────────
+// ────────────────────────────────────────────────────────────────────────────
+// Kanban: create-task / edit-task modal (panel-head "+" + task-detail Edit
+// button entry points).
+//
+// Single modal serves both flows. Title + submit-button labels and the
+// underlying submit verb (POST vs PATCH) flip based on `_kanbanTaskModalMode`.
+//
+// Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two
+// flows look and behave identically (centered card, dim backdrop, ESC closes,
+// click-on-backdrop closes). The modal markup lives in static/index.html as
+// #kanbanTaskModal — see the section just above