From 6fa94116a449ddbc45a1be22ae41a646506d4133 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 9 Aug 2026 12:29:09 +0200 Subject: [PATCH] fix(hub): offene Asks sichtbar machen + Erinnerungsflut stoppen (TSK-0406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Am 09.08. stellte sich heraus, dass fuenf Asks auf pending standen, zwei seit einer Woche. Vier Agenten waren blockiert — darunter einer, der einen fertigen Windows-Build fuer einen externen Tester nicht ausliefern durfte. Der Architekt hat die ganze Zeit das Board gepollt und nichts davon gesehen. Aufgefallen ist es nur, weil ein Agent den Stau selbst benannt hat. TEIL 1 — SICHTBARKEIT (Umsetzung: codex) Asks sind ein eigener, BLOCKIERENDER Kanal, den keine der Oberflaechen erwaehnte, die ein Architekt regelmaessig ansieht: - core/status.ts nennt jetzt Anzahl, IDs und Alter des aeltesten Asks. Das Alter ist der eigentliche Alarm: "einer offen" ist normal, "seit sechs Tagen offen" ist ein Notfall. - /health liefert pendingAsks + oldestAskAgeSec — der billigste Statuscheck des Architekten; was dort nicht steht, existiert fuer ihn nicht. - Der Watchdog kannte Asks gar nicht. Er meldete brav "silent for 16m", also das Symptom, waehrend die Ursache unsichtbar blieb. Jetzt alarmiert er bei ueberfaelligen Asks mit steigender Dringlichkeit, und die irrefuehrende "silent"-Meldung wird bei einem offenen Ask ersetzt durch "wartet seit Xh auf ASK-NNNN — bitte antworten, nicht neu starten". Genau diese Verwechslung fuehrte dazu, dass wartende Agenten fuer tot gehalten und ihre Sessions neu gestartet wurden. - watch --await-ask weckt den Architekten bei neuen Asks (mit --new-only). - Das Board zeigt offene Asks mit Alter und wartendem Agenten. TEIL 2 — DIE FLUT (Umsetzung: claude) Der Watchdog erinnerte einen nicht beanspruchten Task unbegrenzt im Basisintervall weiter — auch an Agenten, von denen er WUSSTE, dass sie nicht im work-Loop sind. Ergebnis: ueber 1500 ungelesene Nachrichten fuer einen einzigen Agenten, dessen Loop daran nicht mehr anlief. Ein Alarm, der den Empfaenger handlungsunfaehig macht, ist schlimmer als kein Alarm. - Ist der Assignee nachweislich aus dem Loop ausgestiegen (nicht: nie gesehen), wird KEINE Erinnerung mehr in sein Postfach geschrieben. Eine Nachricht ist ein dauerhaftes Artefakt; in ein Postfach zu schreiben, das niemand liest, baut nur den Rueckstau auf, der spaeter seinen eigenen Loop blockiert. Stattdessen genau EIN Hinweis an den Architekten — ein Session-Neustart ist eine menschliche Entscheidung. - Das ephemere SSE-Reemit bleibt in beiden Zweigen: es kostet nichts und erreicht womoeglich einen Agenten, der gerade neu verbindet. - Fuer erreichbare Agenten waechst der Abstand exponentiell (Basis x 2^n, gedeckelt bei 8x) statt konstant zu bleiben. - Der Zaehler wird zurueckgesetzt, sobald der Task beansprucht wird, damit ein spaeteres Reopen nicht in einem halb stummgeschalteten Zustand startet. Der bestehende Test "does not spam" pruefte, dass nach erneutem Ablauf der BASIS-Schwelle eine zweite Erinnerung kommt — also genau das Verhalten, das die Flut erzeugt hat. Er ist auf den Backoff angepasst; seine Absicht (Anti-Spam) gilt jetzt ueber die Lebensdauer eines Tasks statt nur ueber ein Cooldown-Fenster. 316 Tests gruen, tsc --noEmit sauber. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/commands/watch.ts | 42 ++++++++- src/cli/index.ts | 4 +- src/core/schema.ts | 4 + src/core/services/presenceService.ts | 8 +- src/core/status.ts | 22 ++++- src/server/board/columns.ts | 1 + src/server/board/kpis.ts | 31 +++++- src/server/board/styles.ts | 36 ++++++- src/server/ui-shared.ts | 19 +++- src/server/watchdog.ts | 136 +++++++++++++++++++++++---- tests/boardV2-styles.test.ts | 13 +++ tests/boardV2.test.ts | 7 +- tests/health.test.ts | 16 +++- tests/sse.test.ts | 38 ++++++++ tests/status.test.ts | 15 +++ tests/ui-shared.test.ts | 6 ++ tests/watchdog.test.ts | 97 ++++++++++++++++++- 17 files changed, 459 insertions(+), 36 deletions(-) diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index dc43365..4c50600 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -95,6 +95,8 @@ function describeEvent(event: AgentHubEvent): { label: string; detail: string } return { label: '✓ Read', detail: event.assignedTo ? `read by ${event.assignedTo}` : 'read' }; } return { label: 'Message', detail: event.title ?? '' }; + case 'ask': + return { label: 'Ask', detail: event.title ?? '' }; default: // 'agent' presence events are formatted in formatEvent() before reaching // here; this fallback only satisfies exhaustiveness. @@ -168,6 +170,32 @@ async function fetchUnreadMessages(serverUrl: string, agent: string): Promise { + try { + const res = await fetch(new URL('/asks?status=pending', serverUrl).toString()); + if (!res.ok) return []; + const asks = (await res.json()) as Array<{ id: string; from?: string; to?: string; status?: string }>; + const recipients = messageRecipientAliases('architect'); + return asks + .filter((a) => a.to && recipients.has(a.to.toLowerCase())) + .map((a) => ({ + type: 'ask', action: 'created', id: a.id, + title: `${a.from ?? ''} → ${a.to ?? ''}`, + status: a.status ?? 'pending', assignedTo: a.to, + })); + } catch { + return []; + } +} + +function isAskForArchitect(event: AgentHubEvent): boolean { + return event.type === 'ask' + && event.action === 'created' + && !!event.assignedTo + && messageRecipientAliases('architect').has(event.assignedTo.toLowerCase()); +} + function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[] = []): boolean { if (event.type !== 'message' || event.action !== 'created') return false; const to = event.assignedTo; @@ -194,7 +222,7 @@ function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[] */ export async function watchEvents( serverUrl: string, - options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean; ignoreFrom?: string[] } = {}, + options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; awaitAsk?: boolean; newOnly?: boolean; ignoreFrom?: string[] } = {}, ): Promise { const url = new URL('/events', serverUrl); // Pass role to the server for an additional server-side filter (saves @@ -247,6 +275,14 @@ export async function watchEvents( return; } } + if (options.awaitAsk && !options.newOnly) { + const pending = await fetchPendingArchitectAsks(serverUrl); + if (pending.length > 0) { + for (const ev of pending) console.log(formatEvent(ev)); + await reader.cancel(); + return; + } + } while (true) { let done: boolean; @@ -290,6 +326,10 @@ export async function watchEvents( await reader.cancel(); return; } + if (options.awaitAsk && isAskForArchitect(event)) { + await reader.cancel(); + return; + } } } } diff --git a/src/cli/index.ts b/src/cli/index.ts index d17ce04..e89ea9f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1034,7 +1034,8 @@ export function createProgram(cwd: string): Command { .option('--role ', 'Client-side role filter (only show events for this role)') .option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier') .option('--await-message ', 'Exit when an unread message arrives for agent/role; architect message notifier') - .option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)') + .option('--await-ask', 'Exit when a pending Ask arrives for the architect') + .option('--new-only', 'With --await-review/--await-message/--await-ask: ignore existing backlog on connect (re-armable without spinning)') .option('--ignore-from ', 'With --await-message: comma-separated senders to ignore (e.g. "agenthub" to mute watchdog reminders)') .action(async (options) => { const { serverUrl } = await resolveContext(program, cwd); @@ -1048,6 +1049,7 @@ export function createProgram(cwd: string): Command { role: options.role as string | undefined, awaitReview: options.awaitReview as boolean | undefined, awaitMessage: options.awaitMessage as string | undefined, + awaitAsk: options.awaitAsk as boolean | undefined, ignoreFrom: typeof options.ignoreFrom === 'string' ? options.ignoreFrom.split(',') : undefined, newOnly: options.newOnly as boolean | undefined, }); diff --git a/src/core/schema.ts b/src/core/schema.ts index 7fa6368..5d7e36d 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -141,6 +141,8 @@ export const StatusSchema = z.object({ generatedAt: z.string().datetime(), activeTasks: z.array(z.string()).default([]), blockedTasks: z.array(z.string()).default([]), + pendingAsks: z.array(z.string()).default([]), + oldestAskAgeSec: z.number().nonnegative().optional(), recentDecisions: z.array(z.string()).default([]), recentHandoffs: z.array(z.string()).default([]), summary: z.string().default(''), @@ -223,6 +225,8 @@ export const WatchdogConfigSchema = z.object({ unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000), /** IN_PROGRESS with no task-log line for this long → alert the architect. */ staleInProgressMs: z.number().int().positive().default(15 * 60_000), + /** PENDING ask open for this long → alert the architect. */ + pendingAskMs: z.number().int().positive().default(15 * 60_000), /** REVIEW untouched for this long → remind the architect. */ staleReviewMs: z.number().int().positive().default(5 * 60_000), }).default({}); diff --git a/src/core/services/presenceService.ts b/src/core/services/presenceService.ts index 61a8e1b..5b260f8 100644 --- a/src/core/services/presenceService.ts +++ b/src/core/services/presenceService.ts @@ -1,5 +1,6 @@ import { listTasks } from './taskService.js'; import { listMessages } from './messageService.js'; +import { listAsks } from './askService.js'; import { getRoster } from './rosterService.js'; import { VERSION } from '../../version.js'; @@ -74,7 +75,7 @@ export interface HealthReport { version: string; startedAt: string; uptimeSec: number; - counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number }; + counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number; pendingAsks: number; oldestAskAgeSec?: number }; agents: AgentHealth[]; indexErrors?: Array<{ filePath: string; error: string; at: string }>; } @@ -165,6 +166,7 @@ export function agentLight( /** Compact hub health: status + version + uptime + counts + per-agent lights. */ export function computeHealth(cwd: string, startedAtMs: number, now: number = Date.now()): HealthReport { const tasks = listTasks(cwd); + const pendingAsks = listAsks(cwd, { status: 'pending' }); const roster = getRoster(cwd); const roleByName = new Map(roster.map((r) => [r.name, r.role])); const dispatchByName = new Map(roster.map((r) => [r.name, r.dispatch])); @@ -205,6 +207,10 @@ export function computeHealth(cwd: string, startedAtMs: number, now: number = Da inProgress: tasks.filter((t) => t.status === 'in_progress').length, review: tasks.filter((t) => t.status === 'review').length, unreadMessages: listMessages(cwd).filter((m) => m.status === 'unread').length, + pendingAsks: pendingAsks.length, + oldestAskAgeSec: pendingAsks.length + ? Math.max(0, Math.round((now - Math.min(...pendingAsks.map((a) => Date.parse(a.createdAt)))) / 1000)) + : undefined, }, agents, }; diff --git a/src/core/status.ts b/src/core/status.ts index 4750884..d41a7f7 100644 --- a/src/core/status.ts +++ b/src/core/status.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { getEntityDir, getStatusPath } from './paths.js'; import { readEntity, writeEntity, listEntities } from './files.js'; import { StatusSchema } from './schema.js'; +import { listAsks } from './services/askService.js'; export function generateStatus(cwd: string) { const taskDir = getEntityDir(cwd, 'tasks'); @@ -25,14 +26,24 @@ export function generateStatus(cwd: string) { const recentDecisions = recentIds(decisionDir, 5); const recentHandoffs = recentIds(handoffDir, 5); + const pending = listAsks(cwd, { status: 'pending' }); + const now = Date.now(); + const oldestAskAgeSec = pending.length + ? Math.max(0, Math.round((now - Math.min(...pending.map((a) => Date.parse(a.createdAt)))) / 1000)) + : undefined; + const askSummary = pending.length + ? ` Pending asks: ${pending.length} (${pending.map((a) => a.id).join(', ')}); oldest open for ${formatAge(oldestAskAgeSec!)} — architect action required.` + : ' Pending asks: 0.'; const status = StatusSchema.parse({ generatedAt: new Date().toISOString(), activeTasks, blockedTasks, + pendingAsks: pending.map((a) => a.id), + oldestAskAgeSec, recentDecisions, recentHandoffs, - summary: `Active tasks: ${activeTasks.length}. Review/blocked: ${blockedTasks.length}.`, + summary: `Active tasks: ${activeTasks.length}. Review/blocked: ${blockedTasks.length}.${askSummary}`, }); const statusPath = getStatusPath(cwd); @@ -42,6 +53,15 @@ export function generateStatus(cwd: string) { return status; } +function formatAge(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + function recentIds(dir: string, limit: number): string[] { return listEntities(dir) .map((f) => ({ file: f, ...readEntity(f) })) diff --git a/src/server/board/columns.ts b/src/server/board/columns.ts index ae9a7bd..56743ac 100644 --- a/src/server/board/columns.ts +++ b/src/server/board/columns.ts @@ -630,6 +630,7 @@ export function columnsJs(opts: ColumnsJsOpts): string { var data = null; try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {} if (type === 'task') { refresh(); feedFromEvent(data); } + if (type === 'ask' && window.__b2RefreshAsks) window.__b2RefreshAsks(); if (type === 'agent' && window.refreshAgentHealth) window.refreshAgentHealth(); refreshBudget(); }; diff --git a/src/server/board/kpis.ts b/src/server/board/kpis.ts index 298b77f..2285f55 100644 --- a/src/server/board/kpis.ts +++ b/src/server/board/kpis.ts @@ -27,6 +27,10 @@ export function kpiSkeletonHtml(): string {
+ +`; } @@ -106,5 +110,30 @@ window.__b2UpdateKpis = function (tasks, agentColor) { el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche'; }); setK('kpiDone', 'bar', function (el) { el.style.width = ds.pct + '%'; }); -};`; +}; +window.__b2RefreshAsks = async function () { + var panel = document.getElementById('pendingAsks'); + if (!panel) return; + try { + var res = await fetch('/asks?status=pending', { headers: { accept: 'application/json' } }); + if (!res.ok) return; + var asks = await res.json(); + panel.hidden = asks.length === 0; + var count = panel.querySelector('[data-ask-count]'); + if (count) count.textContent = String(asks.length); + var list = panel.querySelector('[data-ask-list]'); + if (!list) return; + function safe(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) { return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); } + function age(iso) { + var sec = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000)); + if (sec < 60) return sec + 's'; + var min = Math.round(sec / 60); if (min < 60) return min + 'm'; + var h = Math.round(min / 60); return h < 48 ? h + 'h' : Math.round(h / 24) + 'd'; + } + list.innerHTML = asks.map(function(a) { + return '' + safe(a.id) + '' + safe(a.from) + ' wartet' + age(a.createdAt) + '' + (a.taskId ? '' + safe(a.taskId) + '' : '') + ''; + }).join(''); + } catch (_) {} +}; +window.__b2RefreshAsks();`; } diff --git a/src/server/board/styles.ts b/src/server/board/styles.ts index ab224b4..a36b263 100644 --- a/src/server/board/styles.ts +++ b/src/server/board/styles.ts @@ -122,6 +122,16 @@ export function boardV2Css(): string { } /* ── Board columns & task cards (ported v1, glass look) ───────────── */ + .b2-asks { margin: 0 0 14px; padding: 12px 14px; border-color: rgba(245,158,11,.34); } + .b2-asks[hidden] { display: none; } + .b2-asks-head { display: flex; align-items: center; gap: 8px; color: #fbbf24; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; } + .b2-asks-head span { min-width: 22px; padding: 2px 7px; border-radius: 999px; background: rgba(245,158,11,.18); text-align: center; } + .b2-asks-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; } + .b2-ask { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border-radius: 8px; background: rgba(15,23,42,.48); font-size: 12px; } + .b2-ask b { color: #fbbf24; } + .b2-ask i { color: #e2e8f0; font-style: normal; } + .b2-ask em { color: #fb7185; font-style: normal; font-weight: 700; } + .b2-ask a { color: #93c5fd; text-decoration: none; } .board { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: stretch; min-width: 0; min-height: 0; flex: 1 1 auto; @@ -428,9 +438,18 @@ export function boardV2Css(): string { .b2-splash-word { margin-top: 14px; font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #eef1f8; } /* ── Responsive ───────────────────────────────────────────────────── */ - @media (max-width: 1120px) { - .b2-body { grid-template-columns: 1fr; } - .b2-side { position: static; max-height: none; } + @media (max-width: 1119px) { + html { overflow-x: hidden; overflow-y: auto; } + body { height: auto; min-height: 100dvh; overflow-x: hidden; overflow-y: visible; } + .b2-body { + grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto; + min-height: auto; flex: 0 0 auto; + } + .b2-main { height: auto; min-height: auto; } + .board { min-height: auto; flex: 0 0 auto; } + .column { min-height: auto; } + .cards { min-height: auto; overflow-y: visible; } + .b2-side { position: static; max-height: none; overflow-y: visible; } } @media (max-width: 860px) { body { padding-left: 14px; padding-right: 14px; } @@ -439,8 +458,17 @@ export function boardV2Css(): string { .board { grid-template-columns: repeat(2, 1fr); } .app-header .b2-proj { max-width: 46vw; } } + @media (max-width: 680px) { + body { padding-top: 142px; } + } @media (max-width: 560px) { - .b2-kpis { grid-template-columns: 1fr; } + body { padding-bottom: 20px; } + .b2-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + .b2-kpi { min-height: 58px; padding: 9px 10px; } + .b2-lbl { font-size: 10px; letter-spacing: .07em; } + .b2-val { margin-top: 0; font-size: 22px; line-height: 1.2; } + .b2-val small { font-size: 11px; } + .b2-miniarea, .b2-chips, .b2-cap-r, .b2-pbar { display: none; } .board { grid-template-columns: 1fr; } .detail-activity-row { grid-template-columns: 1fr; gap: 3px; } .agent-bar-row { grid-template-columns: 1fr; gap: 6px; } diff --git a/src/server/ui-shared.ts b/src/server/ui-shared.ts index 76df9d8..490c85b 100644 --- a/src/server/ui-shared.ts +++ b/src/server/ui-shared.ts @@ -349,10 +349,21 @@ export function appHeaderOnlyCss(): string { .sse-status.stale .b2-live-dot { background: var(--b2-amber); animation: none; } .sse-status.down .b2-live-dot { background: #F85149; animation: none; } @media (max-width: 680px) { - .app-header { flex-wrap: wrap; gap: 10px 12px; } - .b2-hdr-right { margin-left: auto; } - .app-header .b2-nav { order: 3; width: 100%; } - .app-header .b2-nav a { flex: 1; justify-content: center; } + .app-header { flex-wrap: wrap; gap: 10px 12px; padding: 10px 14px; } + .app-header .b2-proj { display: none; } + .b2-hdr-right { margin-left: auto; gap: 10px; } + .b2-btn { min-height: 44px; padding-right: 12px; padding-left: 12px; } + .sse-status { min-width: 20px; min-height: 44px; width: 20px; } + .sse-status #sseLabel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .app-header .b2-nav { + order: 3; flex: 0 0 100%; width: 100%; min-width: 0; max-width: 100%; + margin-left: 0; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 28px), transparent 100%); + mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 28px), transparent 100%); + } + .app-header .b2-nav a { flex: 0 0 auto; min-height: 44px; justify-content: center; } }`; } diff --git a/src/server/watchdog.ts b/src/server/watchdog.ts index 0fe0c24..49a9232 100644 --- a/src/server/watchdog.ts +++ b/src/server/watchdog.ts @@ -6,6 +6,7 @@ import { createMessage } from '../core/services/messageService.js'; import { emitChange, eventBus } from './events.js'; import { agentLoopStatus } from '../core/services/presenceService.js'; import { resolveAgentName } from '../core/services/identityService.js'; +import { listAsks } from '../core/services/askService.js'; /** * Server-side stuck-task watchdog (TSK-0226). @@ -30,6 +31,19 @@ import { resolveAgentName } from '../core/services/identityService.js'; /** last alert timestamp per `${taskId}:${kind}` — in-memory, per hub process. */ const lastAlertAt = new Map(); +/** + * How often we already reminded an assignee about one unclaimed task, keyed by + * `${taskId}:unclaimed`. Drives the exponential backoff below and is dropped as + * soon as the task leaves the open+unclaimed state. + */ +const reminderAttempts = new Map(); + +/** + * Upper bound for the backoff multiplier (base threshold × 2^attempts). + * At the 3-min high-priority base this tops out at ~25 min between reminders. + */ +const REMINDER_BACKOFF_MAX = 8; + function architectName(config: Config): string { return config.roles.architect?.preferredAgent ?? 'claude'; } @@ -43,6 +57,20 @@ function cooledDown(key: string, threshold: number, now: number): boolean { return last === undefined || now - last >= threshold; } +function formatAge(ms: number): string { + const minutes = Math.max(1, Math.round(ms / 60_000)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + +function askUrgency(ageMs: number, thresholdMs: number): string { + if (ageMs >= thresholdMs * 24) return 'CRITICAL'; + if (ageMs >= thresholdMs * 4) return 'URGENT'; + return 'PENDING'; +} + /** Board-visible alert: a warn line on the task's live console + SSE fan-out. */ function logAlert(cwd: string, id: string, text: string): void { try { @@ -61,6 +89,31 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { const config = loadConfig(cwd); const cfg = config.watchdog; let alerts = 0; + const pendingAsks = listAsks(cwd, { status: 'pending' }); + const architect = architectName(config); + /** Every task still open+unclaimed in THIS scan — everything else is pruned below. */ + const unclaimedKeys = new Set(); + + // Blocking Asks use the existing agenthub/watchdog channel, so an + // architect's --ignore-from agenthub filter still suppresses repeat noise. + for (const ask of pendingAsks) { + const ageMs = now - Date.parse(ask.createdAt); + const key = `${ask.id}:pending`; + if (ageMs < cfg.pendingAskMs || !cooledDown(key, cfg.pendingAskMs, now)) continue; + lastAlertAt.set(key, now); + alerts++; + const age = formatAge(ageMs); + const urgency = askUrgency(ageMs, cfg.pendingAskMs); + try { + createMessage(cwd, { + from: 'agenthub', + to: architect, + text: `Watchdog Ask ${urgency}: ${ask.id} wartet seit ${age} auf Antwort für ${ask.taskId ?? 'keinen Task'} (${ask.from} → ${ask.to}).`, + taskId: ask.taskId, + }); + } catch { /* best-effort */ } + if (ask.taskId) logAlert(cwd, ask.taskId, `Watchdog: ${ask.id} wartet seit ${age} auf Antwort (${urgency})`); + } for (const t of listTasks(cwd)) { // (a) OPEN + addressed to an agent, but never claimed. @@ -74,14 +127,21 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { } catch { /* fall back to the index row */ } - const threshold = thresholdFor(priority, cfg); + const baseThreshold = thresholdFor(priority, cfg); const waitingMs = now - Date.parse(t.updatedAt || t.createdAt); const key = `${t.id}:unclaimed`; + unclaimedKeys.add(key); let assignee = t.assignedTo; try { assignee = resolveAgentName(cwd, t.assignedTo); } catch { /* invalid legacy target is reported below */ } const dispatch = config.agents?.[assignee]?.dispatch ?? 'loop'; + // Exponential backoff: an assignee that stays silent must not be reminded + // at the base interval forever. Unbounded reminders once produced 1500+ + // unread messages for a single agent and stalled its work loop outright — + // the alarm made the recipient unable to act, which is worse than silence. + const attempts = reminderAttempts.get(key) ?? 0; + const threshold = baseThreshold * Math.min(2 ** attempts, REMINDER_BACKOFF_MAX); const shouldAlert = dispatch === 'architect' ? !lastAlertAt.has(key) : cooledDown(key, threshold, now); - if (waitingMs >= threshold && shouldAlert) { + if (waitingMs >= baseThreshold && shouldAlert) { lastAlertAt.set(key, now); alerts++; if (dispatch === 'architect') { @@ -97,21 +157,10 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { logAlert(cwd, t.id, `Watchdog: wartet auf Architekten-Start von ${assignee}`); continue; } - if (agentLoopStatus(assignee) === 'inactive') { - const architect = architectName(config); - try { - createMessage(cwd, { - from: 'agenthub', - to: architect, - text: `Watchdog: ${t.id} is assigned to ${assignee}, but that agent is not in agenthub_work — manual/session wake may be required.`, - taskId: t.id, - }); - } catch { - /* best-effort */ - } - logAlert(cwd, t.id, `Watchdog: ${assignee} is out of the work loop — alerted ${architect}`); - } + const loopInactive = agentLoopStatus(assignee) === 'inactive'; // 1. Re-emit the task event so SSE subscribers / work loops re-wake. + // Events are ephemeral — they cost nothing and may catch an agent + // that is reconnecting right now, so they run in both branches. emitChange( { type: 'task', @@ -126,7 +175,32 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { }, t.updatedAt, ); + if (loopInactive) { + // The assignee is provably not consuming mail. A reminder message is + // a DURABLE artifact — writing it into a mailbox nobody reads only + // grows a backlog that will later drown the agent's own work loop. + // Tell the architect instead, and only once per task: restarting the + // session is a human decision, and repeating it is pure noise. + const inactiveKey = `${t.id}:inactive`; + if (!lastAlertAt.has(inactiveKey)) { + lastAlertAt.set(inactiveKey, now); + const architect = architectName(config); + try { + createMessage(cwd, { + from: 'agenthub', + to: architect, + text: `Watchdog: ${t.id} is assigned to ${assignee}, but that agent is not in agenthub_work — manual/session wake may be required.`, + taskId: t.id, + }); + } catch { + /* best-effort */ + } + logAlert(cwd, t.id, `Watchdog: ${assignee} is out of the work loop — alerted ${architect}, reminders paused`); + } + continue; + } // 2. Unread reminder message — work loops wake on unread mail. + reminderAttempts.set(key, attempts + 1); try { createMessage(cwd, { from: 'agenthub', @@ -138,7 +212,7 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { /* best-effort */ } // 3. Board-visible alert. - logAlert(cwd, t.id, `Watchdog: unclaimed for ${Math.round(waitingMs / 60_000)}m — re-notified ${t.assignedTo}`); + logAlert(cwd, t.id, `Watchdog: unclaimed for ${Math.round(waitingMs / 60_000)}m — re-notified ${t.assignedTo} (Erinnerung ${attempts + 1})`); } continue; } @@ -150,20 +224,33 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { const silentMs = now - (isNaN(lastActivity) ? now : lastActivity); const key = `${t.id}:stale`; if (silentMs >= cfg.staleInProgressMs && cooledDown(key, cfg.staleInProgressMs, now)) { + const pendingAsk = pendingAsks + .filter((ask) => ask.taskId === t.id) + .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))[0]; + const askAgeMs = pendingAsk ? now - Date.parse(pendingAsk.createdAt) : 0; + const askKey = pendingAsk ? `${pendingAsk.id}:pending` : undefined; + // The Ask scan above may already have emitted the actionable alert. + // Never add a second message for the same blocked agent in one cooldown. + if (askKey && !cooledDown(askKey, cfg.pendingAskMs, now)) continue; lastAlertAt.set(key, now); + if (askKey) lastAlertAt.set(askKey, now); alerts++; const architect = architectName(config); try { createMessage(cwd, { from: 'agenthub', to: architect, - text: `Watchdog: ${t.id} is in_progress by ${t.claimedBy ?? t.assignedTo ?? '?'} but silent for ${Math.round(silentMs / 60_000)}m — no auto-reassign, your call.`, + text: pendingAsk + ? `Watchdog: ${t.id} still seit ${Math.round(silentMs / 60_000)}m, ABER ${t.claimedBy ?? t.assignedTo ?? '?'} wartet seit ${formatAge(askAgeMs)} auf ${pendingAsk.id} — bitte antworten, nicht neu starten.` + : `Watchdog: ${t.id} is in_progress by ${t.claimedBy ?? t.assignedTo ?? '?'} but silent for ${Math.round(silentMs / 60_000)}m — no auto-reassign, your call.`, taskId: t.id, }); } catch { /* best-effort */ } - logAlert(cwd, t.id, `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`); + logAlert(cwd, t.id, pendingAsk + ? `Watchdog: wartet seit ${formatAge(askAgeMs)} auf ${pendingAsk.id} — alerted ${architect}` + : `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`); } } @@ -189,6 +276,16 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { } } + // A task that got claimed (or moved on) must start over with a clean slate — + // otherwise a later re-open would inherit a backed-off, half-muted state. + for (const key of [...reminderAttempts.keys()]) { + if (!unclaimedKeys.has(key)) { + reminderAttempts.delete(key); + lastAlertAt.delete(key); + lastAlertAt.delete(`${key.slice(0, -':unclaimed'.length)}:inactive`); + } + } + return alerts; } @@ -223,4 +320,5 @@ export function startWatchdog(cwd: string): () => void { /** Test hook: drop all cooldown state. */ export function resetWatchdog(): void { lastAlertAt.clear(); + reminderAttempts.clear(); } diff --git a/tests/boardV2-styles.test.ts b/tests/boardV2-styles.test.ts index 308686a..b8fa860 100644 --- a/tests/boardV2-styles.test.ts +++ b/tests/boardV2-styles.test.ts @@ -18,4 +18,17 @@ describe('boardV2Css', () => { expect(css).toMatch(/\.b2-chips \{[^}]*flex-wrap: nowrap/); expect(css).toMatch(/\.b2-chips \{[^}]*overflow: hidden/); }); + it('switches the narrow board to document flow without changing the desktop breakpoint', () => { + const css = boardV2Css(); + expect(css).toContain('@media (max-width: 1119px)'); + expect(css).toMatch(/@media \(max-width: 1119px\)[\s\S]*body \{[^}]*height: auto;[^}]*overflow-y: visible/); + expect(css).toMatch(/@media \(max-width: 1119px\)[\s\S]*\.b2-main \{[^}]*height: auto/); + expect(css).toMatch(/@media \(max-width: 1119px\)[\s\S]*\.cards \{[^}]*overflow-y: visible/); + }); + it('keeps mobile KPIs in a compact 2x2 grid', () => { + const css = boardV2Css(); + expect(css).toMatch(/@media \(max-width: 680px\)[\s\S]*body \{ padding-top: 142px/); + expect(css).toMatch(/@media \(max-width: 560px\)[\s\S]*\.b2-kpis \{[^}]*repeat\(2/); + expect(css).toMatch(/@media \(max-width: 560px\)[\s\S]*\.b2-miniarea, \.b2-chips, \.b2-cap-r, \.b2-pbar \{ display: none/); + }); }); diff --git a/tests/boardV2.test.ts b/tests/boardV2.test.ts index ad521d7..2eb9f06 100644 --- a/tests/boardV2.test.ts +++ b/tests/boardV2.test.ts @@ -10,10 +10,15 @@ describe('renderBoardHtml (v2)', () => { expect(html).toContain('id="b2-splash"'); }); it('contains header, kpis, columns and sidebar mount points', () => { - for (const s of ['app-header', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) { + for (const s of ['app-header', 'demo-project', 'id="kpiOpen"', 'id="pendingAsks"', 'id="b2Budget"', 'id="b2Feed"']) { expect(html).toContain(s); } }); + it('loads pending Asks and refreshes their strip on Ask events', () => { + expect(html).toContain('/asks?status=pending'); + expect(html).toContain("type === 'ask'"); + expect(html).toContain('wartet'); + }); it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => { for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'taskModal']) { expect(html).toContain(s); diff --git a/tests/health.test.ts b/tests/health.test.ts index 320640e..26249cc 100644 --- a/tests/health.test.ts +++ b/tests/health.test.ts @@ -12,7 +12,7 @@ interface HealthBody { version: string; startedAt: string; uptimeSec: number; - counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number }; + counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number; pendingAsks: number; oldestAskAgeSec?: number }; agents: Array<{ name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number; inLoop: boolean; loopSince?: string; loopExitReason?: string; @@ -47,12 +47,24 @@ describe('GET /health + lastSeen stamping (TSK-0226)', () => { expect(typeof h.uptimeSec).toBe('number'); expect(h.uptimeSec).toBeGreaterThanOrEqual(0); expect(typeof h.startedAt).toBe('string'); - expect(h.counts).toMatchObject({ tasks: 0, open: 0, inProgress: 0, review: 0, unreadMessages: 0 }); + expect(h.counts).toMatchObject({ tasks: 0, open: 0, inProgress: 0, review: 0, unreadMessages: 0, pendingAsks: 0 }); expect(Array.isArray(h.agents)).toBe(true); // The init roster seeds the default preferred agents. expect(h.agents.map((a) => a.name)).toContain('claude'); }); + it('counts pending Asks and exposes the oldest age', async () => { + await app.inject({ + method: 'POST', url: '/asks', + payload: { from: 'codex', to: 'claude', question: 'Need architecture input' }, + }); + + const h = await getHealth(); + expect(h.counts.pendingAsks).toBe(1); + expect(h.counts.oldestAskAgeSec).toBeTypeOf('number'); + expect(h.counts.oldestAskAgeSec).toBeGreaterThanOrEqual(0); + }); + it('stamps lastSeen on announce → agent shows active', async () => { await app.inject({ method: 'POST', url: '/announce', payload: { agent: 'kimi', role: 'implementer' } }); const h = await getHealth(); diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 430857a..ac1738a 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -789,3 +789,41 @@ describe('watch --await-message', () => { await expect(watching).resolves.toBeUndefined(); }, 5000); }); + +// ─── 10. watch --await-ask: architect decision notifier ───────────────────── + +describe('watch --await-ask', () => { + let cwd: string; + let server: Awaited>; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'ah-await-ask-')); + init(cwd, { projectName: 'await-ask', yes: true }); + server = await startServer(cwd, { host: '127.0.0.1', port: 0 }); + }); + + afterEach(async () => { + await server.app.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('returns immediately when a pending architect Ask already exists', async () => { + await fetch(`${server.url}/asks`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'codex', to: 'claude', question: 'Need decision' }), + }); + + await expect(watchEvents(server.url, { awaitAsk: true })).resolves.toBeUndefined(); + }, 4000); + + it('with --new-only exits only when a new Ask for the architect arrives', async () => { + const watching = watchEvents(server.url, { awaitAsk: true, newOnly: true }); + await new Promise((r) => setTimeout(r, 200)); + await fetch(`${server.url}/asks`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'kimi', to: 'architect', question: 'Can I proceed?' }), + }); + + await expect(watching).resolves.toBeUndefined(); + }, 5000); +}); diff --git a/tests/status.test.ts b/tests/status.test.ts index 320ded2..9535434 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -5,6 +5,8 @@ import { join } from 'path'; import { generateStatus } from '../src/core/status.js'; import { writeEntity } from '../src/core/files.js'; import { getEntityDir } from '../src/core/paths.js'; +import { init } from '../src/cli/commands/init.js'; +import { createAsk } from '../src/core/services/askService.js'; describe('status', () => { let cwd: string; @@ -18,6 +20,7 @@ describe('status', () => { }); it('generates a status file', () => { + init(cwd, { projectName: 'status-test', yes: true }); const taskDir = getEntityDir(cwd, 'tasks'); writeEntity(join(taskDir, 'TSK-0001.md'), { id: 'TSK-0001', title: 'Open task', status: 'open', createdAt: '2026-06-24T10:00:00Z', updatedAt: '2026-06-24T10:00:00Z' }, 'desc'); @@ -25,4 +28,16 @@ describe('status', () => { expect(status.activeTasks).toContain('TSK-0001'); expect(existsSync(join(cwd, '.agenthub', 'status', 'latest.md'))).toBe(true); }); + + it('surfaces pending Ask ids, count and oldest age as an urgent summary', () => { + init(cwd, { projectName: 'status-test', yes: true }); + createAsk(cwd, { from: 'codex', to: 'claude', question: 'Need a decision' }); + + const status = generateStatus(cwd); + + expect(status.pendingAsks).toEqual(['ASK-0001']); + expect(status.oldestAskAgeSec).toBeTypeOf('number'); + expect(status.summary).toContain('Pending asks: 1 (ASK-0001)'); + expect(status.summary).toContain('architect action required'); + }); }); diff --git a/tests/ui-shared.test.ts b/tests/ui-shared.test.ts index 22b63ca..ed0a410 100644 --- a/tests/ui-shared.test.ts +++ b/tests/ui-shared.test.ts @@ -76,4 +76,10 @@ describe('appHeader (v2 shared header)', () => { expect(appHeaderOnlyCss()).toContain('.app-header'); expect(appHeaderCss()).toContain('.modal-backdrop'); }); + it('makes the mobile nav horizontally scrollable with 44px tap targets', () => { + const css = appHeaderOnlyCss(); + expect(css).toMatch(/@media \(max-width: 680px\)[\s\S]*\.app-header \.b2-nav \{[^}]*overflow-x: auto/); + expect(css).toMatch(/@media \(max-width: 680px\)[\s\S]*\.app-header \.b2-nav a \{[^}]*min-height: 44px/); + expect(css).toContain('mask-image: linear-gradient'); + }); }); diff --git a/tests/watchdog.test.ts b/tests/watchdog.test.ts index 23abb8e..f38351f 100644 --- a/tests/watchdog.test.ts +++ b/tests/watchdog.test.ts @@ -10,6 +10,7 @@ import { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService. import { startWatchdog, resetWatchdog } from '../src/server/watchdog.js'; import { eventBus, type AgentHubEvent } from '../src/server/events.js'; import { enterLoop, leaveLoop, resetPresence } from '../src/core/services/presenceService.js'; +import { createAsk } from '../src/core/services/askService.js'; /** * Watchdog thresholds (TSK-0226). Fake timers drive both the scan interval and @@ -28,6 +29,7 @@ describe('watchdog', () => { const DEFAULT = 5_000; const STALE = 4_000; const REVIEW = 3_000; + const ASK = 4_000; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-')); @@ -39,6 +41,7 @@ describe('watchdog', () => { unclaimedHighMs: HIGH, unclaimedDefaultMs: DEFAULT, staleInProgressMs: STALE, + pendingAskMs: ASK, staleReviewMs: REVIEW, }; saveConfig(cwd, config); @@ -97,8 +100,17 @@ describe('watchdog', () => { await vi.advanceTimersByTimeAsync(INTERVAL); expect(remindersFor('codex')).toHaveLength(1); - // Threshold elapsed again since the last alert → second reminder. + // The base threshold elapsing again is NO LONGER enough: after the first + // reminder the gap doubles (see the backoff test below). Reminding at the + // base interval forever is exactly what buried an agent under 1500+ unread + // messages on 09.08., so the anti-spam guarantee this test is named for now + // has to hold across the whole lifetime of an unclaimed task, not just for + // one cooldown window. await vi.advanceTimersByTimeAsync(HIGH); + expect(remindersFor('codex')).toHaveLength(1); + + // Only the doubled gap releases the second reminder. + await vi.advanceTimersByTimeAsync(HIGH + INTERVAL); expect(remindersFor('codex')).toHaveLength(2); }); @@ -160,6 +172,7 @@ describe('watchdog', () => { expect(architectAlerts).toHaveLength(1); expect(architectAlerts[0].status).toBe('unread'); expect(architectAlerts[0].text).toContain('TSK-0001'); + expect(architectAlerts[0].text).toContain('silent'); expect(remindersFor('kimi')).toHaveLength(0); // Visibility only: the task still belongs to kimi (no auto-reassign). @@ -169,6 +182,28 @@ describe('watchdog', () => { expect(task.claimedBy).toBe('kimi'); }); + it('reports a pending Ask instead of misdiagnosing the waiting agent as merely silent', async () => { + createTask(cwd, { title: 'Blocked WIP', priority: 'high', assignedTo: 'windows-claude' }); + claimTask(cwd, 'TSK-0001', 'windows-claude'); + createAsk(cwd, { + from: 'windows-claude', + to: 'claude', + taskId: 'TSK-0001', + question: 'May I commit the finished build?', + }); + stop = startWatchdog(cwd); + + await vi.advanceTimersByTimeAsync(ASK + INTERVAL); + + const architectAlerts = listMessages(cwd).filter( + (m) => m.to === 'claude' && m.text.startsWith('Watchdog'), + ); + expect(architectAlerts).toHaveLength(1); + expect(architectAlerts[0].text).toContain('ASK-0001'); + expect(architectAlerts[0].text).toContain('wartet seit'); + expect(architectAlerts[0].text).not.toContain('no auto-reassign'); + }); + it('a fresh task-log line keeps an in_progress task from being flagged stale', async () => { createTask(cwd, { title: 'Chatty WIP', priority: 'high', assignedTo: 'kimi' }); claimTask(cwd, 'TSK-0001', 'kimi'); @@ -182,6 +217,66 @@ describe('watchdog', () => { expect(listMessages(cwd).filter((m) => m.text.startsWith('Watchdog:'))).toHaveLength(0); }); + it('never writes reminders into the mailbox of an agent that left the work loop', async () => { + // The 09.08. incident: the hub kept reminding an agent it KNEW was not + // consuming mail. 1500+ unread messages later its own work loop could no + // longer start. The alarm disabled the recipient — worse than no alarm. + createTask(cwd, { title: 'Urgent fix', priority: 'high', assignedTo: 'codex' }); + enterLoop('codex'); + leaveLoop('codex'); // → agentLoopStatus === 'inactive' (provably not reading) + stop = startWatchdog(cwd); + + // Many scans, far past the threshold. + await vi.advanceTimersByTimeAsync(HIGH * 12); + + // 1. Not a single durable reminder in the unreachable mailbox. + expect(remindersFor('codex')).toHaveLength(0); + + // 2. The architect is told — but exactly ONCE, no matter how long it runs. + const architectAlerts = listMessages(cwd).filter( + (m) => m.to === 'claude' && m.text.includes('not in agenthub_work'), + ); + expect(architectAlerts).toHaveLength(1); + expect(architectAlerts[0].taskId).toBe('TSK-0001'); + + // 3. The ephemeral SSE re-emit still happens — it costs nothing and may + // catch the agent mid-reconnect. + expect(events.some((e) => e.type === 'task' && e.id === 'TSK-0001')).toBe(true); + }); + + it('backs off exponentially instead of reminding at the base interval forever', async () => { + createTask(cwd, { title: 'Urgent fix', priority: 'high', assignedTo: 'codex' }); + enterLoop('codex'); // reachable — reminders are legitimate here + stop = startWatchdog(cwd); + + // First reminder at the base threshold, unchanged behaviour. + await vi.advanceTimersByTimeAsync(HIGH + INTERVAL); + expect(remindersFor('codex')).toHaveLength(1); + + // Another base interval must NOT produce a second one — the gap doubled. + await vi.advanceTimersByTimeAsync(HIGH); + expect(remindersFor('codex')).toHaveLength(1); + + // Once the doubled gap has elapsed, the second reminder arrives. + await vi.advanceTimersByTimeAsync(HIGH + INTERVAL); + expect(remindersFor('codex')).toHaveLength(2); + }); + + it('resets the backoff once the task is claimed', async () => { + createTask(cwd, { title: 'Urgent fix', priority: 'high', assignedTo: 'codex' }); + enterLoop('codex'); + stop = startWatchdog(cwd); + await vi.advanceTimersByTimeAsync(HIGH + INTERVAL); + expect(remindersFor('codex')).toHaveLength(1); + + // Claiming clears the per-task state, so a later re-open starts fresh + // instead of inheriting a half-muted, backed-off cooldown. + claimTask(cwd, 'TSK-0001', 'codex'); + await vi.advanceTimersByTimeAsync(HIGH + INTERVAL); + expect(getTask(cwd, 'TSK-0001').task.status).toBe('in_progress'); + expect(remindersFor('codex')).toHaveLength(1); // claimed ⇒ no further reminders + }); + it('alerts the architect when a review waits too long', async () => { createTask(cwd, { title: 'Review me', assignedTo: 'kimi' }); claimTask(cwd, 'TSK-0001', 'kimi');