feat(watch): --await-any — EIN Watcher fuer das ganze Board (v0.11.1)

Der Architekt muss nach jedem Wecken neu armen. Mit drei getrennten
Flags (--await-review, --await-message, --await-ask) heisst das drei
Dinge im Kopf behalten — und am 09.08. ist genau das passiert: nach
einem Review-Ereignis wurde nur der Review-Watcher neu armiert, der
Ask-Watcher war da schon beendet. Ein blockierender Ask von codex lag
daraufhin unbemerkt auf dem Board, waehrend der Architekt sich fuer
abgedeckt hielt. Der CEO hat ihn gefunden, nicht das Werkzeug.

--await-any <agent> weckt bei allem, was den Architekten angeht:
Task in Review, blockierender Ask, Direktnachricht an <agent>, neuer
Task. Ein Flag, ein Prozess, eine Sache zum Neu-Armen.

Zwei Details, die den Unterschied machen:
- OHNE --new-only wird der STEHENDE Rueckstau geprueft, nicht nur
  kuenftige Ereignisse: Reviews, Asks und ungelesene Nachrichten. Ein
  Review, das schon wartete, als der Watcher verband, ist genau der
  Fall, der sonst durchrutscht.
- --ignore-from wirkt weiter. Watchdog-Erinnerungen duerfen nicht
  wecken; ein Alarm, der alle paar Minuten auf nichts feuert, erzieht
  seinen Empfaenger dazu, ihn zu ignorieren.

Die drei Einzel-Flags bleiben unveraendert bestehen.

321 Tests gruen (3 neue), tsc --noEmit sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-08-09 19:40:31 +02:00
parent b8ad801c1d
commit 6d479d94ca
5 changed files with 112 additions and 3 deletions

View File

@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "0.11.0",
"version": "0.11.1",
"description": "Local coordination layer for AI coding agents",
"type": "module",
"main": "./dist/index.js",

View File

@ -208,6 +208,20 @@ function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[]
return !ignoreFrom.some((ignored) => ignored.trim().toLowerCase() === sender);
}
/**
* Every board event the architect must react to, in ONE predicate:
* a task submitted for review, a blocking Ask, a direct message, or a newly
* created task. Watchdog reminders stay filtered via `ignoreFrom` an alarm
* that fires every few minutes on nothing trains its recipient to ignore it.
*/
function isBoardEvent(event: AgentHubEvent, agent: string, ignoreFrom: string[] = []): boolean {
if (event.type === 'task' && event.status === 'review') return true;
if (isAskForArchitect(event)) return true;
if (isMessageFor(event, agent, ignoreFrom)) return true;
if (event.type === 'task' && event.action === 'created') return true;
return false;
}
/**
* Connect to the AgentHub server's SSE endpoint and stream events to stdout.
*
@ -222,7 +236,7 @@ function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[]
*/
export async function watchEvents(
serverUrl: string,
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; awaitAsk?: boolean; newOnly?: boolean; ignoreFrom?: string[] } = {},
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; awaitAsk?: boolean; awaitAny?: string; newOnly?: boolean; ignoreFrom?: string[] } = {},
): Promise<void> {
const url = new URL('/events', serverUrl);
// Pass role to the server for an additional server-side filter (saves
@ -283,6 +297,24 @@ export async function watchEvents(
return;
}
}
// --await-any checks the whole standing backlog, not just future events.
// A review or Ask that was already waiting when the watcher connected is
// exactly the case that goes unnoticed otherwise — the architect arms a
// watcher, believes he is covered, and the thing needing him sits there.
if (options.awaitAny && !options.newOnly) {
const standing = [
...(await fetchReviewTasks(serverUrl)),
...(await fetchPendingArchitectAsks(serverUrl)),
...(await fetchUnreadMessages(serverUrl, options.awaitAny)).filter((ev) =>
isMessageFor(ev, options.awaitAny!, options.ignoreFrom),
),
];
if (standing.length > 0) {
for (const ev of standing) console.log(formatEvent(ev));
await reader.cancel();
return;
}
}
while (true) {
let done: boolean;
@ -330,6 +362,14 @@ export async function watchEvents(
await reader.cancel();
return;
}
// --await-any: ONE watcher for the whole board. The architect has to
// re-arm after every wake-up, and juggling three separate watchers means
// one of them silently lapses — that happened on 2026-08-09 and a pending
// Ask sat unseen. A single arm-and-forget flag removes the bookkeeping.
if (options.awaitAny && isBoardEvent(event, options.awaitAny, options.ignoreFrom)) {
await reader.cancel();
return;
}
}
}
}

