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');