chore(agenthub): snapshot pre-existing orphaned WIP (single-instance/mDNS + claimedBy + message-read) as batch baseline

This commit is contained in:
chahinebrini 2026-07-12 00:46:47 +02:00
parent 2fb9db15b1
commit 7922eeb8e8
26 changed files with 645 additions and 147 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import { createMessage, listInbox } from '../../core/services/messageService.js'; import { createMessage, listInbox, markMessageRead } from '../../core/services/messageService.js';
export function messageSend( export function messageSend(
cwd: string, cwd: string,
@ -19,3 +19,14 @@ export function inboxList(cwd: string, opts: { agent: string; unreadOnly?: boole
console.log(`${flag} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); console.log(`${flag} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
} }
} }
export function messageRead(cwd: string, id: string): void {
const m = markMessageRead(cwd, id);
console.log(`AgentHub: Message read ${m.id} (${m.from}${m.to})`);
}
export function inboxMarkRead(cwd: string, opts: { agent: string; unreadOnly?: boolean }): void {
const msgs = listInbox(cwd, opts.agent, { unreadOnly: opts.unreadOnly });
for (const m of msgs) markMessageRead(cwd, m.id);
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${opts.agent}`);
}

View File

@ -15,6 +15,7 @@
*/ */
import type { AgentHubEvent } from '../../server/events.js'; import type { AgentHubEvent } from '../../server/events.js';
import { messageRecipientAliases } from '../../core/services/messageService.js';
// Re-export so tests can import type + helpers from one place. // Re-export so tests can import type + helpers from one place.
export type { AgentHubEvent } from '../../server/events.js'; export type { AgentHubEvent } from '../../server/events.js';
@ -134,6 +135,36 @@ async function fetchReviewTasks(serverUrl: string): Promise<AgentHubEvent[]> {
} }
} }
/** Fetch unread messages currently addressed to an agent or its role alias. */
async function fetchUnreadMessages(serverUrl: string, agent: string): Promise<AgentHubEvent[]> {
try {
const res = await fetch(new URL(`/messages?agent=${encodeURIComponent(agent)}&unread=1`, serverUrl).toString());
if (!res.ok) return [];
const messages = (await res.json()) as Array<{
id: string;
from?: string;
to?: string;
status?: string;
}>;
return messages.map((m) => ({
type: 'message',
action: 'created',
id: m.id,
title: `${m.from ?? ''}${m.to ?? ''}`,
status: m.status ?? 'unread',
assignedTo: m.to,
}));
} catch {
return [];
}
}
function isMessageFor(event: AgentHubEvent, agent: string): boolean {
if (event.type !== 'message' || event.action !== 'created') return false;
const to = event.assignedTo;
return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase());
}
/** /**
* Connect to the AgentHub server's SSE endpoint and stream events to stdout. * Connect to the AgentHub server's SSE endpoint and stream events to stdout.
* *
@ -148,7 +179,7 @@ async function fetchReviewTasks(serverUrl: string): Promise<AgentHubEvent[]> {
*/ */
export async function watchEvents( export async function watchEvents(
serverUrl: string, serverUrl: string,
options: { once?: boolean; role?: string; awaitReview?: boolean; newOnly?: boolean } = {}, options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {},
): Promise<void> { ): Promise<void> {
const url = new URL('/events', serverUrl); const url = new URL('/events', serverUrl);
// Pass role to the server for an additional server-side filter (saves // Pass role to the server for an additional server-side filter (saves
@ -192,6 +223,14 @@ export async function watchEvents(
return; return;
} }
} }
if (options.awaitMessage && !options.newOnly) {
const pending = await fetchUnreadMessages(serverUrl, options.awaitMessage);
if (pending.length > 0) {
for (const ev of pending) console.log(formatEvent(ev));
await reader.cancel();
return;
}
}
while (true) { while (true) {
let done: boolean; let done: boolean;
@ -230,6 +269,10 @@ export async function watchEvents(
await reader.cancel(); await reader.cancel();
return; return;
} }
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
await reader.cancel();
return;
}
} }
} }
} }

View File

@ -1,5 +1,6 @@
import { parseSSEBuffer } from './watch.js'; import { parseSSEBuffer } from './watch.js';
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js'; import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
import { discoverServer as discoverHubServer } from '../../discovery.js';
/** /**
* `agenthub work --agent <name> --role <role>` the auto-claim primitive * `agenthub work --agent <name> --role <role>` the auto-claim primitive
@ -13,7 +14,13 @@ import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentCont
* up automatically without a human prompt. Best run in the background so the * up automatically without a human prompt. Best run in the background so the
* wait doesn't tie up the foreground. * wait doesn't tie up the foreground.
*/ */
export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Promise<void> { interface WorkAgentContext extends AgentContext {
timeoutSec?: number;
discoverServer?: (timeoutMs?: number) => Promise<string | undefined>;
reconnectBackoffMs?: number[];
}
export async function workAgent(ctx: WorkAgentContext): Promise<void> {
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role); await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
// Already-waiting task? // Already-waiting task?
@ -34,16 +41,30 @@ export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Pr
await waitAndClaim(ctx); await waitAndClaim(ctx);
} }
function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void> { function remainingMs(deadline: number | undefined): number {
const serverUrl = ctx.serverUrl as string; return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function resolveReconnectUrl(ctx: WorkAgentContext, currentUrl: string): Promise<string> {
if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER;
const discovered = await (ctx.discoverServer ?? discoverHubServer)(2000);
return discovered || currentUrl;
}
function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
let serverUrl = ctx.serverUrl as string;
return new Promise((resolve) => { return new Promise((resolve) => {
const controller = new AbortController();
let settled = false; let settled = false;
let controller: AbortController | undefined;
const finish = () => { const finish = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
try { try {
controller.abort(); controller?.abort();
} catch { } catch {
/* already aborted */ /* already aborted */
} }
@ -56,10 +77,14 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
finish(); finish();
}, ctx.timeoutSec * 1000) }, ctx.timeoutSec * 1000)
: undefined; : undefined;
const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined;
const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000];
let reconnectAttempt = 0;
// Re-query then claim if a task addressed to us is now open. Returns true // Re-query then claim if a task addressed to us is now open. Returns true
// if a task was claimed (so the caller can stop). // if a task was claimed (so the caller can stop).
const tryClaim = async (): Promise<boolean> => { const tryClaim = async (): Promise<boolean> => {
ctx.serverUrl = serverUrl;
const f = await findAddressedOpenTask(ctx); const f = await findAddressedOpenTask(ctx);
if (!f) return false; if (!f) return false;
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
@ -68,13 +93,14 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
return true; return true;
}; };
fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } }) const waitLoop = async () => {
.then(async (res) => { while (!settled && remainingMs(deadline) > 0) {
if (!res.body) { controller = new AbortController();
if (timer) clearTimeout(timer); try {
finish(); ctx.serverUrl = serverUrl;
return; const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } });
} if (!res.body) throw new Error('SSE response has no body');
// Close the gap: a task may have appeared between the initial check and // Close the gap: a task may have appeared between the initial check and
// this subscription — check once more now that we're listening. // this subscription — check once more now that we're listening.
if (await tryClaim()) return; if (await tryClaim()) return;
@ -82,6 +108,7 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
reconnectAttempt = 0;
while (!settled) { while (!settled) {
let done: boolean; let done: boolean;
let value: Uint8Array | undefined; let value: Uint8Array | undefined;
@ -100,13 +127,28 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
if (await tryClaim()) return; if (await tryClaim()) return;
} }
} }
if (timer) clearTimeout(timer); } catch (err: unknown) {
finish(); if (settled || (err instanceof Error && err.name === 'AbortError')) return;
})
.catch((err: unknown) => {
if (!(err instanceof Error && err.name === 'AbortError')) {
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
} }
if (settled || remainingMs(deadline) <= 0) break;
serverUrl = await resolveReconnectUrl(ctx, serverUrl);
ctx.serverUrl = serverUrl;
const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000;
reconnectAttempt += 1;
const delay = deadline === undefined ? backoff : Math.min(backoff, remainingMs(deadline));
if (delay > 0) await sleep(delay);
}
if (!settled) {
if (timer) {
clearTimeout(timer);
console.log(`AgentHub: no task for ${ctx.agent} after ${ctx.timeoutSec}s — exiting.`);
}
finish();
}
};
waitLoop().catch((err: unknown) => {
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
finish(); finish();
}); });

View File

@ -5,7 +5,7 @@ import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './co
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js'; import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js'; import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList } from './commands/message.js'; import { messageSend, inboxList, messageRead, inboxMarkRead } from './commands/message.js';
import { agentSetup, hookContext } from './commands/agentSetup.js'; import { agentSetup, hookContext } from './commands/agentSetup.js';
import { syncOrgFromFile } from '../core/services/orgService.js'; import { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js'; import { delegate } from './commands/delegate.js';
@ -457,8 +457,44 @@ export function createProgram(cwd: string): Command {
program.addCommand(decisionCmd); program.addCommand(decisionCmd);
// ─── messaging ─────────────────────────────────────────────────────────── // ─── messaging ───────────────────────────────────────────────────────────
program const messageCmd = new Command('message')
.command('message <to> <text>') .description('Send and manage direct messages')
.argument('[to]', 'Recipient agent/role')
.argument('[text]', 'Message text')
.option('--from <agent>', 'Sender agent name')
.option('--task <id>', 'Related task ID')
.action(async (to: string | undefined, text: string | undefined, options: { from?: string; task?: string }) => {
if (!to || !text || !options.from) {
messageCmd.help({ error: true });
return;
}
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
const payload = { from: options.from, to, text, taskId: options.task };
if (serverUrl) {
await runRemote(serverUrl, async () => {
const m = await remoteClient.sendMessage(serverUrl, payload);
console.log(`AgentHub: Message sent ${m.id} (${m.from}${m.to})`);
});
} else {
messageSend(projectCwd, payload);
}
});
messageCmd
.command('read <id>')
.description('Mark a message as read')
.action(async (id: string) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const m = await remoteClient.markMessageRead(serverUrl, id);
console.log(`AgentHub: Message read ${m.id} (${m.from}${m.to})`);
});
} else {
messageRead(projectCwd, id);
}
});
messageCmd
.command('send <to> <text>')
.description('Send a direct message to another agent') .description('Send a direct message to another agent')
.requiredOption('--from <agent>', 'Sender agent name') .requiredOption('--from <agent>', 'Sender agent name')
.option('--task <id>', 'Related task ID') .option('--task <id>', 'Related task ID')
@ -474,14 +510,26 @@ export function createProgram(cwd: string): Command {
messageSend(projectCwd, payload); messageSend(projectCwd, payload);
} }
}); });
program.addCommand(messageCmd);
program program
.command('inbox') .command('inbox')
.description('Read messages addressed to an agent') .description('Read messages addressed to an agent')
.requiredOption('--agent <agent>', 'Agent whose inbox to read') .requiredOption('--agent <agent>', 'Agent whose inbox to read')
.option('--unread', 'Only unread messages') .option('--unread', 'Only unread messages')
.action(async (options: { agent: string; unread?: boolean }) => { .option('--mark-read', 'Mark listed messages as read')
.option('--wait', 'Wait until a new unread message arrives for this agent')
.action(async (options: { agent: string; unread?: boolean; markRead?: boolean; wait?: boolean }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd); const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (options.wait) {
if (!serverUrl) {
console.error('No AgentHub server found. Start one with: agenthub server start --host 0.0.0.0');
process.exit(1);
return;
}
await watchEvents(serverUrl, { awaitMessage: options.agent, newOnly: true });
return;
}
if (serverUrl) { if (serverUrl) {
await runRemote(serverUrl, async () => { await runRemote(serverUrl, async () => {
const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread); const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread);
@ -489,9 +537,14 @@ export function createProgram(cwd: string): Command {
for (const m of msgs) { for (const m of msgs) {
console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
} }
if (options.markRead) {
for (const m of msgs) await remoteClient.markMessageRead(serverUrl, m.id);
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${options.agent}`);
}
}); });
} else { } else {
inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread }); if (options.markRead) inboxMarkRead(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
else inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
} }
}); });
@ -639,7 +692,8 @@ export function createProgram(cwd: string): Command {
.option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)') .option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)')
.option('--role <role>', 'Client-side role filter (only show events for this role)') .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-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
.option('--new-only', 'With --await-review: fire only on NEW submissions, ignore tasks already in review on connect (re-armable without spinning)') .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)')
.action(async (options) => { .action(async (options) => {
const { serverUrl } = await resolveContext(program, cwd); const { serverUrl } = await resolveContext(program, cwd);
if (!serverUrl) { if (!serverUrl) {
@ -651,6 +705,7 @@ export function createProgram(cwd: string): Command {
once: options.once as boolean | undefined, once: options.once as boolean | undefined,
role: options.role as string | undefined, role: options.role as string | undefined,
awaitReview: options.awaitReview as boolean | undefined, awaitReview: options.awaitReview as boolean | undefined,
awaitMessage: options.awaitMessage as string | undefined,
newOnly: options.newOnly as boolean | undefined, newOnly: options.newOnly as boolean | undefined,
}); });
}); });

