fix(hub): offene Asks sichtbar machen + Erinnerungsflut stoppen (TSK-0406)
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) <noreply@anthropic.com>
This commit is contained in:
parent
df698c9dff
commit
6fa94116a4
@ -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<Ag
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch pending Asks for the architect/legacy claude recipient aliases. */
|
||||
async function fetchPendingArchitectAsks(serverUrl: string): Promise<AgentHubEvent[]> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1034,7 +1034,8 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--role <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 <agent>', '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 <agents>', '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,
|
||||
});
|
||||
|
||||
@ -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({});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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) }))
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -27,6 +27,10 @@ export function kpiSkeletonHtml(): string {
|
||||
<div class="b2-cap-r" data-k="cap"></div>
|
||||
<div class="b2-pbar"><i data-k="bar"></i></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="b2-asks b2-glass" id="pendingAsks" aria-label="Offene Entscheidungen" hidden>
|
||||
<div class="b2-asks-head"><strong>Offene Asks</strong><span data-ask-count>0</span></div>
|
||||
<div class="b2-asks-list" data-ask-list></div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
@ -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 '<span class="b2-ask"><b>' + safe(a.id) + '</b><i>' + safe(a.from) + ' wartet</i><em>' + age(a.createdAt) + '</em>' + (a.taskId ? '<a href="/tasks/' + encodeURIComponent(a.taskId) + '">' + safe(a.taskId) + '</a>' : '') + '</span>';
|
||||
}).join('');
|
||||
} catch (_) {}
|
||||
};
|
||||
window.__b2RefreshAsks();`;
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
}`;
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, number>();
|
||||
|
||||
/**
|
||||
* 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<string, number>();
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<ReturnType<typeof startServer>>;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user