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(
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}`);
}
}
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 { messageRecipientAliases } from '../../core/services/messageService.js';
// Re-export so tests can import type + helpers from one place.
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.
*
@ -148,7 +179,7 @@ async function fetchReviewTasks(serverUrl: string): Promise<AgentHubEvent[]> {
*/
export async function watchEvents(
serverUrl: string,
options: { once?: boolean; role?: string; awaitReview?: boolean; newOnly?: boolean } = {},
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {},
): Promise<void> {
const url = new URL('/events', serverUrl);
// Pass role to the server for an additional server-side filter (saves
@ -192,6 +223,14 @@ export async function watchEvents(
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) {
let done: boolean;
@ -230,6 +269,10 @@ export async function watchEvents(
await reader.cancel();
return;
}
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
await reader.cancel();
return;
}
}
}
}

View File

@ -1,5 +1,6 @@
import { parseSSEBuffer } from './watch.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
@ -13,7 +14,13 @@ import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentCont
* up automatically without a human prompt. Best run in the background so the
* 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);
// Already-waiting task?
@ -34,16 +41,30 @@ export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Pr
await waitAndClaim(ctx);
}
function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void> {
const serverUrl = ctx.serverUrl as string;
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(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) => {
const controller = new AbortController();
let settled = false;
let controller: AbortController | undefined;
const finish = () => {
if (settled) return;
settled = true;
try {
controller.abort();
controller?.abort();
} catch {
/* already aborted */
}
@ -56,10 +77,14 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
finish();
}, ctx.timeoutSec * 1000)
: 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
// if a task was claimed (so the caller can stop).
const tryClaim = async (): Promise<boolean> => {
ctx.serverUrl = serverUrl;
const f = await findAddressedOpenTask(ctx);
if (!f) return false;
if (timer) clearTimeout(timer);
@ -68,47 +93,64 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
return true;
};
fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
.then(async (res) => {
if (!res.body) {
if (timer) clearTimeout(timer);
finish();
return;
}
// Close the gap: a task may have appeared between the initial check and
// this subscription — check once more now that we're listening.
if (await tryClaim()) return;
const waitLoop = async () => {
while (!settled && remainingMs(deadline) > 0) {
controller = new AbortController();
try {
ctx.serverUrl = serverUrl;
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');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!settled) {
let done: boolean;
let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch {
break; // aborted or connection closed
}
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
// Close the gap: a task may have appeared between the initial check and
// this subscription — check once more now that we're listening.
if (await tryClaim()) return;
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
// Any task event may mean a task addressed to us just opened/reopened.
if (events.some((e) => e.type === 'task')) {
if (await tryClaim()) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
reconnectAttempt = 0;
while (!settled) {
let done: boolean;
let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch {
break; // aborted or connection closed
}
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
// Any task event may mean a task addressed to us just opened/reopened.
if (events.some((e) => e.type === 'task')) {
if (await tryClaim()) return;
}
}
} catch (err: unknown) {
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
}
if (timer) clearTimeout(timer);
finish();
})
.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.`);
}
if (timer) clearTimeout(timer);
finish();
});
}
};
waitLoop().catch((err: unknown) => {
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
if (timer) clearTimeout(timer);
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 { handoffCreate, handoffRead, handoffList } from './commands/handoff.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 { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js';
@ -457,8 +457,44 @@ export function createProgram(cwd: string): Command {
program.addCommand(decisionCmd);
// ─── messaging ───────────────────────────────────────────────────────────
program
.command('message <to> <text>')
const messageCmd = new Command('message')
.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')
.requiredOption('--from <agent>', 'Sender agent name')
.option('--task <id>', 'Related task ID')
@ -474,14 +510,26 @@ export function createProgram(cwd: string): Command {
messageSend(projectCwd, payload);
}
});
program.addCommand(messageCmd);
program
.command('inbox')
.description('Read messages addressed to an agent')
.requiredOption('--agent <agent>', 'Agent whose inbox to read')
.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);
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) {
await runRemote(serverUrl, async () => {
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) {
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 {
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('--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('--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) => {
const { serverUrl } = await resolveContext(program, cwd);
if (!serverUrl) {
@ -651,6 +705,7 @@ export function createProgram(cwd: string): Command {
once: options.once as boolean | undefined,
role: options.role as string | undefined,
awaitReview: options.awaitReview as boolean | undefined,
awaitMessage: options.awaitMessage as string | undefined,
newOnly: options.newOnly as boolean | undefined,
});
});

View File

@ -14,6 +14,7 @@ export interface IndexEntry {
status?: string;
role?: string;
assignedTo?: string;
claimedBy?: string;
reviewer?: string;
tags?: string;
// Handoff-specific routing fields
@ -46,6 +47,7 @@ export class Index {
status TEXT,
role TEXT,
assignedTo TEXT,
claimedBy TEXT,
reviewer TEXT,
tags TEXT,
fromRole TEXT,
@ -62,7 +64,7 @@ export class Index {
const existingCols = new Set(
(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)) {
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
}
@ -74,6 +76,7 @@ export class Index {
status: null,
role: null,
assignedTo: null,
claimedBy: null,
reviewer: null,
tags: null,
fromRole: null,
@ -86,12 +89,12 @@ export class Index {
};
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)
VALUES (@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, @claimedBy, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
ON CONFLICT(id) DO UPDATE SET
type=@type, title=@title, content=@content, filePath=@filePath,
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,
taskId=@taskId, relatedTasks=@relatedTasks
`);

View File

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

View File

@ -100,7 +100,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
let summary: string;
switch (task.status) {
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;
case '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 };
if (task.assignedTo) meta.assignedTo = task.assignedTo;
if (task.claimedBy) meta.claimedBy = task.claimedBy;
if (task.doneBy) meta.by = task.doneBy;
if (task.doneTokens != null) meta.tokens = task.doneTokens;
if (task.doneDuration != null) meta.duration = task.doneDuration;
@ -124,7 +125,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
items.push({
at: task.updatedAt,
kind: 'status',
actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown',
actor: task.doneBy ?? task.claimedBy ?? task.assignedTo ?? task.role ?? 'unknown',
summary,
meta,
});

View File

@ -49,6 +49,17 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
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 {
id: string;
from: string;
@ -64,8 +75,9 @@ export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boole
const index = new Index(cwd);
const all = index.list('message');
index.close();
const recipients = messageRecipientAliases(agent);
return all
.filter((m) => m.toAgent === agent)
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
.filter((m) => !opts.unreadOnly || m.status === 'unread')
.map((m) => ({
id: m.id,

View File

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

View File

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

View File

@ -21,6 +21,7 @@ import { createMessage, listInbox, markMessageRead } from '../core/services/mess
import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js';
import { getStatus } from '../core/services/statusService.js';
import { discoverServer as discoverHubServer } from '../discovery.js';
/**
* AgentHub MCP server (TSK-0030).
@ -51,44 +52,87 @@ function asText(value: unknown) {
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. */
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) => {
const controller = new AbortController();
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) => {
if (settled) return;
settled = true;
try { controller.abort(); } catch { /* already */ }
try { controller?.abort(); } catch { /* already */ }
resolve(v);
};
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
.then(async (res) => {
if (!res.body) { clearTimeout(timer); finish(null); return; }
// Close the gap: a task may have arrived between the initial check and now.
const early = await findClaim();
if (early) { clearTimeout(timer); finish(early); return; }
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!settled) {
let done: boolean; let value: Uint8Array | undefined;
try { ({ done, value } = await reader.read()); } catch { break; }
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
// Wake on a new task OR a new message addressed to the agent.
if (events.some((e) => e.type === 'task' || e.type === 'message')) {
const claimed = await findClaim();
if (claimed) { clearTimeout(timer); finish(claimed); return; }
const waitLoop = async () => {
while (!settled && remainingMs(deadline) > 0) {
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.
const early = await findClaim(currentUrl);
if (early) { clearTimeout(timer); finish(early); return; }
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
reconnectAttempt = 0;
while (!settled) {
let done: boolean; let value: Uint8Array | undefined;
try { ({ done, value } = await reader.read()); } catch { break; }
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
// Wake on a new task OR a new message addressed to the agent.
if (events.some((e) => e.type === 'task' || e.type === 'message')) {
const claimed = await findClaim(currentUrl);
if (claimed) { clearTimeout(timer); finish(claimed); return; }
}
}
} catch (err: unknown) {
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
}
clearTimeout(timer); finish(null);
})
.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 }) => {
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
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
// them once and doesn't spin on the same message.
const drainInbox = async () => {
const msgs = remote ? await remoteClient.getInbox(serverUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
const drainInbox = async (nextServerUrl?: string) => {
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) {
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;
};
const findWork = async () => {
const findWork = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl);
const found = await findAddressedOpenTask(ctx);
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);
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);
let handoff: unknown = null;
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.' };
return null;
};
// Architect/reviewer variant: wake on tasks submitted to review (not on
// 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 messages = await drainInbox();
const messages = await drainInbox(ctx.serverUrl);
if (reviews.length) {
return {
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;
};
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
// agenthub_work, so a finished task/message never leaves it dormant.

View File

@ -1000,7 +1000,7 @@ ${columnSkeleton()}
return compactDuration(Date.now() - t) + ' ago';
}
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);
return res.json();
}
@ -1324,6 +1324,8 @@ ${columnSkeleton()}
eventSourceReady = true;
stopFallbackPoll();
setConn('ok', 'connected');
refresh();
refreshBudget();
};
source.onmessage = function() {
eventSourceReady = true;
@ -1354,6 +1356,21 @@ ${columnSkeleton()}
}
window.addEventListener('pagehide', 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 ──────────────────────────────────────────────────────────────
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
@ -1697,15 +1714,11 @@ ${columnSkeleton()}
try {
if (status === 'in_progress') {
// 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
// here already assigned.
var agent = assigned || agentFromTitle(id);
if (!agent) {
toast('No agent yet — let the architect assign this task first', { error: true, ms: 4200 });
return;
}
// in the title, else mark it as a manual board claim.
var titledAgent = assigned ? '' : agentFromTitle(id);
var agent = assigned || titledAgent || 'manual';
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') {
var reviewed = await patchTask(id, { status: 'review' });
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));

View File

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

View File

@ -60,7 +60,7 @@ function toEvent(
case 'task':
return {
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':
return {

View File

@ -11,6 +11,16 @@ export function buildApp(cwd: string) {
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 }> {
const app = buildApp(cwd);
const port = options.port ?? 3377;
@ -36,14 +46,19 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
console.log(`AgentHub: server listening on ${url}`);
const advertiseUrl = resolveAdvertiseUrl(host, actualPort);
broadcaster = startDiscoveryBroadcaster(advertiseUrl);
// Skip LAN advertisers under test: their background timers (mDNS re-advertise
// 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).
mdns = startMdnsAdvertise({ port: actualPort });
if (mdns) {
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
// Advertise a browsable LAN hostname over mDNS (best-effort).
mdns = startMdnsAdvertise({ port: actualPort });
if (mdns) {
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
}
}
return { app, url };
@ -55,7 +70,7 @@ export async function startServer(cwd: string, options: { port?: number; host?:
`Stop it, or start with a different --port.`,
);
} else {
console.error(err);
console.error(err instanceof Error ? err.message : String(err));
}
process.exit(1);
}

View File

@ -1,4 +1,5 @@
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
@ -15,38 +16,80 @@ export interface MdnsHandle {
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 hostname = `${base}.local`;
try {
const bonjour = new Bonjour();
const service: Service = bonjour.publish({
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 {
service?.stop?.();
} catch {
/* ignore */
}
try {
bonjour?.destroy();
} catch {
/* ignore */
}
service = undefined;
bonjour = undefined;
};
const publish = () => {
bonjour = createBonjour();
service = bonjour.publish({
name: 'AgentHub',
type: 'http',
port: opts.port,
host: hostname,
txt: { path: '/board' },
txt: { path: '/board', address: currentIp ?? '' },
});
// Swallow responder errors — advertisement is optional infrastructure.
service.on('error', () => {
/* 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 {
hostname,
stop: () => {
try {
service.stop?.();
} catch {
/* ignore */
}
try {
bonjour.destroy();
} catch {
/* ignore */
}
if (pollTimer) clearInterval(pollTimer);
stopCurrent();
},
};
} catch {
stopCurrent();
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> {
// Never let a browser cache a hub HTML page — otherwise a stale board/team
// page keeps showing old markup and doesn't reflect live task/agent state.
app.addHook('onSend', async (_request, reply, payload) => {
const ct = reply.getHeader('content-type');
if (typeof ct === 'string' && ct.includes('text/html')) {
reply.header('Cache-Control', 'no-store, must-revalidate');
}
return payload;
// Default dynamic responses to no-store so board reloads/fetches never reuse
// stale task JSON or HTML. Static asset routes override this with cacheable
// headers, and the SSE route writes its own raw no-cache header.
app.addHook('onRequest', async (_request, reply) => {
reply.header('Cache-Control', 'no-store, must-revalidate');
});
// 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,
role: task.role,
assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer,
},
task.updatedAt,
@ -312,6 +310,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: assigned.status,
role: assigned.role,
assignedTo: assigned.assignedTo,
claimedBy: assigned.claimedBy,
reviewer: assigned.reviewer,
},
assigned.updatedAt,
@ -322,8 +321,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
let task: Task;
switch (patch.status) {
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;
case 'done':
task = doneTask(cwd, id, {
@ -354,6 +356,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status,
role: task.role,
assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer,
},
task.updatedAt,
@ -441,6 +444,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
action: 'created',
id: message.id,
title: `${message.from}${message.to}`,
assignedTo: message.to,
},
message.updatedAt,
);

View File

@ -29,4 +29,17 @@ describe('discovery', () => {
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 () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'GET', url: '/tasks' });
expect(res.headers['cache-control']).toContain('no-store');
expect(JSON.parse(res.payload)).toHaveLength(1);
});
@ -47,10 +48,18 @@ describe('server routes', () => {
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' } });
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 () => {
@ -129,6 +138,10 @@ describe('server routes', () => {
}
// Board polling stays wired; handoffs/decisions now live on dedicated pages.
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('Token Insights');
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 logs: string[] = [];
const original = console.log;
console.log = (msg: string) => logs.push(msg);
await serverStart(cwd, { host: '127.0.0.1', port });
console.log = original;
console.log = (msg: unknown) => logs.push(String(msg));
try {
await serverStart(cwd, { host: '127.0.0.1', port });
} finally {
console.log = original;
}
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();
}, 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');
expect(claimed.status).toBe('in_progress');
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);
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)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
let nextServer: Awaited<ReturnType<typeof startServer>> | undefined;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
@ -47,7 +48,8 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
});
afterEach(async () => {
await server.app.close();
await server.app.close().catch(() => undefined);
await nextServer?.app.close().catch(() => undefined);
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?.assignedTo).toBe('kimi');
}, 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);
});