View File

@ -14,6 +14,7 @@ export interface IndexEntry {
status?: string; status?: string;
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
claimedBy?: string;
reviewer?: string; reviewer?: string;
tags?: string; tags?: string;
// Handoff-specific routing fields // Handoff-specific routing fields
@ -46,6 +47,7 @@ export class Index {
status TEXT, status TEXT,
role TEXT, role TEXT,
assignedTo TEXT, assignedTo TEXT,
claimedBy TEXT,
reviewer TEXT, reviewer TEXT,
tags TEXT, tags TEXT,
fromRole TEXT, fromRole TEXT,
@ -62,7 +64,7 @@ export class Index {
const existingCols = new Set( const existingCols = new Set(
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), (this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name),
); );
for (const col of ['reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) { for (const col of ['claimedBy', 'reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
if (!existingCols.has(col)) { if (!existingCols.has(col)) {
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
} }
@ -74,6 +76,7 @@ export class Index {
status: null, status: null,
role: null, role: null,
assignedTo: null, assignedTo: null,
claimedBy: null,
reviewer: null, reviewer: null,
tags: null, tags: null,
fromRole: null, fromRole: null,
@ -86,12 +89,12 @@ export class Index {
}; };
const insert = this.db.prepare(` const insert = this.db.prepare(`
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks) INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, claimedBy, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks) VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @claimedBy, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type=@type, title=@title, content=@content, filePath=@filePath, type=@type, title=@title, content=@content, filePath=@filePath,
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
assignedTo=@assignedTo, reviewer=@reviewer, tags=@tags, assignedTo=@assignedTo, claimedBy=@claimedBy, reviewer=@reviewer, tags=@tags,
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent, fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
taskId=@taskId, relatedTasks=@relatedTasks taskId=@taskId, relatedTasks=@relatedTasks
`); `);

View File

@ -15,6 +15,7 @@ export const TaskSchema = z.object({
priority: Priority.default('medium'), priority: Priority.default('medium'),
role: Role.optional(), role: Role.optional(),
assignedTo: z.string().optional(), assignedTo: z.string().optional(),
claimedBy: z.string().optional(),
reviewer: z.string().optional(), reviewer: z.string().optional(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),

View File

@ -100,7 +100,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
let summary: string; let summary: string;
switch (task.status) { switch (task.status) {
case 'in_progress': case 'in_progress':
summary = task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed'; summary = task.claimedBy ? `Claimed by ${task.claimedBy}` : task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed';
break; break;
case 'review': case 'review':
summary = 'Submitted for review'; summary = 'Submitted for review';
@ -117,6 +117,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
const meta: Record<string, unknown> = { status: task.status }; const meta: Record<string, unknown> = { status: task.status };
if (task.assignedTo) meta.assignedTo = task.assignedTo; if (task.assignedTo) meta.assignedTo = task.assignedTo;
if (task.claimedBy) meta.claimedBy = task.claimedBy;
if (task.doneBy) meta.by = task.doneBy; if (task.doneBy) meta.by = task.doneBy;
if (task.doneTokens != null) meta.tokens = task.doneTokens; if (task.doneTokens != null) meta.tokens = task.doneTokens;
if (task.doneDuration != null) meta.duration = task.doneDuration; if (task.doneDuration != null) meta.duration = task.doneDuration;
@ -124,7 +125,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
items.push({ items.push({
at: task.updatedAt, at: task.updatedAt,
kind: 'status', kind: 'status',
actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown', actor: task.doneBy ?? task.claimedBy ?? task.assignedTo ?? task.role ?? 'unknown',
summary, summary,
meta, meta,
}); });

View File

@ -49,6 +49,17 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
return record; return record;
} }
/** Agent aliases that should see the same inbox. Keep deliberately small. */
export function messageRecipientAliases(agent: string): Set<string> {
const key = agent.toLowerCase();
const aliases = new Set([agent, key]);
if (key === 'architect' || key === 'claude') {
aliases.add('architect');
aliases.add('claude');
}
return aliases;
}
export interface InboxMessage { export interface InboxMessage {
id: string; id: string;
from: string; from: string;
@ -64,8 +75,9 @@ export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boole
const index = new Index(cwd); const index = new Index(cwd);
const all = index.list('message'); const all = index.list('message');
index.close(); index.close();
const recipients = messageRecipientAliases(agent);
return all return all
.filter((m) => m.toAgent === agent) .filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
.filter((m) => !opts.unreadOnly || m.status === 'unread') .filter((m) => !opts.unreadOnly || m.status === 'unread')
.map((m) => ({ .map((m) => ({
id: m.id, id: m.id,

View File

@ -17,6 +17,7 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
priority: options.priority ?? 'medium', priority: options.priority ?? 'medium',
role: options.role, role: options.role,
assignedTo: options.assignedTo, assignedTo: options.assignedTo,
claimedBy: options.claimedBy,
reviewer: options.reviewer, reviewer: options.reviewer,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -59,7 +60,7 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
} }
export function claimTask(cwd: string, id: string, agentName: string): Task { export function claimTask(cwd: string, id: string, agentName: string): Task {
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName }); return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
} }
export function doneTask( export function doneTask(
@ -141,6 +142,7 @@ function toIndexEntry(task: Task, filePath: string) {
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer, reviewer: task.reviewer,
tags: JSON.stringify(task.tags), tags: JSON.stringify(task.tags),
}; };

View File

@ -22,17 +22,18 @@ export function resolveAdvertiseUrl(host: string, port: number): string {
return `http://${advertiseHost}:${port}`; return `http://${advertiseHost}:${port}`;
} }
export function startDiscoveryBroadcaster(serverUrl: string, options?: { port?: number; intervalMs?: number }) { export function startDiscoveryBroadcaster(getServerUrl: string | (() => string), options?: { port?: number; intervalMs?: number }) {
const port = options?.port ?? DISCOVERY_PORT; const port = options?.port ?? DISCOVERY_PORT;
const intervalMs = options?.intervalMs ?? 2000; const intervalMs = options?.intervalMs ?? 2000;
const socket = dgram.createSocket('udp4'); const socket = dgram.createSocket('udp4');
const message = Buffer.from(`${DISCOVERY_PREFIX}${serverUrl}`); const resolveUrl = typeof getServerUrl === 'function' ? getServerUrl : () => getServerUrl;
socket.on('error', () => { socket.on('error', () => {
// Discovery is best-effort; ignore network errors. // Discovery is best-effort; ignore network errors.
}); });
const send = () => { const send = () => {
const message = Buffer.from(`${DISCOVERY_PREFIX}${resolveUrl()}`);
try { try {
socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST); socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST);
} catch { } catch {

View File

@ -21,6 +21,7 @@ import { createMessage, listInbox, markMessageRead } from '../core/services/mess
import { addMemory, searchMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js'; import { createDecision } from '../core/services/decisionService.js';
import { getStatus } from '../core/services/statusService.js'; import { getStatus } from '../core/services/statusService.js';
import { discoverServer as discoverHubServer } from '../discovery.js';
/** /**
* AgentHub MCP server (TSK-0030). * AgentHub MCP server (TSK-0030).
@ -51,28 +52,57 @@ function asText(value: unknown) {
return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] }; return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
} }
function remainingMs(deadline: number | undefined): number {
return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function resolveReconnectUrl(currentUrl: string): Promise<string> {
if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER;
const discovered = await discoverHubServer(2000);
return discovered || currentUrl;
}
/** Block on the SSE stream until findClaim() returns a task, or timeout. */ /** Block on the SSE stream until findClaim() returns a task, or timeout. */
function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, timeoutSec: number): Promise<T | null> { function waitForTask<T>(
serverUrl: string,
findClaim: (serverUrl: string) => Promise<T | null>,
timeoutSec: number,
): Promise<T | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const controller = new AbortController();
let settled = false; let settled = false;
let controller: AbortController | undefined;
let currentUrl = serverUrl;
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
const backoffs = [2000, 5000, 10000];
let reconnectAttempt = 0;
const finish = (v: T | null) => { const finish = (v: T | null) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
try { controller.abort(); } catch { /* already */ } try { controller?.abort(); } catch { /* already */ }
resolve(v); resolve(v);
}; };
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000); const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } }) const waitLoop = async () => {
.then(async (res) => { while (!settled && remainingMs(deadline) > 0) {
if (!res.body) { clearTimeout(timer); finish(null); return; } controller = new AbortController();
try {
const res = await fetch(new URL('/events', currentUrl).toString(), {
signal: controller.signal,
headers: { Accept: 'text/event-stream' },
});
if (!res.body) throw new Error('SSE response has no body');
// Close the gap: a task may have arrived between the initial check and now. // Close the gap: a task may have arrived between the initial check and now.
const early = await findClaim(); const early = await findClaim(currentUrl);
if (early) { clearTimeout(timer); finish(early); return; } if (early) { clearTimeout(timer); finish(early); return; }
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
reconnectAttempt = 0;
while (!settled) { while (!settled) {
let done: boolean; let value: Uint8Array | undefined; let done: boolean; let value: Uint8Array | undefined;
try { ({ done, value } = await reader.read()); } catch { break; } try { ({ done, value } = await reader.read()); } catch { break; }
@ -82,13 +112,27 @@ function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, t
buffer = remaining; buffer = remaining;
// Wake on a new task OR a new message addressed to the agent. // Wake on a new task OR a new message addressed to the agent.
if (events.some((e) => e.type === 'task' || e.type === 'message')) { if (events.some((e) => e.type === 'task' || e.type === 'message')) {
const claimed = await findClaim(); const claimed = await findClaim(currentUrl);
if (claimed) { clearTimeout(timer); finish(claimed); return; } if (claimed) { clearTimeout(timer); finish(claimed); return; }
} }
} }
clearTimeout(timer); finish(null); } catch (err: unknown) {
}) if (settled || (err instanceof Error && err.name === 'AbortError')) return;
.catch(() => { clearTimeout(timer); finish(null); }); }
if (settled || remainingMs(deadline) <= 0) break;
currentUrl = await resolveReconnectUrl(currentUrl);
const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000;
reconnectAttempt += 1;
await sleep(Math.min(backoff, remainingMs(deadline)));
}
clearTimeout(timer);
finish(null);
};
waitLoop().catch(() => {
clearTimeout(timer);
finish(null);
});
}); });
} }
@ -110,37 +154,44 @@ export async function startMcpServer(cwd: string): Promise<void> {
async ({ agent, role, timeoutSec }) => { async ({ agent, role, timeoutSec }) => {
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' }; const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
const reviewer = isReviewerRole(ctx.role); const reviewer = isReviewerRole(ctx.role);
const useServerUrl = (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
return ctx.serverUrl!;
};
// Fetch + mark-read the agent's unread messages, so the work loop surfaces // Fetch + mark-read the agent's unread messages, so the work loop surfaces
// them once and doesn't spin on the same message. // them once and doesn't spin on the same message.
const drainInbox = async () => { const drainInbox = async (nextServerUrl?: string) => {
const msgs = remote ? await remoteClient.getInbox(serverUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true }); const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl;
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
for (const m of msgs) { for (const m of msgs) {
try { if (remote) await remoteClient.markMessageRead(serverUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ } try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
} }
return msgs; return msgs;
}; };
const findWork = async () => { const findWork = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl);
const found = await findAddressedOpenTask(ctx); const found = await findAddressedOpenTask(ctx);
if (found) { if (found) {
if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent); if (remote) await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent);
else claimTask(root, found.task.id, agent); else claimTask(root, found.task.id, agent);
const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id); const detail = remote ? await remoteClient.getTask(ctx.serverUrl!, found.task.id) : getTask(root, found.task.id);
const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id); const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
let handoff: unknown = null; let handoff: unknown = null;
if (hofEntry) { if (hofEntry) {
try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ } try { handoff = remote ? await remoteClient.getHandoff(ctx.serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
} }
return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox() }; return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox(ctx.serverUrl) };
} }
const messages = await drainInbox(); const messages = await drainInbox(ctx.serverUrl);
if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' }; if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' };
return null; return null;
}; };
// Architect/reviewer variant: wake on tasks submitted to review (not on // Architect/reviewer variant: wake on tasks submitted to review (not on
// tasks addressed to you). Returns the pending review set — never claims. // tasks addressed to you). Returns the pending review set — never claims.
const findReview = async () => { const findReview = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl);
const reviews = await listReviewTasks(ctx); const reviews = await listReviewTasks(ctx);
const messages = await drainInbox(); const messages = await drainInbox(ctx.serverUrl);
if (reviews.length) { if (reviews.length) {
return { return {
reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })), reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })),
@ -152,7 +203,7 @@ export async function startMcpServer(cwd: string): Promise<void> {
return null; return null;
}; };
const finder: () => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork; const finder: (nextServerUrl?: string) => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork;
// Self-perpetuating loop: every response reminds the agent to relaunch // Self-perpetuating loop: every response reminds the agent to relaunch
// agenthub_work, so a finished task/message never leaves it dormant. // agenthub_work, so a finished task/message never leaves it dormant.

