chore(agenthub): snapshot pre-existing orphaned WIP (single-instance/mDNS + claimedBy + message-read) as batch baseline
This commit is contained in:
parent
2fb9db15b1
commit
7922eeb8e8
File diff suppressed because one or more lines are too long
@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
|
||||||
|
}
|
||||||
|
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();
|
finish();
|
||||||
})
|
}
|
||||||
.catch((err: unknown) => {
|
};
|
||||||
if (!(err instanceof Error && err.name === 'AbortError')) {
|
|
||||||
|
waitLoop().catch((err: unknown) => {
|
||||||
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
|
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
`);
|
`);
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 : ''));
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
43
tests/mdns.test.ts
Normal 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
39
tests/message-cmd.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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"');
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user