View File

@ -1035,6 +1035,7 @@ export function createProgram(cwd: string): Command {
.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('--await-ask', 'Exit when a pending Ask arrives for the architect')
.option('--await-any <agent>', 'ONE watcher for the whole board: review, Ask, message for <agent>, or a new task. Replaces juggling three separate --await flags')
.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) => {
@ -1050,6 +1051,7 @@ export function createProgram(cwd: string): Command {
awaitReview: options.awaitReview as boolean | undefined,
awaitMessage: options.awaitMessage as string | undefined,
awaitAsk: options.awaitAsk as boolean | undefined,
awaitAny: options.awaitAny as string | undefined,
ignoreFrom: typeof options.ignoreFrom === 'string' ? options.ignoreFrom.split(',') : undefined,
newOnly: options.newOnly as boolean | undefined,
});

View File

@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join, parse } from 'node:path';
/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */
export const VERSION = '0.11.0';
export const VERSION = '0.11.1';
/** Locate the agenthub install dir by walking up from this file. */
function findPackageRoot(): string | undefined {

View File

@ -827,3 +827,70 @@ describe('watch --await-ask', () => {
await expect(watching).resolves.toBeUndefined();
}, 5000);
});
// ─── 11. watch --await-any: ONE watcher for the whole board ─────────────────
describe('watch --await-any', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-await-any-'));
init(cwd, { projectName: 'await-any', 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 });
});
// The 2026-08-09 failure: three separate watchers, one lapses unnoticed, and
// a pending Ask sits there while the architect believes he is covered.
it('wakes on a standing Ask that was already pending when it connected', async () => {
await fetch(`${server.url}/asks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'codex', to: 'claude', question: 'Schema decision needed' }),
});
await expect(watchEvents(server.url, { awaitAny: 'claude' })).resolves.toBeUndefined();
}, 4000);
it('wakes on a task submitted for review', async () => {
const watching = watchEvents(server.url, { awaitAny: 'claude', newOnly: true });
await new Promise((r) => setTimeout(r, 200));
const created = await fetch(`${server.url}/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Review me', role: 'implementer', assignedTo: 'codex' }),
});
const task = (await created.json()) as { id: string };
await fetch(`${server.url}/tasks/${task.id}/claim`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent: 'codex' }),
});
await fetch(`${server.url}/tasks/${task.id}/review`, { method: 'POST' });
await expect(watching).resolves.toBeUndefined();
}, 6000);
it('wakes on a direct message but NOT on watchdog noise', async () => {
const watching = watchEvents(server.url, {
awaitAny: 'claude', newOnly: true, ignoreFrom: ['agenthub'],
});
await new Promise((r) => setTimeout(r, 200));
// Watchdog reminder first — must NOT wake the architect. An alarm that
// fires on nothing every few minutes trains its recipient to ignore it.
await fetch(`${server.url}/messages`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'agenthub', to: 'claude', text: 'Watchdog: still silent' }),
});
await new Promise((r) => setTimeout(r, 300));
// A real agent message must.
await fetch(`${server.url}/messages`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'kimi', to: 'claude', text: 'Blocked, need a call' }),
});
await expect(watching).resolves.toBeUndefined();
}, 6000);
});