View File

@ -1000,7 +1000,7 @@ ${columnSkeleton()}
return compactDuration(Date.now() - t) + ' ago'; return compactDuration(Date.now() - t) + ' ago';
} }
async function getJSON(path) { async function getJSON(path) {
var res = await fetch(path, { headers: { accept: 'application/json' } }); var res = await fetch(path, { cache: 'no-store', headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(path + ' -> ' + res.status); if (!res.ok) throw new Error(path + ' -> ' + res.status);
return res.json(); return res.json();
} }
@ -1324,6 +1324,8 @@ ${columnSkeleton()}
eventSourceReady = true; eventSourceReady = true;
stopFallbackPoll(); stopFallbackPoll();
setConn('ok', 'connected'); setConn('ok', 'connected');
refresh();
refreshBudget();
}; };
source.onmessage = function() { source.onmessage = function() {
eventSourceReady = true; eventSourceReady = true;
@ -1354,6 +1356,21 @@ ${columnSkeleton()}
} }
window.addEventListener('pagehide', closeEvents); window.addEventListener('pagehide', closeEvents);
window.addEventListener('beforeunload', closeEvents); window.addEventListener('beforeunload', closeEvents);
window.addEventListener('pageshow', function() {
refresh();
refreshBudget();
if (!eventSource) connectEvents();
});
document.addEventListener('visibilitychange', function() {
if (document.hidden) return;
refresh();
refreshBudget();
if (!eventSource) connectEvents();
});
window.addEventListener('focus', function() {
refresh();
refreshBudget();
});
// ── Roster ────────────────────────────────────────────────────────────── // ── Roster ──────────────────────────────────────────────────────────────
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a // AGENTS backs the budget panel + resolves a title's "name:" prefix to a
@ -1697,15 +1714,11 @@ ${columnSkeleton()}
try { try {
if (status === 'in_progress') { if (status === 'in_progress') {
// Claiming needs an agent: use the current assignee, else the one named // Claiming needs an agent: use the current assignee, else the one named
// in the title. The architect assigns via MCP/CLI, so most cards arrive // in the title, else mark it as a manual board claim.
// here already assigned. var titledAgent = assigned ? '' : agentFromTitle(id);
var agent = assigned || agentFromTitle(id); var agent = assigned || titledAgent || 'manual';
if (!agent) {
toast('No agent yet — let the architect assign this task first', { error: true, ms: 4200 });
return;
}
await patchTask(id, { status: 'in_progress', assignedTo: agent }); await patchTask(id, { status: 'in_progress', assignedTo: agent });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (assigned ? '' : ' (from title)')); toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
} else if (status === 'review') { } else if (status === 'review') {
var reviewed = await patchTask(id, { status: 'review' }); var reviewed = await patchTask(id, { status: 'review' });
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : '')); toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));

