From 6d479d94cac3ecac0a5e7173da1b18a7eab7d93b Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 9 Aug 2026 19:40:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(watch):=20--await-any=20=E2=80=94=20EIN=20?= =?UTF-8?q?Watcher=20fuer=20das=20ganze=20Board=20(v0.11.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 weckt bei allem, was den Architekten angeht: Task in Review, blockierender Ask, Direktnachricht an , 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) --- package.json | 2 +- src/cli/commands/watch.ts | 42 +++++++++++++++++++++++- src/cli/index.ts | 2 ++ src/version.ts | 2 +- tests/sse.test.ts | 67 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index dedc223..e1bf571 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index 4c50600..6ed6df9 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -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 { 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; + } } } } diff --git a/src/cli/index.ts b/src/cli/index.ts index e89ea9f..6e12f38 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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 ', '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 ', 'ONE watcher for the whole board: review, Ask, message for , 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 ', '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, }); diff --git a/src/version.ts b/src/version.ts index d2f5ce2..348e74e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -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 { diff --git a/tests/sse.test.ts b/tests/sse.test.ts index ac1738a..1a4ac8b 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -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>; + + 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); +});