View File

@ -12,6 +12,7 @@ export interface AgentHubEvent {
status?: string; status?: string;
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
claimedBy?: string;
reviewer?: string; reviewer?: string;
} }

View File

@ -60,7 +60,7 @@ function toEvent(
case 'task': case 'task':
return { return {
stamp, stamp,
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), reviewer: str(fm.reviewer) }, event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), claimedBy: str(fm.claimedBy), reviewer: str(fm.reviewer) },
}; };
case 'handoff': case 'handoff':
return { return {

View File

@ -11,6 +11,16 @@ export function buildApp(cwd: string) {
return app; return app;
} }
function isTestRuntime(): boolean {
const lifecycle = process.env.npm_lifecycle_event ?? '';
return process.env.NODE_ENV === 'test'
|| process.env.VITEST === 'true'
|| process.env.VITEST_WORKER_ID !== undefined
|| process.env.VITEST_POOL_ID !== undefined
|| lifecycle === 'test'
|| lifecycle.startsWith('test:');
}
export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> { export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> {
const app = buildApp(cwd); const app = buildApp(cwd);
const port = options.port ?? 3377; const port = options.port ?? 3377;
@ -36,8 +46,12 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`; const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
console.log(`AgentHub: server listening on ${url}`); console.log(`AgentHub: server listening on ${url}`);
const advertiseUrl = resolveAdvertiseUrl(host, actualPort); // Skip LAN advertisers under test: their background timers (mDNS re-advertise
broadcaster = startDiscoveryBroadcaster(advertiseUrl); // poll + UDP discovery broadcaster) fire during unrelated tests, flood the
// network with duplicate mDNS publishes ("Service name already in use") and
// leak non-string logs into single-instance assertions. No production change.
if (!isTestRuntime()) {
broadcaster = startDiscoveryBroadcaster(() => resolveAdvertiseUrl(host, actualPort));
// Advertise a browsable LAN hostname over mDNS (best-effort). // Advertise a browsable LAN hostname over mDNS (best-effort).
mdns = startMdnsAdvertise({ port: actualPort }); mdns = startMdnsAdvertise({ port: actualPort });
@ -45,6 +59,7 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`; const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`); console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
} }
}
return { app, url }; return { app, url };
} catch (err) { } catch (err) {
@ -55,7 +70,7 @@ export async function startServer(cwd: string, options: { port?: number; host?:
`Stop it, or start with a different --port.`, `Stop it, or start with a different --port.`,
); );
} else { } else {
console.error(err); console.error(err instanceof Error ? err.message : String(err));
} }
process.exit(1); process.exit(1);
} }

View File

@ -1,4 +1,5 @@
import { Bonjour, type Service } from 'bonjour-service'; import { Bonjour, type Service } from 'bonjour-service';
import { getLanIPv4 } from '../discovery.js';
/** /**
* Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN * Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN
@ -15,38 +16,80 @@ export interface MdnsHandle {
stop: () => void; stop: () => void;
} }
export function startMdnsAdvertise(opts: { port: number; name?: string }): MdnsHandle | undefined { export interface MdnsAdvertiseOptions {
port: number;
name?: string;
pollMs?: number;
getIp?: () => string | undefined;
createBonjour?: () => Bonjour;
}
export function startMdnsAdvertise(opts: MdnsAdvertiseOptions): MdnsHandle | undefined {
const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub'; const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub';
const hostname = `${base}.local`; const hostname = `${base}.local`;
const pollMs = opts.pollMs ?? 15_000;
const readIp = opts.getIp ?? getLanIPv4;
const createBonjour = opts.createBonjour ?? (() => new Bonjour());
let currentIp = readIp();
let bonjour: Bonjour | undefined;
let service: Service | undefined;
let pollTimer: ReturnType<typeof setInterval> | undefined;
const stopCurrent = () => {
try { try {
const bonjour = new Bonjour(); service?.stop?.();
const service: Service = bonjour.publish({ } catch {
/* ignore */
}
try {
bonjour?.destroy();
} catch {
/* ignore */
}
service = undefined;
bonjour = undefined;
};
const publish = () => {
bonjour = createBonjour();
service = bonjour.publish({
name: 'AgentHub', name: 'AgentHub',
type: 'http', type: 'http',
port: opts.port, port: opts.port,
host: hostname, host: hostname,
txt: { path: '/board' }, txt: { path: '/board', address: currentIp ?? '' },
}); });
// Swallow responder errors — advertisement is optional infrastructure. // Swallow responder errors — advertisement is optional infrastructure.
service.on('error', () => { service.on('error', () => {
/* best-effort */ /* best-effort */
}); });
};
try {
publish();
pollTimer = setInterval(() => {
const nextIp = readIp();
const changed = nextIp !== currentIp;
if (!changed && service) return;
if (changed) {
currentIp = nextIp;
stopCurrent();
}
try {
publish();
} catch {
// Keep polling; a later network state may be publishable.
}
}, pollMs);
return { return {
hostname, hostname,
stop: () => { stop: () => {
try { if (pollTimer) clearInterval(pollTimer);
service.stop?.(); stopCurrent();
} catch {
/* ignore */
}
try {
bonjour.destroy();
} catch {
/* ignore */
}
}, },
}; };
} catch { } catch {
stopCurrent();
return undefined; return undefined;
} }
} }

View File

@ -44,14 +44,11 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
} }
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> { export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
// Never let a browser cache a hub HTML page — otherwise a stale board/team // Default dynamic responses to no-store so board reloads/fetches never reuse
// page keeps showing old markup and doesn't reflect live task/agent state. // stale task JSON or HTML. Static asset routes override this with cacheable
app.addHook('onSend', async (_request, reply, payload) => { // headers, and the SSE route writes its own raw no-cache header.
const ct = reply.getHeader('content-type'); app.addHook('onRequest', async (_request, reply) => {
if (typeof ct === 'string' && ct.includes('text/html')) {
reply.header('Cache-Control', 'no-store, must-revalidate'); reply.header('Cache-Control', 'no-store, must-revalidate');
}
return payload;
}); });
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and // Static, self-contained Trello-like board. Polls /tasks, /handoffs and
@ -234,6 +231,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer, reviewer: task.reviewer,
}, },
task.updatedAt, task.updatedAt,
@ -312,6 +310,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: assigned.status, status: assigned.status,
role: assigned.role, role: assigned.role,
assignedTo: assigned.assignedTo, assignedTo: assigned.assignedTo,
claimedBy: assigned.claimedBy,
reviewer: assigned.reviewer, reviewer: assigned.reviewer,
}, },
assigned.updatedAt, assigned.updatedAt,
@ -322,8 +321,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
let task: Task; let task: Task;
switch (patch.status) { switch (patch.status) {
case 'in_progress': case 'in_progress':
if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)'); {
task = claimTask(cwd, id, patch.assignedTo); const current = getTask(cwd, id).task;
const agent = patch.assignedTo?.trim() || current.assignedTo || 'manual';
task = claimTask(cwd, id, agent);
}
break; break;
case 'done': case 'done':
task = doneTask(cwd, id, { task = doneTask(cwd, id, {
@ -354,6 +356,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer, reviewer: task.reviewer,
}, },
task.updatedAt, task.updatedAt,
@ -441,6 +444,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
action: 'created', action: 'created',
id: message.id, id: message.id,
title: `${message.from}${message.to}`, title: `${message.from}${message.to}`,
assignedTo: message.to,
}, },
message.updatedAt, message.updatedAt,
); );

View File

@ -29,4 +29,17 @@ describe('discovery', () => {
broadcaster.stop(); broadcaster.stop();
} }
}); });
it('broadcasts the latest server URL without restarting the broadcaster', async () => {
const port = 53379;
let url = 'http://127.0.0.1:3377';
const broadcaster = startDiscoveryBroadcaster(() => url, { port, intervalMs: 50 });
try {
expect(await discoverServer(1000, port)).toBe(url);
url = 'http://127.0.0.1:4477';
expect(await discoverServer(1000, port)).toBe(url);
} finally {
broadcaster.stop();
}
});
}); });

43
tests/mdns.test.ts Normal file
View File

@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { startMdnsAdvertise } from '../src/server/mdns.js';
import type { Bonjour, Service } from 'bonjour-service';
describe('mDNS advertise', () => {
it('republishes after an IP change and stops the old service first', async () => {
let ip = '192.168.1.10';
const calls: string[] = [];
const published: Array<{ txt?: Record<string, string> }> = [];
const createBonjour = () =>
({
publish(opts: { txt?: Record<string, string> }) {
calls.push(`publish:${opts.txt?.address ?? ''}`);
published.push(opts);
return {
on: vi.fn(),
stop: vi.fn(() => calls.push('stop')),
} as unknown as Service;
},
destroy: vi.fn(() => calls.push('destroy')),
}) as unknown as Bonjour;
const handle = startMdnsAdvertise({
port: 3377,
pollMs: 25,
getIp: () => ip,
createBonjour,
});
expect(handle).toBeDefined();
expect(published[0]?.txt?.address).toBe('192.168.1.10');
ip = '192.168.1.44';
await new Promise((resolve) => setTimeout(resolve, 70));
expect(published).toHaveLength(2);
expect(published[1]?.txt?.address).toBe('192.168.1.44');
expect(calls).toEqual(['publish:192.168.1.10', 'stop', 'destroy', 'publish:192.168.1.44']);
handle?.stop();
});
});

39
tests/message-cmd.test.ts Normal file
View File

@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { messageRead, inboxMarkRead } from '../src/cli/commands/message.js';
import { createMessage, listInbox } from '../src/core/services/messageService.js';
describe('message commands', () => {
let cwd: string;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-cmd-'));
await init(cwd, { yes: true, projectName: 'test' });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('messageRead marks one message as read', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'done' });
messageRead(cwd, msg.id);
expect(listInbox(cwd, 'claude', { unreadOnly: true })).toHaveLength(0);
expect(listInbox(cwd, 'claude')).toMatchObject([{ id: msg.id, status: 'read' }]);
});
it('inboxMarkRead bulk-marks unread alias messages for architect', () => {
createMessage(cwd, { from: 'windows-claude', to: 'architect', text: 'ping 1' });
createMessage(cwd, { from: 'codex', to: 'claude', text: 'ping 2' });
expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(2);
inboxMarkRead(cwd, { agent: 'architect', unreadOnly: true });
expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(0);
expect(listInbox(cwd, 'claude')).toHaveLength(2);
});
});

View File

@ -33,6 +33,7 @@ describe('server routes', () => {
it('lists tasks via GET /tasks', async () => { it('lists tasks via GET /tasks', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'GET', url: '/tasks' }); const res = await app.inject({ method: 'GET', url: '/tasks' });
expect(res.headers['cache-control']).toContain('no-store');
expect(JSON.parse(res.payload)).toHaveLength(1); expect(JSON.parse(res.payload)).toHaveLength(1);
}); });
@ -47,10 +48,18 @@ describe('server routes', () => {
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
it('returns 400 when claiming without assignedTo', async () => { it('claims manually when setting in_progress without assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'manual', claimedBy: 'manual' });
});
it('claims with the existing assignee when setting in_progress without assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'codex', claimedBy: 'codex' });
}); });
it('PATCH /tasks/:id → review', async () => { it('PATCH /tasks/:id → review', async () => {
@ -129,6 +138,10 @@ describe('server routes', () => {
} }
// Board polling stays wired; handoffs/decisions now live on dedicated pages. // Board polling stays wired; handoffs/decisions now live on dedicated pages.
expect(html).toContain("getJSON('/tasks')"); expect(html).toContain("getJSON('/tasks')");
expect(html).toContain("cache: 'no-store'");
expect(html).toContain("window.addEventListener('pageshow'");
expect(html).toContain("document.addEventListener('visibilitychange'");
expect(html).toContain("var agent = assigned || titledAgent || 'manual'");
expect(html).toContain('setInterval(refresh'); expect(html).toContain('setInterval(refresh');
expect(html).toContain('Token Insights'); expect(html).toContain('Token Insights');
expect(html).toContain('data-budget-mode="session"'); expect(html).toContain('data-budget-mode="session"');

View File

@ -48,10 +48,27 @@ describe('single source of truth', () => {
const port = Number(new URL(server.url).port); const port = Number(new URL(server.url).port);
const logs: string[] = []; const logs: string[] = [];
const original = console.log; const original = console.log;
console.log = (msg: string) => logs.push(msg); console.log = (msg: unknown) => logs.push(String(msg));
try {
await serverStart(cwd, { host: '127.0.0.1', port }); await serverStart(cwd, { host: '127.0.0.1', port });
} finally {
console.log = original; console.log = original;
}
expect(logs.some((m) => m.includes('already running'))).toBe(true); expect(logs.some((m) => m.includes('already running'))).toBe(true);
}); });
it('does not start LAN advertisers during tests', async () => {
const logs: string[] = [];
const original = console.log;
console.log = (msg: unknown) => logs.push(String(msg));
let isolated: Awaited<ReturnType<typeof startServer>> | undefined;
try {
isolated = await startServer(cwd, { host: '127.0.0.1', port: 0 });
} finally {
console.log = original;
}
await isolated?.app.close();
expect(logs.some((m) => m.includes('reachable in a browser'))).toBe(false);
});
}); });
}); });

View File

@ -637,3 +637,43 @@ describe('watch --await-review', () => {
await expect(watching).resolves.toBeUndefined(); await expect(watching).resolves.toBeUndefined();
}, 5000); }, 5000);
}); });
// ─── 9. watch --await-message: architect inbox notifier ─────────────────────
describe('watch --await-message', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-await-message-'));
init(cwd, { projectName: 'await-message', 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 an unread alias message already exists', async () => {
await fetch(`${server.url}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'windows-claude', to: 'architect', text: 'need review' }),
});
await expect(watchEvents(server.url, { awaitMessage: 'claude' })).resolves.toBeUndefined();
}, 4000);
it('exits when a new message arrives for the watched alias', async () => {
const watching = watchEvents(server.url, { awaitMessage: 'architect', newOnly: true });
await new Promise((r) => setTimeout(r, 200));
await fetch(`${server.url}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'codex', to: 'claude', text: 'ready' }),
});
await expect(watching).resolves.toBeUndefined();
}, 5000);
});

View File

@ -37,6 +37,8 @@ describe('taskService', () => {
const claimed = claimTask(cwd, task.id, 'codex'); const claimed = claimTask(cwd, task.id, 'codex');
expect(claimed.status).toBe('in_progress'); expect(claimed.status).toBe('in_progress');
expect(claimed.assignedTo).toBe('codex'); expect(claimed.assignedTo).toBe('codex');
expect(claimed.claimedBy).toBe('codex');
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
const done = doneTask(cwd, task.id); const done = doneTask(cwd, task.id);
expect(done.status).toBe('done'); expect(done.status).toBe('done');
}); });

View File

@ -39,6 +39,7 @@ describe('agenthub work — immediate claim (local)', () => {
describe('agenthub work — wait then auto-claim (server SSE)', () => { describe('agenthub work — wait then auto-claim (server SSE)', () => {
let cwd: string; let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>; let server: Awaited<ReturnType<typeof startServer>>;
let nextServer: Awaited<ReturnType<typeof startServer>> | undefined;
beforeEach(async () => { beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-')); cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
@ -47,7 +48,8 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
}); });
afterEach(async () => { afterEach(async () => {
await server.app.close(); await server.app.close().catch(() => undefined);
await nextServer?.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}); });
@ -77,4 +79,35 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
expect(mine?.status).toBe('in_progress'); expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi'); expect(mine?.assignedTo).toBe('kimi');
}, 6000); }, 6000);
it('reconnects after the SSE server moves and claims a later task', async () => {
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 6,
reconnectBackoffMs: [50, 100],
discoverServer: async () => nextServer?.url,
});
await new Promise((r) => setTimeout(r, 200));
server.app.server.closeAllConnections?.();
await server.app.close();
nextServer = await startServer(cwd, { host: '127.0.0.1', port: 0 });
await fetch(`${nextServer.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: delegated after reconnect', role: 'implementer' }),
});
await workDone;
const tasks = (await fetch(`${nextServer.url}/tasks`).then((r) => r.json())) as Task[];
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi: delegated after reconnect'));
expect(mine).toBeDefined();
expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi');
}, 8000);
}); });